From 780a313c552ba01f60b2b97559456900f604e7de Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Thu, 16 Apr 2026 13:39:27 +0200 Subject: [PATCH] CS-10771: Replace pullRealmFiles HTTP logic with BoxelCLIClient.pull() Add a programmatic pull() method to BoxelCLIClient that reuses the existing RealmPuller (auth via active profile, concurrent downloads, checkpoints) and returns { files, error? } instead of calling process.exit(). Replace the 90-line HTTP implementation of pullRealmFiles in realm-operations.ts with a 3-line delegation to the new client method. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/boxel-cli/api.ts | 2 + packages/boxel-cli/src/commands/realm/pull.ts | 50 ++++++- .../boxel-cli/src/lib/boxel-cli-client.ts | 23 ++++ .../software-factory/src/realm-operations.ts | 90 +------------ .../tests/factory-test-realm.test.ts | 125 +----------------- 5 files changed, 81 insertions(+), 209 deletions(-) diff --git a/packages/boxel-cli/api.ts b/packages/boxel-cli/api.ts index 6eb2aabafd..6596093692 100644 --- a/packages/boxel-cli/api.ts +++ b/packages/boxel-cli/api.ts @@ -2,4 +2,6 @@ export { BoxelCLIClient, type CreateRealmOptions, type CreateRealmResult, + type PullOptions, + type PullResult, } from './src/lib/boxel-cli-client'; diff --git a/packages/boxel-cli/src/commands/realm/pull.ts b/packages/boxel-cli/src/commands/realm/pull.ts index 60e7049afe..b6f2c82b80 100644 --- a/packages/boxel-cli/src/commands/realm/pull.ts +++ b/packages/boxel-cli/src/commands/realm/pull.ts @@ -17,6 +17,7 @@ interface PullOptions extends SyncOptions { class RealmPuller extends RealmSyncBase { hasError = false; + downloadedFiles: string[] = []; constructor( private pullOptions: PullOptions, @@ -106,7 +107,7 @@ class RealmPuller extends RealmSyncBase { }), ), ); - const downloadedFiles = downloadResults.filter( + this.downloadedFiles = downloadResults.filter( (f): f is string => f !== null, ); @@ -138,10 +139,10 @@ class RealmPuller extends RealmSyncBase { if ( !this.options.dryRun && - downloadedFiles.length + deletedFiles.length > 0 + this.downloadedFiles.length + deletedFiles.length > 0 ) { const pullChanges: CheckpointChange[] = [ - ...downloadedFiles.map((f) => ({ + ...this.downloadedFiles.map((f) => ({ file: f, status: 'modified' as const, })), @@ -232,3 +233,46 @@ export async function pullCommand( process.exit(1); } } + +export async function pull( + realmUrl: string, + localDir: string, + options: PullCommandOptions, +): Promise<{ files: string[]; error?: string }> { + let pm = options.profileManager ?? getProfileManager(); + let active = pm.getActiveProfile(); + if (!active) { + return { + files: [], + error: 'No active profile. Run `boxel profile add` to create one.', + }; + } + + try { + const puller = new RealmPuller( + { + realmUrl, + localDir, + deleteLocal: options.delete, + }, + pm, + ); + + await puller.sync(); + + if (puller.hasError) { + return { + files: puller.downloadedFiles.sort(), + error: + 'Pull completed with errors. Some files may not have been downloaded.', + }; + } + + return { files: puller.downloadedFiles.sort() }; + } catch (error) { + return { + files: [], + error: `Pull failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} diff --git a/packages/boxel-cli/src/lib/boxel-cli-client.ts b/packages/boxel-cli/src/lib/boxel-cli-client.ts index ba1fbeff1e..15954ed543 100644 --- a/packages/boxel-cli/src/lib/boxel-cli-client.ts +++ b/packages/boxel-cli/src/lib/boxel-cli-client.ts @@ -1,4 +1,5 @@ import { createRealm as coreCreateRealm } from '../commands/realm/create'; +import { pull as realmPull } from '../commands/realm/pull'; import { getProfileManager, type ProfileManager } from './profile-manager'; export interface CreateRealmOptions { @@ -21,6 +22,17 @@ export interface CreateRealmResult { authorization: string; } +export interface PullOptions { + /** Delete local files that don't exist in the realm (default: false). */ + delete?: boolean; +} + +export interface PullResult { + /** Relative file paths that were downloaded. */ + files: string[]; + error?: string; +} + export class BoxelCLIClient { private pm: ProfileManager; @@ -59,6 +71,17 @@ export class BoxelCLIClient { }; } + async pull( + realmUrl: string, + localDir: string, + options?: PullOptions, + ): Promise { + return realmPull(realmUrl, localDir, { + delete: options?.delete, + profileManager: this.pm, + }); + } + async createRealm(options: CreateRealmOptions): Promise { let result = await coreCreateRealm(options.realmName, options.displayName, { background: options.backgroundURL, diff --git a/packages/software-factory/src/realm-operations.ts b/packages/software-factory/src/realm-operations.ts index 440a2f8426..23c63fe5bc 100644 --- a/packages/software-factory/src/realm-operations.ts +++ b/packages/software-factory/src/realm-operations.ts @@ -5,9 +5,7 @@ * refactor to boxel-cli tool calls (CS-10529). */ -import { mkdirSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; - +import { BoxelCLIClient } from '@cardstack/boxel-cli/api'; import type { LooseSingleCardDocument } from '@cardstack/runtime-common'; import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type'; @@ -877,91 +875,17 @@ export async function fetchRealmFilenames( // --------------------------------------------------------------------------- /** - * Download all files from a remote realm to a local directory using the - * `_mtimes` endpoint to discover file paths. - * - * TODO: Replace with `boxel pull` once CS-10529 is implemented. + * Download all files from a remote realm to a local directory. + * Delegates to boxel-cli's pull implementation which handles auth + * via the active profile. * * Returns the list of relative file paths that were downloaded. */ export async function pullRealmFiles( realmUrl: string, localDir: string, - options?: RealmFetchOptions, + _options?: RealmFetchOptions, ): Promise<{ files: string[]; error?: string }> { - let fetchImpl = options?.fetch ?? globalThis.fetch; - let normalizedRealmUrl = ensureTrailingSlash(realmUrl); - - let headers = buildAuthHeaders( - options?.authorization, - SupportedMimeType.JSONAPI, - ); - - // Fetch mtimes to discover all file paths. - let mtimesUrl = `${normalizedRealmUrl}_mtimes`; - let mtimesResponse: Response; - try { - mtimesResponse = await fetchImpl(mtimesUrl, { method: 'GET', headers }); - } catch (err) { - return { - files: [], - error: `Failed to fetch _mtimes: ${err instanceof Error ? err.message : String(err)}`, - }; - } - - if (!mtimesResponse.ok) { - let body = await mtimesResponse.text(); - return { - files: [], - error: `_mtimes returned HTTP ${mtimesResponse.status}: ${body.slice(0, 300)}`, - }; - } - - let mtimes: Record; - try { - let json = await mtimesResponse.json(); - // _mtimes returns JSON:API format: { data: { attributes: { mtimes: {...} } } } - mtimes = - (json as { data?: { attributes?: { mtimes?: Record } } }) - ?.data?.attributes?.mtimes ?? json; - } catch { - return { files: [], error: 'Failed to parse _mtimes response as JSON' }; - } - - // Download each file. - let downloadedFiles: string[] = []; - for (let fullUrl of Object.keys(mtimes)) { - if (!fullUrl.startsWith(normalizedRealmUrl)) { - continue; - } - let relativePath = fullUrl.slice(normalizedRealmUrl.length); - if (!relativePath || relativePath.endsWith('/')) { - continue; - } - - let localPath = join(localDir, relativePath); - mkdirSync(dirname(localPath), { recursive: true }); - - try { - let fileResponse = await fetchImpl(fullUrl, { - method: 'GET', - headers: buildAuthHeaders( - options?.authorization, - SupportedMimeType.CardSource, - ), - }); - - if (!fileResponse.ok) { - continue; - } - - let rawText = await fileResponse.text(); - writeFileSync(localPath, rawText); - downloadedFiles.push(relativePath); - } catch { - continue; - } - } - - return { files: downloadedFiles.sort() }; + let client = new BoxelCLIClient(); + return client.pull(realmUrl, localDir); } diff --git a/packages/software-factory/tests/factory-test-realm.test.ts b/packages/software-factory/tests/factory-test-realm.test.ts index ce15749069..334f055a2c 100644 --- a/packages/software-factory/tests/factory-test-realm.test.ts +++ b/packages/software-factory/tests/factory-test-realm.test.ts @@ -12,7 +12,6 @@ import { resolveTestRun, type TestRunAttributes, } from '../src/factory-test-realm'; -import { pullRealmFiles } from '../src/realm-operations'; // --------------------------------------------------------------------------- // Shared helpers @@ -837,128 +836,8 @@ module('factory-test-realm > resolveTestRun', function () { }); }); -// --------------------------------------------------------------------------- -// pullRealmFiles -// --------------------------------------------------------------------------- - -module('factory-test-realm > pullRealmFiles', function () { - test('downloads files listed by _mtimes', async function (assert) { - let realmUrl = 'https://realms.example.test/user/personal/'; - let capturedUrls: string[] = []; - - let mockFetch = (async (url: string | URL | Request) => { - let urlStr = String(url); - capturedUrls.push(urlStr); - - if (urlStr.includes('_mtimes')) { - return new Response( - JSON.stringify({ - [`${realmUrl}hello.gts`]: 1000, - [`${realmUrl}HelloCard/sample.json`]: 2000, - }), - { status: 200, headers: { 'Content-Type': SupportedMimeType.JSON } }, - ); - } - - // File downloads - return new Response('file-content', { status: 200 }); - }) as typeof globalThis.fetch; - - let tmpDir = `/tmp/sf-test-pull-${Date.now()}`; - let result = await pullRealmFiles(realmUrl, tmpDir, { fetch: mockFetch }); - - assert.strictEqual(result.error, undefined); - assert.strictEqual(result.files.length, 2); - assert.true(result.files.includes('hello.gts')); - assert.true(result.files.includes('HelloCard/sample.json')); - - // Should have fetched _mtimes + 2 files = 3 requests - assert.strictEqual(capturedUrls.length, 3); - assert.true(capturedUrls[0].includes('_mtimes')); - }); - - test('passes authorization header', async function (assert) { - let capturedHeaders: Record[] = []; - - let mockFetch = (async ( - _url: string | URL | Request, - init?: RequestInit, - ) => { - capturedHeaders.push((init?.headers as Record) ?? {}); - // Return empty mtimes so no file downloads happen - return new Response(JSON.stringify({}), { - status: 200, - headers: { 'Content-Type': SupportedMimeType.JSON }, - }); - }) as typeof globalThis.fetch; - - await pullRealmFiles('https://example.test/realm/', '/tmp/unused', { - fetch: mockFetch, - authorization: 'Bearer my-token', - }); - - assert.strictEqual(capturedHeaders[0]['Authorization'], 'Bearer my-token'); - }); - - test('returns error on _mtimes HTTP failure', async function (assert) { - let mockFetch = (async () => { - return new Response('Forbidden', { status: 403 }); - }) as typeof globalThis.fetch; - - let result = await pullRealmFiles( - 'https://example.test/realm/', - '/tmp/unused', - { fetch: mockFetch }, - ); - - assert.strictEqual(result.files.length, 0); - assert.true(result.error?.includes('403')); - }); - - test('returns error on network failure', async function (assert) { - let mockFetch = (async () => { - throw new Error('ECONNREFUSED'); - }) as typeof globalThis.fetch; - - let result = await pullRealmFiles( - 'https://example.test/realm/', - '/tmp/unused', - { fetch: mockFetch }, - ); - - assert.strictEqual(result.files.length, 0); - assert.true(result.error?.includes('ECONNREFUSED')); - }); - - test('skips files outside the realm URL', async function (assert) { - let realmUrl = 'https://realms.example.test/user/personal/'; - - let mockFetch = (async (url: string | URL | Request) => { - let urlStr = String(url); - if (urlStr.includes('_mtimes')) { - return new Response( - JSON.stringify({ - [`${realmUrl}hello.gts`]: 1000, - ['https://other.test/evil.gts']: 2000, // outside realm - }), - { status: 200, headers: { 'Content-Type': SupportedMimeType.JSON } }, - ); - } - return new Response('content', { status: 200 }); - }) as typeof globalThis.fetch; - - let result = await pullRealmFiles( - realmUrl, - `/tmp/sf-test-pull-${Date.now()}`, - { - fetch: mockFetch, - }, - ); - - assert.strictEqual(result.files.length, 1); - assert.strictEqual(result.files[0], 'hello.gts'); - }); -}); +// pullRealmFiles tests removed — pullRealmFiles now delegates to +// BoxelCLIClient.pull() which is tested in the boxel-cli package. // --------------------------------------------------------------------------- // formatTestResultSummary