-
Notifications
You must be signed in to change notification settings - Fork 12
CS-10617: Reimplement boxel workspace push command #4356
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 2 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
08aedb6
CS-10617: Reimplement `boxel workspace push` command
FadhlanR ab6364f
Move push command under realm/ subcommand with ProfileManager auth an…
FadhlanR ec596d3
Rename workspaceUrl to realmUrl and simplify RealmPuller checkpoint u…
FadhlanR 14057e8
Make RealmPuller/CheckpointManager async, add tests
FadhlanR 5fb4eaa
Expand realm-push tests with direct endpoint checks and flag matrix
FadhlanR de7cb0a
Push via /_atomic with drift detection and manifest validation
FadhlanR 73d80c0
Add node_modules ignore, concurrency limits, and URL normalization
FadhlanR File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,273 @@ | ||
| import type { Command } from 'commander'; | ||
| import { RealmSyncBase, isProtectedFile, type SyncOptions } from '../../lib/realm-sync-base'; | ||
| import { | ||
| CheckpointManager, | ||
| type CheckpointChange, | ||
| } from '../../lib/checkpoint-manager'; | ||
| import { | ||
| getProfileManager, | ||
| type ProfileManager, | ||
| } from '../../lib/profile-manager'; | ||
| import * as fs from 'fs'; | ||
| import * as path from 'path'; | ||
| import * as crypto from 'crypto'; | ||
|
|
||
| interface SyncManifest { | ||
| workspaceUrl: string; | ||
| files: Record<string, string>; // relativePath -> contentHash | ||
| } | ||
|
|
||
| function computeFileHash(filePath: string): string { | ||
| const content = fs.readFileSync(filePath); | ||
| return crypto.createHash('md5').update(content).digest('hex'); | ||
| } | ||
|
|
||
| function loadManifest(localDir: string): SyncManifest | null { | ||
| const manifestPath = path.join(localDir, '.boxel-sync.json'); | ||
| if (fs.existsSync(manifestPath)) { | ||
| try { | ||
| return JSON.parse(fs.readFileSync(manifestPath, 'utf8')); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| function saveManifest(localDir: string, manifest: SyncManifest): void { | ||
| const manifestPath = path.join(localDir, '.boxel-sync.json'); | ||
| fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); | ||
| } | ||
|
|
||
| interface PushOptions extends SyncOptions { | ||
| deleteRemote?: boolean; | ||
| force?: boolean; | ||
| } | ||
|
|
||
| class RealmPusher extends RealmSyncBase { | ||
| hasError = false; | ||
|
|
||
| constructor( | ||
| private pushOptions: PushOptions, | ||
| profileManager: ProfileManager, | ||
| ) { | ||
| super(pushOptions, profileManager); | ||
| } | ||
|
|
||
| async sync(): Promise<void> { | ||
| console.log( | ||
| `Starting push from ${this.options.localDir} to ${this.options.workspaceUrl}`, | ||
| ); | ||
|
|
||
| console.log('Testing workspace access...'); | ||
| try { | ||
| await this.getRemoteFileList(''); | ||
| } catch (error) { | ||
| console.error('Failed to access workspace:', error); | ||
| throw new Error( | ||
| 'Cannot proceed with push: Authentication or access failed. ' + | ||
| 'Please check your credentials and workspace permissions.', | ||
| ); | ||
| } | ||
| console.log('Workspace access verified'); | ||
|
|
||
| const localFiles = await this.getLocalFileList(); | ||
| console.log(`Found ${localFiles.size} files in local directory`); | ||
|
|
||
| const manifest = loadManifest(this.options.localDir); | ||
| const newManifest: SyncManifest = { | ||
| workspaceUrl: this.options.workspaceUrl, | ||
| files: {}, | ||
| }; | ||
|
|
||
| const filesToUpload: Map<string, string> = new Map(); | ||
|
|
||
| if ( | ||
| this.pushOptions.force || | ||
| !manifest || | ||
| manifest.workspaceUrl !== this.options.workspaceUrl | ||
| ) { | ||
| if (this.pushOptions.force) { | ||
| console.log('Force mode: uploading all files'); | ||
| } else if (!manifest) { | ||
| console.log('No sync manifest found, will upload all files'); | ||
| } else { | ||
| console.log('Workspace URL changed, will upload all files'); | ||
| } | ||
| for (const [relativePath, localPath] of localFiles) { | ||
| if (isProtectedFile(relativePath)) continue; | ||
| filesToUpload.set(relativePath, localPath); | ||
| } | ||
| } else { | ||
| console.log('Checking for changed files...'); | ||
| let skipped = 0; | ||
|
|
||
| for (const [relativePath, localPath] of localFiles) { | ||
| if (isProtectedFile(relativePath)) { | ||
| skipped++; | ||
| continue; | ||
| } | ||
| const currentHash = computeFileHash(localPath); | ||
| const previousHash = manifest.files[relativePath]; | ||
|
FadhlanR marked this conversation as resolved.
Outdated
|
||
|
|
||
| if (previousHash !== currentHash) { | ||
| filesToUpload.set(relativePath, localPath); | ||
|
FadhlanR marked this conversation as resolved.
Outdated
|
||
| } else { | ||
| skipped++; | ||
| newManifest.files[relativePath] = currentHash; | ||
| } | ||
| } | ||
|
|
||
| if (skipped > 0) { | ||
| console.log(`Skipping ${skipped} unchanged files`); | ||
| } | ||
| } | ||
|
|
||
| if (filesToUpload.size === 0) { | ||
| console.log('No files to upload - everything is up to date'); | ||
| } else { | ||
| console.log(`Uploading ${filesToUpload.size} file(s)...`); | ||
|
|
||
| for (const [relativePath, localPath] of filesToUpload) { | ||
| try { | ||
| await this.uploadFile(relativePath, localPath); | ||
|
FadhlanR marked this conversation as resolved.
Outdated
|
||
| newManifest.files[relativePath] = computeFileHash(localPath); | ||
| } catch (error) { | ||
| this.hasError = true; | ||
| console.error(`Error uploading ${relativePath}:`, error); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (this.pushOptions.deleteRemote) { | ||
| const remoteFiles = await this.getRemoteFileList(); | ||
| const filesToDelete = new Set(remoteFiles.keys()); | ||
|
|
||
| for (const relativePath of filesToDelete) { | ||
| if (isProtectedFile(relativePath)) { | ||
| filesToDelete.delete(relativePath); | ||
| } | ||
| } | ||
|
|
||
| for (const relativePath of localFiles.keys()) { | ||
| filesToDelete.delete(relativePath); | ||
| } | ||
|
|
||
| if (filesToDelete.size > 0) { | ||
| console.log( | ||
| `Deleting ${filesToDelete.size} remote files that don't exist locally`, | ||
| ); | ||
|
|
||
| for (const relativePath of filesToDelete) { | ||
| try { | ||
| await this.deleteFile(relativePath); | ||
| } catch (error) { | ||
| this.hasError = true; | ||
| console.error(`Error deleting ${relativePath}:`, error); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (!this.options.dryRun) { | ||
| saveManifest(this.options.localDir, newManifest); | ||
| } | ||
|
|
||
| if (!this.options.dryRun && filesToUpload.size > 0) { | ||
| const checkpointManager = new CheckpointManager(this.options.localDir); | ||
| const pushChanges: CheckpointChange[] = Array.from( | ||
| filesToUpload.keys(), | ||
| ).map((f) => ({ | ||
| file: f, | ||
| status: 'modified' as const, | ||
| })); | ||
| const checkpoint = checkpointManager.createCheckpoint( | ||
| 'local', | ||
| pushChanges, | ||
| ); | ||
| if (checkpoint) { | ||
| const tag = checkpoint.isMajor ? '[MAJOR]' : '[minor]'; | ||
| console.log( | ||
| `\nCheckpoint created: ${checkpoint.shortHash} ${tag} ${checkpoint.message}`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| console.log('Push completed'); | ||
| } | ||
| } | ||
|
|
||
| export interface PushCommandOptions { | ||
| delete?: boolean; | ||
| dryRun?: boolean; | ||
| force?: boolean; | ||
| profileManager?: ProfileManager; | ||
| } | ||
|
|
||
| export function registerPushCommand(realm: Command): void { | ||
| realm | ||
| .command('push') | ||
| .description('Push local files to a Boxel realm') | ||
| .argument('<local-dir>', 'The local directory containing files to sync') | ||
| .argument( | ||
| '<workspace-url>', | ||
| 'The URL of the target workspace (e.g., https://app.boxel.ai/demo/)', | ||
| ) | ||
| .option('--delete', 'Delete remote files that do not exist locally') | ||
| .option('--dry-run', 'Show what would be done without making changes') | ||
| .option('--force', 'Upload all files, even if unchanged') | ||
| .action( | ||
| async ( | ||
| localDir: string, | ||
| workspaceUrl: string, | ||
| options: { delete?: boolean; dryRun?: boolean; force?: boolean }, | ||
| ) => { | ||
| await pushCommand(localDir, workspaceUrl, options); | ||
| }, | ||
| ); | ||
| } | ||
|
|
||
| export async function pushCommand( | ||
| localDir: string, | ||
| workspaceUrl: string, | ||
| options: PushCommandOptions, | ||
| ): Promise<void> { | ||
| let pm = options.profileManager ?? getProfileManager(); | ||
| let active = pm.getActiveProfile(); | ||
| if (!active) { | ||
| console.error( | ||
| 'Error: no active profile. Run `boxel profile add` to create one.', | ||
| ); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| if (!fs.existsSync(localDir)) { | ||
| console.error(`Local directory does not exist: ${localDir}`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| try { | ||
| const pusher = new RealmPusher( | ||
| { | ||
| workspaceUrl, | ||
| localDir, | ||
| deleteRemote: options.delete, | ||
| dryRun: options.dryRun, | ||
| force: options.force, | ||
| }, | ||
| pm, | ||
| ); | ||
|
|
||
| await pusher.sync(); | ||
|
|
||
| if (pusher.hasError) { | ||
| console.log('Push did not complete successfully. View logs for details'); | ||
| process.exit(2); | ||
| } else { | ||
| console.log('Push completed successfully'); | ||
| } | ||
| } catch (error) { | ||
| console.error('Push failed:', error); | ||
| process.exit(1); | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm super confused--i thought we were reimplementing this, but RealmSyncBase is already in the repo? which commit added that? we are we not reimplementing that too?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
RealmSyncBase is added here: 72d1f10#diff-85179b7b382ff103db64d2324166436d7ef6db399991b68fea86fe9a08376a73
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why is your implementation of this so close to the existing boxel-cli?