From 77e92a1fbaa4829ed47352a679c76eedef2abb26 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Wed, 8 Apr 2026 16:15:35 +0700 Subject: [PATCH] CS-10617: Reimplement `boxel workspace push` command Port push command from standalone boxel-cli into monorepo. Adds manifest-based incremental sync via .boxel-sync.json with MD5 hashing. Supports --delete, --dry-run, --force options. Protected files (.realm.json) are never uploaded or deleted. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/boxel-cli/src/commands/push.ts | 248 ++++++++++++++++ packages/boxel-cli/src/index.ts | 22 ++ .../boxel-cli/tests/commands/push.test.ts | 270 ++++++++++++++++++ 3 files changed, 540 insertions(+) create mode 100644 packages/boxel-cli/src/commands/push.ts create mode 100644 packages/boxel-cli/tests/commands/push.test.ts diff --git a/packages/boxel-cli/src/commands/push.ts b/packages/boxel-cli/src/commands/push.ts new file mode 100644 index 0000000000..e405c61b67 --- /dev/null +++ b/packages/boxel-cli/src/commands/push.ts @@ -0,0 +1,248 @@ +import { + RealmSyncBase, + validateMatrixEnvVars, + isProtectedFile, + type SyncOptions, +} from '../lib/realm-sync-base.js'; +import { + CheckpointManager, + type CheckpointChange, +} from '../lib/checkpoint-manager.js'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; + +interface SyncManifest { + workspaceUrl: string; + files: Record; // 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, + matrixUrl: string, + username: string, + password: string, + ) { + super(pushOptions, matrixUrl, username, password); + } + + async sync(): Promise { + 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 Matrix 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 = 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]; + + if (previousHash !== currentHash) { + filesToUpload.set(relativePath, localPath); + } 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); + 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; +} + +export async function pushCommand( + localDir: string, + workspaceUrl: string, + options: PushCommandOptions, +): Promise { + const { matrixUrl, username, password } = + await validateMatrixEnvVars(workspaceUrl); + + 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, + }, + matrixUrl, + username, + password, + ); + + await pusher.initialize(); + 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); + } +} diff --git a/packages/boxel-cli/src/index.ts b/packages/boxel-cli/src/index.ts index efcf53fed5..a5f4d85d7c 100644 --- a/packages/boxel-cli/src/index.ts +++ b/packages/boxel-cli/src/index.ts @@ -4,6 +4,7 @@ import { readFileSync } from 'fs'; import { resolve } from 'path'; import { profileCommand } from './commands/profile.js'; import { pullCommand } from './commands/pull.js'; +import { pushCommand } from './commands/push.js'; const pkg = JSON.parse( readFileSync(resolve(__dirname, '../package.json'), 'utf-8'), @@ -64,4 +65,25 @@ workspace }, ); +workspace + .command('push') + .description('Push local files to a Boxel workspace') + .argument('', 'The local directory containing files to sync') + .argument( + '', + '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); + }, + ); + program.parse(); diff --git a/packages/boxel-cli/tests/commands/push.test.ts b/packages/boxel-cli/tests/commands/push.test.ts new file mode 100644 index 0000000000..5b70f6dc55 --- /dev/null +++ b/packages/boxel-cli/tests/commands/push.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + createRealmState, + type RealmServerState, +} from '../helpers/mock-realm-server.js'; +import { createMockFetch, type FetchCall } from '../helpers/mock-fetch.js'; +import { TEST_REALM_URL } from '../helpers/mock-credentials.js'; + +const mockCredentials = vi.hoisted(() => ({ + matrixUrl: 'https://matrix.test.local/', + username: 'testuser', + password: 'testpassword', +})); + +vi.mock('../../src/lib/realm-sync-base.js', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + validateMatrixEnvVars: vi.fn().mockResolvedValue(mockCredentials), + }; +}); + +vi.mock('../../src/lib/checkpoint-manager.js', () => ({ + CheckpointManager: vi.fn().mockImplementation(() => ({ + createCheckpoint: vi.fn().mockReturnValue({ + shortHash: 'abc1234', + message: 'test checkpoint', + isMajor: false, + }), + })), +})); + +// @ts-expect-error vitest supports top-level await even in CJS mode +const { pushCommand } = await import('../../src/commands/push.js'); + +describe('push integration', () => { + let tmpDir: string; + let realmState: RealmServerState; + let calls: FetchCall[]; + let originalFetch: typeof fetch; + + beforeEach(() => { + tmpDir = fs.mkdtempSync( + path.join(process.env.TMPDIR || '/tmp', 'boxel-push-test-'), + ); + originalFetch = globalThis.fetch; + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation( + (code?: string | number | null | undefined) => { + throw new Error(`process.exit(${code})`); + }, + ); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.mocked(console.log).mockRestore?.(); + vi.mocked(console.error).mockRestore?.(); + vi.mocked(console.warn).mockRestore?.(); + vi.mocked(process.exit).mockRestore?.(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function setupFetch( + state: RealmServerState, + onRequest?: (url: string, method: string) => void, + ) { + const result = createMockFetch({ realmState: state, onRequest }); + calls = result.calls; + globalThis.fetch = result.mockFetch; + return result; + } + + function writeLocalFile(localDir: string, relPath: string, content: string) { + const fullPath = path.join(localDir, relPath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + } + + it('pushes local files to empty remote', async () => { + realmState = createRealmState({}); + setupFetch(realmState); + + const localDir = path.join(tmpDir, 'workspace'); + writeLocalFile( + localDir, + 'BlogPost/hello.json', + '{"data":{"attributes":{"title":"Hello"}}}', + ); + writeLocalFile( + localDir, + 'my-card.gts', + 'export class MyCard extends CardDef {}', + ); + + await pushCommand(localDir, TEST_REALM_URL, {}); + + expect(realmState.files.has('BlogPost/hello.json')).toBe(true); + expect(realmState.files.get('BlogPost/hello.json')?.content).toBe( + '{"data":{"attributes":{"title":"Hello"}}}', + ); + expect(realmState.files.has('my-card.gts')).toBe(true); + + const postCalls = calls.filter( + (c) => + c.method === 'POST' && + !c.url.includes('_session') && + !c.url.includes('_matrix'), + ); + expect(postCalls.length).toBe(2); + + expect(fs.existsSync(path.join(localDir, '.boxel-sync.json'))).toBe(true); + const manifest = JSON.parse( + fs.readFileSync(path.join(localDir, '.boxel-sync.json'), 'utf-8'), + ); + expect(manifest.workspaceUrl).toBe(TEST_REALM_URL); + expect(Object.keys(manifest.files).length).toBe(2); + }); + + it('incremental push skips unchanged files', async () => { + realmState = createRealmState({}); + setupFetch(realmState); + + const localDir = path.join(tmpDir, 'workspace'); + writeLocalFile( + localDir, + 'BlogPost/hello.json', + '{"data":{"attributes":{"title":"Hello"}}}', + ); + writeLocalFile( + localDir, + 'BlogPost/world.json', + '{"data":{"attributes":{"title":"World"}}}', + ); + + await pushCommand(localDir, TEST_REALM_URL, {}); + + writeLocalFile( + localDir, + 'BlogPost/hello.json', + '{"data":{"attributes":{"title":"Hello Updated"}}}', + ); + + const result2 = createMockFetch({ realmState }); + calls = result2.calls; + globalThis.fetch = result2.mockFetch; + + await pushCommand(localDir, TEST_REALM_URL, {}); + + const postCalls = calls.filter( + (c) => + c.method === 'POST' && + !c.url.includes('_session') && + !c.url.includes('_matrix'), + ); + expect(postCalls.length).toBe(1); + expect(postCalls[0].url).toContain('hello.json'); + }); + + it('push with --force uploads all files regardless of manifest', async () => { + realmState = createRealmState({}); + setupFetch(realmState); + + const localDir = path.join(tmpDir, 'workspace'); + writeLocalFile(localDir, 'BlogPost/hello.json', '{"title":"Hello"}'); + writeLocalFile(localDir, 'BlogPost/world.json', '{"title":"World"}'); + + await pushCommand(localDir, TEST_REALM_URL, {}); + + const result2 = createMockFetch({ realmState }); + calls = result2.calls; + globalThis.fetch = result2.mockFetch; + + await pushCommand(localDir, TEST_REALM_URL, { force: true }); + + const postCalls = calls.filter( + (c) => + c.method === 'POST' && + !c.url.includes('_session') && + !c.url.includes('_matrix'), + ); + expect(postCalls.length).toBe(2); + }); + + it('push with --delete removes remote-only files', async () => { + realmState = createRealmState({ + 'BlogPost/old.json': { content: '{"title":"Old"}' }, + 'BlogPost/orphan.json': { content: '{"title":"Orphan"}' }, + }); + setupFetch(realmState); + + const localDir = path.join(tmpDir, 'workspace'); + writeLocalFile(localDir, 'BlogPost/hello.json', '{"title":"Hello"}'); + + await pushCommand(localDir, TEST_REALM_URL, { delete: true }); + + expect(realmState.files.has('BlogPost/hello.json')).toBe(true); + expect(realmState.files.has('BlogPost/old.json')).toBe(false); + expect(realmState.files.has('BlogPost/orphan.json')).toBe(false); + + const deleteCalls = calls.filter((c) => c.method === 'DELETE'); + expect(deleteCalls.length).toBe(2); + }); + + it('push with --dry-run makes no POST or DELETE calls', async () => { + realmState = createRealmState({ + 'BlogPost/orphan.json': { content: '{"title":"Orphan"}' }, + }); + setupFetch(realmState); + + const localDir = path.join(tmpDir, 'workspace'); + writeLocalFile(localDir, 'BlogPost/hello.json', '{"title":"Hello"}'); + + await pushCommand(localDir, TEST_REALM_URL, { dryRun: true }); + + const filePostCalls = calls.filter( + (c) => + c.method === 'POST' && + !c.url.includes('_session') && + !c.url.includes('_matrix'), + ); + expect(filePostCalls.length).toBe(0); + + const deleteCalls = calls.filter((c) => c.method === 'DELETE'); + expect(deleteCalls.length).toBe(0); + + expect(realmState.files.has('BlogPost/orphan.json')).toBe(true); + + expect(fs.existsSync(path.join(localDir, '.boxel-sync.json'))).toBe(false); + }); + + it('push with upload error still uploads other files', async () => { + realmState = createRealmState({}); + realmState.failingPaths = new Set(['BlogPost/broken.json']); + setupFetch(realmState); + + const localDir = path.join(tmpDir, 'workspace'); + writeLocalFile(localDir, 'BlogPost/good.json', '{"title":"Good"}'); + writeLocalFile(localDir, 'BlogPost/broken.json', '{"title":"Broken"}'); + + await expect(pushCommand(localDir, TEST_REALM_URL, {})).rejects.toThrow( + /process\.exit/, + ); + + expect(realmState.files.has('BlogPost/good.json')).toBe(true); + expect(realmState.files.has('BlogPost/broken.json')).toBe(false); + }); + + it('push ignores .boxel-sync.json', async () => { + realmState = createRealmState({}); + setupFetch(realmState); + + const localDir = path.join(tmpDir, 'workspace'); + writeLocalFile(localDir, 'BlogPost/hello.json', '{"title":"Hello"}'); + writeLocalFile( + localDir, + '.boxel-sync.json', + '{"workspaceUrl":"test","files":{}}', + ); + + await pushCommand(localDir, TEST_REALM_URL, {}); + + expect(realmState.files.has('.boxel-sync.json')).toBe(false); + expect(realmState.files.has('BlogPost/hello.json')).toBe(true); + }); +});