diff --git a/packages/boxel-cli/api.ts b/packages/boxel-cli/api.ts index 6eb2aabafd2..6f612b090b6 100644 --- a/packages/boxel-cli/api.ts +++ b/packages/boxel-cli/api.ts @@ -2,4 +2,14 @@ export { BoxelCLIClient, type CreateRealmOptions, type CreateRealmResult, + type PullOptions, + type PullResult, + type ReadResult, + type WriteResult, + type DeleteResult, + type SearchResult, + type ListFilesResult, + type RunCommandResult, + type LintMessage, + type LintResult, } 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 60e7049afeb..b6f2c82b807 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 ba1fbeff1ee..7d14dc2269c 100644 --- a/packages/boxel-cli/src/lib/boxel-cli-client.ts +++ b/packages/boxel-cli/src/lib/boxel-cli-client.ts @@ -1,6 +1,22 @@ import { createRealm as coreCreateRealm } from '../commands/realm/create'; +import { pull as realmPull } from '../commands/realm/pull'; import { getProfileManager, type ProfileManager } from './profile-manager'; +// --------------------------------------------------------------------------- +// MIME types for realm API requests +// --------------------------------------------------------------------------- + +const MIME = { + CardSource: 'application/vnd.card+source', + CardJson: 'application/vnd.card+json', + JSON: 'application/json', + JSONAPI: 'application/vnd.api+json', +} as const; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + export interface CreateRealmOptions { /** URL slug for the realm (lowercase, numbers, hyphens). */ realmName: string; @@ -15,12 +31,88 @@ export interface CreateRealmOptions { export interface CreateRealmResult { realmUrl: string; created: boolean; - // TODO: Remove once pull/push/sync/search are added to BoxelCLIClient. - // Callers should not manage tokens directly — this is transitional glue - // until the factory uses BoxelCLIClient for all realm operations. - 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 interface ReadResult { + ok: boolean; + status?: number; + /** Parsed JSON document (for .json files). */ + document?: Record; + /** Raw text content (for non-JSON files like .gts). */ + content?: string; + error?: string; +} + +export interface WriteResult { + ok: boolean; + error?: string; +} + +export interface DeleteResult { + ok: boolean; + error?: string; +} + +export interface SearchResult { + ok: boolean; + data?: Record[]; + status?: number; + error?: string; +} + +export interface ListFilesResult { + filenames: string[]; + error?: string; +} + +export interface RunCommandResult { + status: 'ready' | 'error' | 'unusable'; + /** Serialized command result (JSON string), or null. */ + result?: string | null; + error?: string | null; +} + +/** A single lint diagnostic message (mirrors ESLint's Linter.LintMessage). */ +export interface LintMessage { + ruleId: string | null; + severity: 1 | 2; // 1 = warning, 2 = error + message: string; + line: number; + column: number; + endLine?: number; + endColumn?: number; +} + +/** Response from the realm `_lint` endpoint (mirrors ESLint's Linter.FixReport). */ +export interface LintResult { + fixed: boolean; + output: string; + messages: LintMessage[]; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function ensureTrailingSlash(url: string): string { + return url.endsWith('/') ? url : `${url}/`; +} + +// --------------------------------------------------------------------------- +// BoxelCLIClient +// --------------------------------------------------------------------------- + export class BoxelCLIClient { private pm: ProfileManager; @@ -59,6 +151,424 @@ export class BoxelCLIClient { }; } + // ------------------------------------------------------------------------- + // Token access (for Playwright route injection and similar) + // ------------------------------------------------------------------------- + + /** + * Resolve and return the raw JWT for a realm URL. + * Uses prefix matching and auto-refreshes from the server if needed. + * Throws if no token is available. + */ + async getRealmToken(realmUrl: string): Promise { + let token = await this.pm.resolveRealmTokenForUrl(realmUrl); + if (!token) { + throw new Error( + `No realm token available for ${realmUrl}. The realm may not be accessible.`, + ); + } + return token; + } + + // ------------------------------------------------------------------------- + // Realm file operations + // ------------------------------------------------------------------------- + + /** + * Read a file from a realm. Returns parsed JSON for .json files, + * raw text for everything else (.gts, etc.). + */ + async read(realmUrl: string, path: string): Promise { + let url = new URL(path, ensureTrailingSlash(realmUrl)).href; + + try { + let response = await this.pm.authedRealmFetch(url, { + method: 'GET', + headers: { Accept: MIME.CardSource }, + }); + + if (!response.ok) { + let body = await response.text(); + return { + ok: false, + status: response.status, + error: `HTTP ${response.status}: ${body.slice(0, 300)}`, + }; + } + + let text = await response.text(); + try { + let document = JSON.parse(text) as Record; + return { ok: true, status: response.status, document }; + } catch { + return { ok: true, status: response.status, content: text }; + } + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } + } + + /** + * Write a file to a realm. Content is sent as-is with card+source MIME type. + * Path should include the file extension. + */ + async write( + realmUrl: string, + path: string, + content: string, + ): Promise { + let url = new URL(path, ensureTrailingSlash(realmUrl)).href; + + try { + let response = await this.pm.authedRealmFetch(url, { + method: 'POST', + headers: { + Accept: MIME.CardSource, + 'Content-Type': MIME.CardSource, + }, + body: content, + }); + + if (!response.ok) { + let body = await response.text(); + return { + ok: false, + error: `HTTP ${response.status}: ${body.slice(0, 300)}`, + }; + } + + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } + } + + /** + * Delete a file from a realm. + */ + async delete(realmUrl: string, path: string): Promise { + let url = new URL(path, ensureTrailingSlash(realmUrl)).href; + + try { + let response = await this.pm.authedRealmFetch(url, { + method: 'DELETE', + headers: { Accept: MIME.CardSource }, + }); + + if (!response.ok) { + let body = await response.text(); + return { + ok: false, + error: `HTTP ${response.status}: ${body.slice(0, 300)}`, + }; + } + + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } + } + + /** + * Search a realm using the `_search` endpoint. + */ + async search( + realmUrl: string, + query: Record, + ): Promise { + let searchUrl = `${ensureTrailingSlash(realmUrl)}_search`; + + try { + let response = await this.pm.authedRealmFetch(searchUrl, { + method: 'QUERY', + headers: { + Accept: MIME.CardJson, + 'Content-Type': MIME.JSON, + }, + body: JSON.stringify(query), + }); + + if (!response.ok) { + let body = await response.text(); + return { + ok: false, + status: response.status, + error: `HTTP ${response.status}: ${body.slice(0, 300)}`, + }; + } + + let result = (await response.json()) as { + data?: Record[]; + }; + return { ok: true, data: result.data }; + } catch (err) { + return { + ok: false, + status: 0, + error: err instanceof Error ? err.message : String(err), + }; + } + } + + /** + * List all file paths in a realm via the `_mtimes` endpoint. + * Returns relative paths (e.g., `hello.gts`, `Cards/my-card.json`). + */ + async listFiles(realmUrl: string): Promise { + let normalizedRealmUrl = ensureTrailingSlash(realmUrl); + let mtimesUrl = `${normalizedRealmUrl}_mtimes`; + + try { + let response = await this.pm.authedRealmFetch(mtimesUrl, { + method: 'GET', + headers: { Accept: MIME.JSONAPI }, + }); + + if (!response.ok) { + let body = await response.text(); + return { + filenames: [], + error: `_mtimes returned HTTP ${response.status}: ${body.slice(0, 300)}`, + }; + } + + let json = (await response.json()) as { + data?: { attributes?: { mtimes?: Record } }; + }; + let mtimes = json?.data?.attributes?.mtimes ?? (json as Record); + + let filenames: string[] = []; + for (let fullUrl of Object.keys(mtimes)) { + if (!fullUrl.startsWith(normalizedRealmUrl)) { + continue; + } + let relativePath = fullUrl.slice(normalizedRealmUrl.length); + if (!relativePath || relativePath.endsWith('/')) { + continue; + } + filenames.push(relativePath); + } + + return { filenames: filenames.sort() }; + } catch (err) { + return { + filenames: [], + error: err instanceof Error ? err.message : String(err), + }; + } + } + + // ------------------------------------------------------------------------- + // Run Command (prerenderer) + // ------------------------------------------------------------------------- + + /** + * Execute a host command on the realm server via the `/_run-command` + * endpoint. Uses server-level auth (authedRealmServerFetch). + */ + async runCommand( + realmServerUrl: string, + realmUrl: string, + command: string, + commandInput?: Record, + ): Promise { + let url = `${ensureTrailingSlash(realmServerUrl)}_run-command`; + + let body = { + data: { + type: 'run-command', + attributes: { + realmURL: realmUrl, + command, + commandInput: commandInput ?? null, + }, + }, + }; + + let response: Response; + try { + response = await this.pm.authedRealmServerFetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/vnd.api+json', + Accept: 'application/vnd.api+json', + }, + body: JSON.stringify(body), + }); + } catch (err) { + return { + status: 'error', + error: `run-command fetch failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } + + if (!response.ok) { + return { + status: 'error', + error: `run-command HTTP ${response.status}: ${await response.text().catch(() => '(no body)')}`, + }; + } + + let json = (await response.json()) as { + data?: { + attributes?: { + status?: string; + cardResultString?: string | null; + error?: string | null; + }; + }; + }; + + let attrs = json.data?.attributes; + return { + status: (attrs?.status as RunCommandResult['status']) ?? 'error', + result: attrs?.cardResultString ?? null, + error: attrs?.error ?? null, + }; + } + + // ------------------------------------------------------------------------- + // Lint + // ------------------------------------------------------------------------- + + /** + * Lint a single file's source code via the realm's `_lint` endpoint. + * Uses per-realm auth (authedRealmFetch). + */ + async lint( + realmUrl: string, + source: string, + filename: string, + ): Promise { + let normalizedUrl = ensureTrailingSlash(realmUrl); + let lintUrl = `${normalizedUrl}_lint`; + + let response = await this.pm.authedRealmFetch(lintUrl, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/vnd.card+source', + 'X-Filename': filename, + 'X-HTTP-Method-Override': 'QUERY', + }, + body: source, + }); + + if (!response.ok) { + let body = await response.text().catch(() => '(no body)'); + throw new Error( + `_lint returned HTTP ${response.status}: ${body.slice(0, 300)}`, + ); + } + + return (await response.json()) as LintResult; + } + + // ------------------------------------------------------------------------- + // Readiness & waiting + // ------------------------------------------------------------------------- + + /** + * Poll a specific realm file path until it exists (non-404), or the + * timeout is reached. Uses `this.read()` internally. + * + * @returns true if the file was found, false on timeout. + */ + async waitForFile( + realmUrl: string, + path: string, + opts?: { timeoutMs?: number; pollMs?: number }, + ): Promise { + let timeoutMs = opts?.timeoutMs ?? 30_000; + let pollMs = opts?.pollMs ?? 300; + let startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + let result = await this.read(realmUrl, path); + if (result.ok) { + return true; + } + await new Promise((r) => setTimeout(r, pollMs)); + } + + return false; + } + + /** + * Wait for a realm to be ready by polling `_readiness-check` until it + * returns 200 or the timeout is reached. + */ + async waitForReady( + realmUrl: string, + opts?: { timeoutMs?: number; pollMs?: number }, + ): Promise { + let normalizedUrl = ensureTrailingSlash(realmUrl); + let readinessUrl = `${normalizedUrl}_readiness-check`; + let timeoutMs = opts?.timeoutMs ?? 30_000; + let pollMs = opts?.pollMs ?? 1000; + let startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + try { + let response = await this.pm.authedRealmFetch(readinessUrl, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (response.ok) { + return true; + } + } catch { + // retry + } + await new Promise((r) => setTimeout(r, pollMs)); + } + + return false; + } + + // ------------------------------------------------------------------------- + // Server-level fetch (for callers needing raw server-authed requests) + // ------------------------------------------------------------------------- + + /** + * Make a fetch request authenticated with the realm server token. + * Used for server-level endpoints like `_server-session` and `_realm-auth`. + */ + async authedServerFetch( + input: string | URL | Request, + init?: RequestInit, + ): Promise { + return this.pm.authedRealmServerFetch(input, init); + } + + // ------------------------------------------------------------------------- + // Bulk operations + // ------------------------------------------------------------------------- + + async pull( + realmUrl: string, + localDir: string, + options?: PullOptions, + ): Promise { + return realmPull(realmUrl, localDir, { + delete: options?.delete, + profileManager: this.pm, + }); + } + + // ------------------------------------------------------------------------- + // Realm management + // ------------------------------------------------------------------------- + async createRealm(options: CreateRealmOptions): Promise { let result = await coreCreateRealm(options.realmName, options.displayName, { background: options.backgroundURL, @@ -67,16 +577,9 @@ export class BoxelCLIClient { waitForReady: options.waitForReady !== false, }); - if (!result.realmToken) { - throw new Error( - `Realm "${options.realmName}" was created, but no authorization token was returned.`, - ); - } - return { realmUrl: result.realmUrl, created: result.created, - authorization: result.realmToken, }; } } diff --git a/packages/boxel-cli/src/lib/profile-manager.ts b/packages/boxel-cli/src/lib/profile-manager.ts index 1938cb89ec1..4766d36c9a0 100644 --- a/packages/boxel-cli/src/lib/profile-manager.ts +++ b/packages/boxel-cli/src/lib/profile-manager.ts @@ -307,6 +307,15 @@ export class ProfileManager { return active?.profile.realmTokens?.[realmUrl]; } + /** + * Resolve a realm token for a URL, using prefix matching and + * auto-refreshing from the server if needed. Public wrapper around + * the private getRealmTokenForUrl(). + */ + async resolveRealmTokenForUrl(url: string): Promise { + return this.getRealmTokenForUrl(url); + } + setRealmServerToken(token: string): void { let active = this.getActiveProfile(); if (!active) { diff --git a/packages/software-factory/scripts/smoke-tests/factory-tools-smoke.ts b/packages/software-factory/scripts/smoke-tests/factory-tools-smoke.ts index fa5253a1d8b..af8f1d17b99 100644 --- a/packages/software-factory/scripts/smoke-tests/factory-tools-smoke.ts +++ b/packages/software-factory/scripts/smoke-tests/factory-tools-smoke.ts @@ -28,6 +28,7 @@ import { type ClarificationResult, } from '../../src/factory-tool-builder'; import { ToolRegistry } from '../../src/factory-tool-registry'; +import { createMockClient } from '../../tests/helpers/mock-client'; // --------------------------------------------------------------------------- // Helpers @@ -135,6 +136,7 @@ async function main(): Promise { log.info(''); let executor = new ToolExecutor(registry, { + client: createMockClient(), packageRoot: process.cwd(), targetRealmUrl: 'https://realms.example.test/user/target/', sourceRealmUrl: 'https://realms.example.test/user/source/', @@ -180,10 +182,7 @@ async function main(): Promise { let mockCallCount = 0; - let mockExecutor = new ToolExecutor(registry, { - packageRoot: process.cwd(), - targetRealmUrl: 'https://realms.example.test/user/target/', - fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { + let mockFetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { mockCallCount++; let url = String(input); let method = init?.method ?? 'GET'; @@ -194,7 +193,13 @@ async function main(): Promise { }), { status: 200, headers: { 'Content-Type': SupportedMimeType.JSON } }, ); - }) as typeof globalThis.fetch, + }) as typeof globalThis.fetch; + + let mockExecutor = new ToolExecutor(registry, { + client: createMockClient({ fetch: mockFetchFn }), + packageRoot: process.cwd(), + targetRealmUrl: 'https://realms.example.test/user/target/', + fetch: mockFetchFn, }); let readResult = await mockExecutor.execute('realm-read', { @@ -247,6 +252,7 @@ async function main(): Promise { }) as typeof globalThis.fetch; let toolBuilderExecutor = new ToolExecutor(registry, { + client: createMockClient({ fetch: toolBuilderFetch }), packageRoot: process.cwd(), targetRealmUrl: 'https://realms.example.test/user/target/', fetch: toolBuilderFetch, @@ -258,10 +264,7 @@ async function main(): Promise { darkfactoryModuleUrl: 'https://realms.example.test/software-factory/darkfactory', realmServerUrl: 'https://realms.example.test/', - realmTokens: { - 'https://realms.example.test/user/target/': 'Bearer target-jwt', - 'https://realms.example.test/user/target-tests/': 'Bearer test-jwt', - }, + client: createMockClient({ fetch: toolBuilderFetch }), fetch: toolBuilderFetch, }, toolBuilderExecutor, diff --git a/packages/software-factory/scripts/smoke-tests/smoke-test-realm.ts b/packages/software-factory/scripts/smoke-tests/smoke-test-realm.ts index c9b4d7b09f1..63b29fac3bd 100644 --- a/packages/software-factory/scripts/smoke-tests/smoke-test-realm.ts +++ b/packages/software-factory/scripts/smoke-tests/smoke-test-realm.ts @@ -36,6 +36,7 @@ import { getRealmServerToken, matrixLogin, parseArgs } from '../../src/boxel'; import { logger } from '../../src/logger'; import { getRealmScopedAuth, writeFile } from '../../src/realm-operations'; import { createDefaultPipeline } from '../../src/validators/validation-pipeline'; +import { createMockClient } from '../../tests/helpers/mock-client'; import type { TestValidationDetails } from '../../src/validators/test-step'; // --------------------------------------------------------------------------- @@ -321,6 +322,7 @@ async function main() { ).href; let pipeline = createDefaultPipeline({ + client: createMockClient({ fetch: fetchImpl }), authorization, fetch: fetchImpl, realmServerUrl, diff --git a/packages/software-factory/src/darkfactory-schemas.ts b/packages/software-factory/src/darkfactory-schemas.ts index 9eefc5a064b..d15453263ba 100644 --- a/packages/software-factory/src/darkfactory-schemas.ts +++ b/packages/software-factory/src/darkfactory-schemas.ts @@ -14,6 +14,7 @@ import type { Relationship, } from '@cardstack/runtime-common'; +import type { BoxelCLIClient } from '@cardstack/boxel-cli/api'; import { runRealmCommand, type RunCommandOptions } from './realm-operations'; // --------------------------------------------------------------------------- @@ -45,6 +46,7 @@ export async function fetchCardTypeSchema( realmUrl: string, codeRef: ResolvedCodeRef, options: RunCommandOptions, + client?: BoxelCLIClient, ): Promise< | { attributes: Record; @@ -58,13 +60,20 @@ export async function fetchCardTypeSchema( return cached; } - let response = await runRealmCommand( - realmServerUrl, - realmUrl, - GET_CARD_TYPE_SCHEMA_COMMAND, - { codeRef }, - options, - ); + let response = client + ? await client.runCommand( + realmServerUrl, + realmUrl, + GET_CARD_TYPE_SCHEMA_COMMAND, + { codeRef }, + ) + : await runRealmCommand( + realmServerUrl, + realmUrl, + GET_CARD_TYPE_SCHEMA_COMMAND, + { codeRef }, + options, + ); if (response.status !== 'ready' || !response.result) { log.warn( diff --git a/packages/software-factory/src/factory-agent-tool-use.ts b/packages/software-factory/src/factory-agent-tool-use.ts index deaccdc5b07..0670353a360 100644 --- a/packages/software-factory/src/factory-agent-tool-use.ts +++ b/packages/software-factory/src/factory-agent-tool-use.ts @@ -110,9 +110,7 @@ export class ToolUseFactoryAgent implements LoopAgent { return globalThis.fetch(input, { ...init, headers }); }) as typeof globalThis.fetch; } else { - this.fetchImpl = createBoxelRealmFetch(config.realmServerUrl, { - authorization: config.authorization?.trim() || undefined, - }); + this.fetchImpl = createBoxelRealmFetch(config.realmServerUrl); } } diff --git a/packages/software-factory/src/factory-agent-types.ts b/packages/software-factory/src/factory-agent-types.ts index a5e58fe4698..47df9c25b71 100644 --- a/packages/software-factory/src/factory-agent-types.ts +++ b/packages/software-factory/src/factory-agent-types.ts @@ -52,7 +52,6 @@ export type ActionRealm = (typeof VALID_REALMS)[number]; export interface FactoryAgentConfig { model: string; realmServerUrl: string; - authorization?: string; maxSkillTokens?: number; /** Call OpenRouter directly with this API key instead of going through the * realm server _request-forward proxy. Useful for local dev / CI. */ diff --git a/packages/software-factory/src/factory-entrypoint.ts b/packages/software-factory/src/factory-entrypoint.ts index 2833c95249e..9684485a72a 100644 --- a/packages/software-factory/src/factory-entrypoint.ts +++ b/packages/software-factory/src/factory-entrypoint.ts @@ -1,5 +1,7 @@ import { parseArgs as parseNodeArgs } from 'node:util'; +import { BoxelCLIClient } from '@cardstack/boxel-cli/api'; + import { inferDarkfactoryModuleUrl } from './factory-seed'; import { loadFactoryBrief, type FactoryBrief } from './factory-brief'; import { FactoryEntrypointUsageError } from './factory-entrypoint-errors'; @@ -16,7 +18,7 @@ import { type ResolveFactoryTargetRealmOptions, } from './factory-target-realm'; import type { IssueLoopResult } from './issue-loop'; -import { createBoxelRealmFetch } from './realm-auth'; +import { createBoxelRealmFetch } from './realm-auth'; // Used for brief loading (public URLs) export interface FactoryEntrypointOptions { briefUrl: string; @@ -80,7 +82,7 @@ export interface RunFactoryEntrypointDependencies { createSeed?: ( brief: FactoryBrief, targetRealmUrl: string, - options: { fetch?: typeof globalThis.fetch; darkfactoryModuleUrl: string }, + options: { client: BoxelCLIClient; darkfactoryModuleUrl: string }, ) => Promise; runIssueLoop?: (config: IssueLoopWiringConfig) => Promise; } @@ -210,19 +212,16 @@ export async function runFactoryEntrypoint( dependencies?.bootstrapTargetRealm ?? bootstrapFactoryTargetRealm )(targetRealmResolution); - let realmFetch = createBoxelRealmFetch(targetRealm.url, { - authorization: targetRealm.authorization, - fetch: dependencies?.fetch, - primeRealmURL: targetRealm.url, - }); - let darkfactoryModuleUrl = inferDarkfactoryModuleUrl(targetRealm.url); + // Create a BoxelCLIClient for seed issue creation + let client = new BoxelCLIClient(); + // Create the seed issue in the realm let seedResult = await (dependencies?.createSeed ?? createSeedIssue)( brief, targetRealm.url, - { fetch: realmFetch, darkfactoryModuleUrl }, + { client, darkfactoryModuleUrl }, ); let summary = buildFactoryEntrypointSummary( @@ -239,7 +238,6 @@ export async function runFactoryEntrypoint( targetRealmUrl: targetRealm.url, realmServerUrl: targetRealm.serverUrl, ownerUsername: targetRealm.ownerUsername, - authorization: targetRealm.authorization, model: options.model, debug: options.debug, retryBlocked: options.retryBlocked, diff --git a/packages/software-factory/src/factory-issue-loop-wiring.ts b/packages/software-factory/src/factory-issue-loop-wiring.ts index b3cfb836110..ced4c3af355 100644 --- a/packages/software-factory/src/factory-issue-loop-wiring.ts +++ b/packages/software-factory/src/factory-issue-loop-wiring.ts @@ -14,6 +14,8 @@ import { resolve } from 'node:path'; +import { BoxelCLIClient } from '@cardstack/boxel-cli/api'; + import { logger } from './logger'; import { @@ -55,8 +57,6 @@ import { RealmIssueStore, type IssueStore } from './issue-scheduler'; import { RealmIssueRelationshipLoader } from './realm-issue-relationship-loader'; import { ensureTrailingSlash, - fetchRealmFilenames, - type RealmFetchOptions, } from './realm-operations'; import { fetchCardTypeSchema } from './darkfactory-schemas'; @@ -73,7 +73,6 @@ export interface IssueLoopWiringConfig { targetRealmUrl: string; realmServerUrl: string; ownerUsername: string; - authorization: string; model?: string; debug?: boolean; fetch?: typeof globalThis.fetch; @@ -110,19 +109,17 @@ export async function runFactoryIssueLoop( let fetchImpl = config.fetch ?? globalThis.fetch; // 1. Auth - let { serverToken, realmTokens } = await resolveAuth(config); + await resolveAuth(config); - let fetchOptions: RealmFetchOptions = { - authorization: config.authorization, - fetch: fetchImpl, - }; + // Create the BoxelCLIClient — handles per-realm and server-level auth via the active profile + let client = new BoxelCLIClient(); // 2. Issue store let darkfactoryModuleUrl = inferDarkfactoryModuleUrl(targetRealmUrl); let issueStore = new RealmIssueStore({ realmUrl: targetRealmUrl, darkfactoryModuleUrl, - options: fetchOptions, + client, }); // 2b. Retry blocked issues (default on, opt out with --no-retry-blocked) @@ -133,7 +130,7 @@ export async function runFactoryIssueLoop( // 3. Context builder with issue relationship loader let issueLoader = new RealmIssueRelationshipLoader({ realmUrl: targetRealmUrl, - options: fetchOptions, + client, }); let contextBuilder = new ContextBuilder({ skillResolver: new DefaultSkillResolver(), @@ -146,8 +143,8 @@ export async function runFactoryIssueLoop( let toolExecutor = new ToolExecutor(toolRegistry, { packageRoot: PACKAGE_ROOT, targetRealmUrl, + client, fetch: fetchImpl, - authorization: config.authorization, }); let darkfactoryModuleBase = new URL('software-factory/', realmServerUrl).href; @@ -155,7 +152,7 @@ export async function runFactoryIssueLoop( realmServerUrl, targetRealmUrl, darkfactoryModuleBase, - { authorization: serverToken, fetch: fetchImpl }, + client, ); let testResultsModuleUrl = new URL( @@ -171,8 +168,7 @@ export async function runFactoryIssueLoop( targetRealmUrl, darkfactoryModuleUrl, realmServerUrl, - realmTokens, - serverToken, + client, testResultsModuleUrl, fetch: fetchImpl, cardTypeSchemas, @@ -192,22 +188,18 @@ export async function runFactoryIssueLoop( new ToolUseFactoryAgent({ model, realmServerUrl, - authorization: config.authorization, debug: config.debug, } satisfies FactoryAgentConfig); // 6. Validator factory let createValidator = (issueId: string) => createDefaultPipeline({ + client, realmServerUrl, - authorization: config.authorization, - fetch: fetchImpl, hostAppUrl, testResultsModuleUrl, lintResultsModuleUrl, issueId, - fetchFilenames: (realmUrl: string) => - fetchRealmFilenames(realmUrl, fetchOptions), }); // 7. Run issue loop @@ -388,7 +380,7 @@ async function loadDarkFactorySchemas( realmServerUrl: string, commandRealmUrl: string, darkfactoryModuleBase: string, - options: { authorization?: string; fetch?: typeof globalThis.fetch }, + client: BoxelCLIClient, ): Promise< | Map< string, @@ -414,7 +406,8 @@ async function loadDarkFactorySchemas( realmServerUrl, commandRealmUrl, { module: darkfactoryModule, name: cardName }, - options, + {}, + client, ); if (schema) { schemas.set(cardName, schema); @@ -434,7 +427,8 @@ async function loadDarkFactorySchemas( realmServerUrl, commandRealmUrl, { module: mod, name }, - options, + {}, + client, ); if (schema) { schemas.set(name, schema); diff --git a/packages/software-factory/src/factory-seed.ts b/packages/software-factory/src/factory-seed.ts index 6a34fa40c5e..da57c6c66a4 100644 --- a/packages/software-factory/src/factory-seed.ts +++ b/packages/software-factory/src/factory-seed.ts @@ -7,6 +7,8 @@ * issue as done. */ +import type { BoxelCLIClient } from '@cardstack/boxel-cli/api'; + import type { FactoryBrief } from './factory-brief'; import { logger } from './logger'; @@ -19,12 +21,6 @@ export function inferDarkfactoryModuleUrl(targetRealmUrl: string): string { let parsed = new URL(targetRealmUrl); return new URL('software-factory/darkfactory', parsed.origin + '/').href; } -import { - readFile, - writeFile, - waitForRealmFile, - type RealmFetchOptions, -} from './realm-operations'; let log = logger('factory-seed'); @@ -37,7 +33,8 @@ export interface SeedIssueResult { status: 'created' | 'existing'; } -export interface SeedIssueOptions extends RealmFetchOptions { +export interface SeedIssueOptions { + client: BoxelCLIClient; darkfactoryModuleUrl: string; } @@ -66,7 +63,7 @@ export async function createSeedIssue( options: SeedIssueOptions, ): Promise { // Check if seed issue already exists - let existing = await readFile(targetRealmUrl, SEED_ISSUE_PATH, options); + let existing = await options.client.read(targetRealmUrl, SEED_ISSUE_PATH); if (existing.ok) { log.info(`Seed issue already exists at ${SEED_ISSUE_PATH}`); return { issueId: SEED_ISSUE_PATH, status: 'existing' }; @@ -84,11 +81,10 @@ export async function createSeedIssue( let document = buildSeedIssueDocument(brief, options.darkfactoryModuleUrl); log.info(`Creating seed issue at ${SEED_ISSUE_FILE}`); - let writeResult = await writeFile( + let writeResult = await options.client.write( targetRealmUrl, SEED_ISSUE_FILE, JSON.stringify(document, null, 2), - options, ); if (!writeResult.ok) { @@ -98,8 +94,7 @@ export async function createSeedIssue( } // Wait for the card to be indexed and readable - let readable = await waitForRealmFile(targetRealmUrl, SEED_ISSUE_PATH, { - ...options, + let readable = await options.client.waitForFile(targetRealmUrl, SEED_ISSUE_PATH, { timeoutMs: 15_000, pollMs: 250, }); diff --git a/packages/software-factory/src/factory-target-realm.ts b/packages/software-factory/src/factory-target-realm.ts index 9957024e047..ad31c7a174c 100644 --- a/packages/software-factory/src/factory-target-realm.ts +++ b/packages/software-factory/src/factory-target-realm.ts @@ -17,13 +17,11 @@ export interface FactoryTargetRealmResolution { export interface FactoryTargetRealmBootstrapResult extends FactoryTargetRealmResolution { createdRealm: boolean; - authorization: string; } interface CreateRealmResult { createdRealm: boolean; url: string; - authorization: string; } export interface FactoryTargetRealmBootstrapActions { @@ -58,7 +56,6 @@ export async function bootstrapFactoryTargetRealm( ...resolution, url: createRealmResult.url, createdRealm: createRealmResult.createdRealm, - authorization: createRealmResult.authorization, }; } @@ -89,7 +86,6 @@ async function createRealm( return { createdRealm: result.created, url: result.realmUrl, - authorization: result.authorization, }; } diff --git a/packages/software-factory/src/factory-tool-builder.ts b/packages/software-factory/src/factory-tool-builder.ts index b920660313e..75015fb3d7b 100644 --- a/packages/software-factory/src/factory-tool-builder.ts +++ b/packages/software-factory/src/factory-tool-builder.ts @@ -12,19 +12,14 @@ import type { LooseSingleCardDocument, Relationship, } from '@cardstack/runtime-common'; +import type { BoxelCLIClient } from '@cardstack/boxel-cli/api'; import type { ToolResult } from './factory-agent'; import { buildCardDocument } from './darkfactory-schemas'; import type { ToolExecutor } from './factory-tool-executor'; import type { ToolRegistry } from './factory-tool-registry'; import { - writeFile, - readFile, - searchRealm, - runRealmCommand, ensureJsonExtension, - addCommentToIssue, - type RealmFetchOptions, } from './realm-operations'; // --------------------------------------------------------------------------- @@ -48,10 +43,8 @@ export interface ToolBuilderConfig { targetRealmUrl: string; /** The darkfactory module URL (lives in the software-factory realm, NOT the target realm). */ darkfactoryModuleUrl: string; - /** Per-realm JWTs obtained via getRealmScopedAuth(). */ - realmTokens: Record; - /** Realm server JWT for server-level operations (_create-realm, _realm-auth, _server-session). */ - serverToken?: string; + /** BoxelCLIClient instance — handles per-realm and server-level auth internally. */ + client: BoxelCLIClient; /** Module URL for the TestRun card definition (e.g., `test-results`). */ testResultsModuleUrl?: string; /** Fetch implementation (injectable for testing). */ @@ -181,8 +174,7 @@ function buildWriteFileTool(config: ToolBuilderConfig): FactoryTool { let path = args.path as string; let content = args.content as string; let realmUrl = resolveRealmUrl(config, args.realm as string | undefined); - let fetchOptions = buildFetchOptions(config, realmUrl); - return writeFile(realmUrl, path, content, fetchOptions); + return config.client.write(realmUrl, path, content); }, }; } @@ -210,8 +202,7 @@ function buildReadFileTool(config: ToolBuilderConfig): FactoryTool { execute: async (args) => { let path = args.path as string; let realmUrl = resolveRealmUrl(config, args.realm as string | undefined); - let fetchOptions = buildFetchOptions(config, realmUrl); - return readFile(realmUrl, path, fetchOptions); + return config.client.read(realmUrl, path); }, }; } @@ -239,8 +230,7 @@ function buildSearchRealmTool(config: ToolBuilderConfig): FactoryTool { execute: async (args) => { let query = args.query as Record; let realmUrl = resolveRealmUrl(config, args.realm as string | undefined); - let fetchOptions = buildFetchOptions(config, realmUrl); - let result = await searchRealm(realmUrl, query, fetchOptions); + let result = await config.client.search(realmUrl, query); return result.ok ? { data: result.data } : { error: result.error }; }, }; @@ -262,12 +252,12 @@ async function readPatchDocument( darkfactoryModuleUrl: string, attributes: Record, relationships: Record | undefined, - fetchOptions: RealmFetchOptions, + client: BoxelCLIClient, ): Promise { - let existing = await readFile(realmUrl, path, fetchOptions); + let existing = await client.read(realmUrl, path); if (existing.ok && existing.document) { - let doc = existing.document; + let doc = existing.document as unknown as LooseSingleCardDocument; let existingAttrs = (doc.data.attributes ?? {}) as Record; doc.data.attributes = { ...existingAttrs, ...attributes }; if (relationships && Object.keys(relationships).length > 0) { @@ -343,7 +333,6 @@ function buildUpdateProjectTool(config: ToolBuilderConfig): FactoryTool { | Record | undefined; let realmUrl = config.targetRealmUrl; - let fetchOptions = buildFetchOptions(config, realmUrl); // Read-patch-write: preserve attributes the agent didn't include. let doc = await readPatchDocument( @@ -353,13 +342,12 @@ function buildUpdateProjectTool(config: ToolBuilderConfig): FactoryTool { config.darkfactoryModuleUrl, attributes, relationships, - fetchOptions, + config.client, ); - return writeFile( + return config.client.write( realmUrl, path, JSON.stringify(doc, null, 2), - fetchOptions, ); }, }; @@ -397,7 +385,6 @@ function buildUpdateIssueTool(config: ToolBuilderConfig): FactoryTool { | Record | undefined; let realmUrl = config.targetRealmUrl; - let fetchOptions = buildFetchOptions(config, realmUrl); let doc = await readPatchDocument( realmUrl, @@ -406,13 +393,12 @@ function buildUpdateIssueTool(config: ToolBuilderConfig): FactoryTool { config.darkfactoryModuleUrl, attributes, relationships, - fetchOptions, + config.client, ); - return writeFile( + return config.client.write( realmUrl, path, JSON.stringify(doc, null, 2), - fetchOptions, ); }, }; @@ -444,14 +430,41 @@ function buildAddCommentTool(config: ToolBuilderConfig): FactoryTool { required: ['path', 'body', 'author'], }, execute: async (args) => { - let path = args.path as string; + let path = ensureJsonExtension(args.path as string); let body = args.body as string; let author = args.author as string; let realmUrl = config.targetRealmUrl; - let fetchOptions = buildFetchOptions(config, realmUrl); - return addCommentToIssue(realmUrl, path, { body, author }, fetchOptions); + let existing = await config.client.read(realmUrl, path); + if (!existing.ok || !existing.document) { + return { + ok: false, + error: `Failed to read issue at ${path}: ${existing.error ?? 'no document'}`, + }; + } + + let doc = existing.document as unknown as LooseSingleCardDocument; + let attrs = (doc.data?.attributes ?? {}) as Record; + let existingComments = Array.isArray(attrs.comments) + ? (attrs.comments as unknown[]) + : []; + + existingComments.push({ + body, + author, + datetime: new Date().toISOString(), + }); + + attrs.comments = existingComments; + attrs.updatedAt = new Date().toISOString(); + doc.data.attributes = attrs; + + return config.client.write( + realmUrl, + path, + JSON.stringify(doc, null, 2), + ); }, }; } @@ -473,7 +486,6 @@ function buildCreateKnowledgeTool(config: ToolBuilderConfig): FactoryTool { | Record | undefined; let realmUrl = config.targetRealmUrl; - let fetchOptions = buildFetchOptions(config, realmUrl); let doc = await readPatchDocument( realmUrl, @@ -482,13 +494,12 @@ function buildCreateKnowledgeTool(config: ToolBuilderConfig): FactoryTool { config.darkfactoryModuleUrl, attributes, relationships, - fetchOptions, + config.client, ); - return writeFile( + return config.client.write( realmUrl, path, JSON.stringify(doc, null, 2), - fetchOptions, ); }, }; @@ -513,7 +524,6 @@ function buildCreateCatalogSpecTool(config: ToolBuilderConfig): FactoryTool { | Record | undefined; let realmUrl = config.targetRealmUrl; - let fetchOptions = buildFetchOptions(config, realmUrl); // Spec cards adopt from https://cardstack.com/base/spec, not darkfactory let doc: LooseSingleCardDocument = { data: { @@ -532,11 +542,10 @@ function buildCreateCatalogSpecTool(config: ToolBuilderConfig): FactoryTool { [fieldName: string]: Relationship | Relationship[]; }; } - return writeFile( + return config.client.write( realmUrl, path, JSON.stringify(doc, null, 2), - fetchOptions, ); }, }; @@ -606,21 +615,11 @@ function buildRunCommandTool(config: ToolBuilderConfig): FactoryTool { required: ['command'], }, execute: async (args) => { - if (!config.serverToken) { - return { - status: 'error', - error: 'run_command requires serverToken in config', - }; - } - return runRealmCommand( + return config.client.runCommand( config.realmServerUrl, config.targetRealmUrl, args.command as string, args.commandInput as Record | undefined, - { - authorization: config.serverToken, - fetch: config.fetch, - }, ); }, }; @@ -643,7 +642,7 @@ function buildRegisteredTool( }[]; }, toolExecutor: ToolExecutor, - config: ToolBuilderConfig, + _config: ToolBuilderConfig, ): FactoryTool { let properties: Record = {}; let required: string[] = []; @@ -667,25 +666,9 @@ function buildRegisteredTool( ...(required.length > 0 ? { required } : {}), }, execute: async (args) => { - // For realm-api tools, resolve the correct JWT: - // - Tools with realm-server-url (realm-create, realm-server-session, - // realm-auth) use the server JWT - // - Tools with realm-url use the per-realm JWT - let authorization: string | undefined; - if (manifest.category === 'realm-api') { - let serverUrl = args['realm-server-url'] as string | undefined; - let realmUrl = args['realm-url'] as string | undefined; - if (serverUrl) { - authorization = config.serverToken; - } else if (realmUrl) { - authorization = resolveAuthForUrl(config, realmUrl); - } - } - let result: ToolResult = await toolExecutor.execute( manifest.name, args, - authorization ? { authorization } : undefined, ); return result; }, @@ -702,37 +685,3 @@ function resolveRealmUrl( ): string { return config.targetRealmUrl; } - -function buildFetchOptions( - config: ToolBuilderConfig, - realmUrl: string, -): RealmFetchOptions { - return { - authorization: resolveAuthForUrl(config, realmUrl), - fetch: config.fetch, - }; -} - -/** - * Resolve the correct JWT for a realm URL. Tries an exact match in - * realmTokens first, then tries with trailing slash normalization. - */ -function resolveAuthForUrl( - config: ToolBuilderConfig, - url: string, -): string | undefined { - // Exact match - if (config.realmTokens[url]) { - return config.realmTokens[url]; - } - // Try with/without trailing slash - let normalized = url.endsWith('/') ? url : `${url}/`; - if (config.realmTokens[normalized]) { - return config.realmTokens[normalized]; - } - let withoutSlash = url.endsWith('/') ? url.slice(0, -1) : url; - if (config.realmTokens[withoutSlash]) { - return config.realmTokens[withoutSlash]; - } - return undefined; -} diff --git a/packages/software-factory/src/factory-tool-executor.ts b/packages/software-factory/src/factory-tool-executor.ts index cea259f671b..4946f3fe8d3 100644 --- a/packages/software-factory/src/factory-tool-executor.ts +++ b/packages/software-factory/src/factory-tool-executor.ts @@ -7,12 +7,6 @@ import { BoxelCLIClient } from '@cardstack/boxel-cli/api'; import { ensureTrailingSlash, - readFile, - writeFile, - deleteFile, - searchRealm, - getServerSession, - getRealmScopedAuth, } from './realm-operations'; // --------------------------------------------------------------------------- @@ -56,10 +50,10 @@ export interface ToolExecutorConfig { allowedRealmPrefixes?: string[]; /** Source realm URL — tools must NEVER target this realm. */ sourceRealmUrl?: string; - /** Fetch implementation for realm API calls. */ + /** BoxelCLIClient instance — handles per-realm and server-level auth internally. */ + client: BoxelCLIClient; + /** Fetch implementation for realm API calls (only used for niche server-level tools). */ fetch?: typeof globalThis.fetch; - /** Authorization header value for realm API calls. */ - authorization?: string; /** Per-invocation timeout in ms (default: 60 000). */ timeoutMs?: number; /** Optional log function for auditability. */ @@ -134,7 +128,6 @@ export class ToolExecutor { async execute( toolName: string, toolArgs: Record = {}, - options?: { authorization?: string }, ): Promise { if (!toolName) { throw new ToolNotFoundError('(empty)'); @@ -156,10 +149,6 @@ export class ToolExecutor { // Safety: reject source realm targeting this.enforceRealmSafety(toolName, toolArgs); - // Per-call authorization override (used by ToolBuilder to inject - // the correct per-realm JWT). Falls back to config.authorization. - let authorization = options?.authorization ?? this.config.authorization; - let start = Date.now(); let result: ToolResult; @@ -175,7 +164,6 @@ export class ToolExecutor { result = await this.executeRealmApi( toolName, toolArgs, - authorization, ); break; default: @@ -400,19 +388,12 @@ export class ToolExecutor { private async executeRealmApi( toolName: string, toolArgs: Record, - authorization?: string, ): Promise { - let baseFetch = this.config.fetch ?? globalThis.fetch; let start = Date.now(); - // Wrap fetch with AbortController for timeout enforcement + // AbortController for timeout enforcement on server-level operations let controller = new AbortController(); let timeout = setTimeout(() => controller.abort(), this.timeoutMs); - let fetchImpl = ((input: RequestInfo | URL, init?: RequestInit) => { - return baseFetch(input, { ...init, signal: controller.signal }); - }) as typeof globalThis.fetch; - - let fetchOptions = { authorization, fetch: fetchImpl }; try { let output: unknown; @@ -420,10 +401,9 @@ export class ToolExecutor { switch (toolName) { case 'realm-read': { - let result = await readFile( + let result = await this.config.client.read( String(toolArgs['realm-url']), String(toolArgs['path']), - fetchOptions, ); ok = result.ok; output = ok ? result.document : { error: result.error }; @@ -431,11 +411,10 @@ export class ToolExecutor { } case 'realm-write': { - let result = await writeFile( + let result = await this.config.client.write( String(toolArgs['realm-url']), String(toolArgs['path']), String(toolArgs['content']), - fetchOptions, ); ok = result.ok; output = ok ? result : { error: result.error }; @@ -443,10 +422,9 @@ export class ToolExecutor { } case 'realm-delete': { - let result = await deleteFile( + let result = await this.config.client.delete( String(toolArgs['realm-url']), String(toolArgs['path']), - fetchOptions, ); ok = result.ok; output = ok ? result : { error: result.error }; @@ -474,10 +452,9 @@ export class ToolExecutor { }; break; } - let result = await searchRealm( + let result = await this.config.client.search( String(toolArgs['realm-url']), query, - fetchOptions, ); ok = result.ok; output = result.ok @@ -521,24 +498,79 @@ export class ToolExecutor { } case 'realm-server-session': { - let result = await getServerSession( - String(toolArgs['realm-server-url']), - String(toolArgs['openid-token']), - { fetch: fetchImpl, authorization }, - ); - ok = !!result.token; - output = ok ? { token: result.token } : { error: result.error }; + // Server-level operation — use authedServerFetch from the client + let serverUrl = ensureTrailingSlash(String(toolArgs['realm-server-url'])); + let openidToken = String(toolArgs['openid-token']); + try { + let response = await this.config.client.authedServerFetch( + `${serverUrl}_server-session`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ access_token: openidToken }), + signal: controller.signal, + }, + ); + if (!response.ok) { + ok = false; + let body = await response.text(); + output = { error: `_server-session returned HTTP ${response.status}: ${body.slice(0, 300)}` }; + } else { + let authHeader = response.headers.get('authorization'); + if (authHeader) { + ok = true; + output = { token: authHeader }; + } else { + let bodyText = await response.text(); + let parsed: { token?: string } = {}; + try { parsed = JSON.parse(bodyText); } catch { /* empty */ } + if (typeof parsed.token === 'string' && parsed.token.length > 0) { + ok = true; + output = { token: parsed.token }; + } else { + ok = false; + output = { error: '_server-session succeeded but no token was returned' }; + } + } + } + } catch (err) { + ok = false; + output = { error: err instanceof Error ? err.message : String(err) }; + } break; } case 'realm-auth': { - let result = await getRealmScopedAuth( - String(toolArgs['realm-server-url']), - authorization ?? '', - { fetch: fetchImpl }, - ); - ok = !result.error; - output = ok ? result.tokens : { error: result.error }; + // Server-level operation — use authedServerFetch from the client + let serverUrl = ensureTrailingSlash(String(toolArgs['realm-server-url'])); + try { + let response = await this.config.client.authedServerFetch( + `${serverUrl}_realm-auth`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + signal: controller.signal, + }, + ); + if (!response.ok) { + let body = await response.text(); + ok = false; + output = { error: `_realm-auth returned HTTP ${response.status}: ${body.slice(0, 300)}` }; + } else { + let data = (await response.json()) as Record; + ok = true; + output = data; + } + } catch (err) { + ok = false; + output = { error: err instanceof Error ? err.message : String(err) }; + } break; } diff --git a/packages/software-factory/src/issue-scheduler.ts b/packages/software-factory/src/issue-scheduler.ts index 51e48757187..faecae69126 100644 --- a/packages/software-factory/src/issue-scheduler.ts +++ b/packages/software-factory/src/issue-scheduler.ts @@ -12,13 +12,11 @@ import type { SchedulableIssue, } from './factory-agent-types'; +import type { LooseSingleCardDocument } from '@cardstack/runtime-common'; +import type { BoxelCLIClient } from '@cardstack/boxel-cli/api'; + import { - searchRealm, - readFile, - writeFile, ensureJsonExtension, - addCommentToIssue, - type RealmFetchOptions, } from './realm-operations'; import { logger } from './logger'; @@ -188,29 +186,28 @@ export interface RealmIssueStoreConfig { realmUrl: string; /** Absolute module URL for the darkfactory module (e.g. from inferDarkfactoryModuleUrl()). */ darkfactoryModuleUrl: string; - options?: RealmFetchOptions; + client: BoxelCLIClient; } export class RealmIssueStore implements IssueStore { private realmUrl: string; private darkfactoryModuleUrl: string; - private options: RealmFetchOptions | undefined; + private client: BoxelCLIClient; constructor(config: RealmIssueStoreConfig) { this.realmUrl = config.realmUrl; this.darkfactoryModuleUrl = config.darkfactoryModuleUrl; - this.options = config.options; + this.client = config.client; } async listIssues(): Promise { - let result = await searchRealm( + let result = await this.client.search( this.realmUrl, { filter: { type: { module: this.darkfactoryModuleUrl, name: 'Issue' }, }, }, - this.options, ); if (!result.ok) { @@ -224,7 +221,7 @@ export class RealmIssueStore implements IssueStore { } async refreshIssue(issueId: string): Promise { - let result = await searchRealm( + let result = await this.client.search( this.realmUrl, { filter: { @@ -232,7 +229,6 @@ export class RealmIssueStore implements IssueStore { eq: { id: issueId }, }, }, - this.options, ); if (!result.ok || !result.data?.length) { @@ -250,10 +246,9 @@ export class RealmIssueStore implements IssueStore { ): Promise { // Read the source JSON file (not the indexed card, which can have // stripped relationships during indexing). - let readResult = await readFile( + let readResult = await this.client.read( this.realmUrl, ensureJsonExtension(issueId), - this.options, ); if (!readResult.ok || !readResult.document) { let reason = @@ -265,7 +260,7 @@ export class RealmIssueStore implements IssueStore { ); } - let doc = readResult.document; + let doc = readResult.document as unknown as LooseSingleCardDocument; let attrs = (doc.data.attributes ?? {}) as Record; if (updates.status != null) { @@ -278,11 +273,10 @@ export class RealmIssueStore implements IssueStore { doc.data.attributes = attrs; - let writeResult = await writeFile( + let writeResult = await this.client.write( this.realmUrl, ensureJsonExtension(issueId), JSON.stringify(doc, null, 2), - this.options, ); if (!writeResult.ok) { @@ -298,15 +292,38 @@ export class RealmIssueStore implements IssueStore { issueId: string, comment: { body: string; author: string }, ): Promise { - let result = await addCommentToIssue( + let filePath = ensureJsonExtension(issueId); + let existing = await this.client.read(this.realmUrl, filePath); + if (!existing.ok || !existing.document) { + throw new Error( + `Failed to read issue "${issueId}" for comment: ${existing.error ?? 'no document'}`, + ); + } + + let doc = existing.document as unknown as LooseSingleCardDocument; + let attrs = (doc.data?.attributes ?? {}) as Record; + let existingComments = Array.isArray(attrs.comments) + ? (attrs.comments as unknown[]) + : []; + + existingComments.push({ + body: comment.body, + author: comment.author, + datetime: new Date().toISOString(), + }); + + attrs.comments = existingComments; + attrs.updatedAt = new Date().toISOString(); + doc.data.attributes = attrs; + + let writeResult = await this.client.write( this.realmUrl, - issueId, - comment, - this.options, + filePath, + JSON.stringify(doc, null, 2), ); - if (!result.ok) { + if (!writeResult.ok) { throw new Error( - `Failed to add comment to issue "${issueId}": ${result.error}`, + `Failed to add comment to issue "${issueId}": ${writeResult.error}`, ); } log.info(`Added comment to issue "${issueId}" by ${comment.author}`); @@ -314,7 +331,7 @@ export class RealmIssueStore implements IssueStore { async updateProjectStatus(projectStatus: string): Promise { // We expect exactly one Project card per target realm. - let result = await searchRealm( + let result = await this.client.search( this.realmUrl, { filter: { @@ -322,7 +339,6 @@ export class RealmIssueStore implements IssueStore { }, sort: [{ by: 'lastModified', direction: 'desc' as const }], }, - this.options, ); if (!result.ok || !result.data?.length) { @@ -336,10 +352,9 @@ export class RealmIssueStore implements IssueStore { // Strip the realm URL prefix to get the relative path let relativePath = projectId.replace(this.realmUrl, ''); - let readResult = await readFile( + let readResult = await this.client.read( this.realmUrl, ensureJsonExtension(relativePath), - this.options, ); if (!readResult.ok || !readResult.document) { log.warn( @@ -348,17 +363,16 @@ export class RealmIssueStore implements IssueStore { return; } - let doc = readResult.document; + let doc = readResult.document as unknown as LooseSingleCardDocument; let attrs = (doc.data.attributes ?? {}) as Record; attrs.projectStatus = projectStatus; attrs.updatedAt = new Date().toISOString(); doc.data.attributes = attrs; - let writeResult = await writeFile( + let writeResult = await this.client.write( this.realmUrl, ensureJsonExtension(relativePath), JSON.stringify(doc, null, 2), - this.options, ); if (!writeResult.ok) { diff --git a/packages/software-factory/src/lint-result-cards.ts b/packages/software-factory/src/lint-result-cards.ts index 7c9f8b379d5..631294709fa 100644 --- a/packages/software-factory/src/lint-result-cards.ts +++ b/packages/software-factory/src/lint-result-cards.ts @@ -1,6 +1,5 @@ import type { LooseSingleCardDocument } from '@cardstack/runtime-common'; - -import { readFile, writeFile } from './realm-operations'; +import type { BoxelCLIClient } from '@cardstack/boxel-cli/api'; // --------------------------------------------------------------------------- // Types @@ -29,8 +28,7 @@ export interface LintResultAttributes { export interface LintResultRealmOptions { targetRealmUrl: string; - authorization?: string; - fetch?: typeof globalThis.fetch; + client: BoxelCLIClient; } export interface CreateLintResultOptions { @@ -61,11 +59,10 @@ export async function createLintResult( projectCardUrl: options.projectCardUrl, }); - let result = await writeFile( + let result = await options.client.write( options.targetRealmUrl, `${lintResultId}.json`, JSON.stringify(document, null, 2), - { authorization: options.authorization, fetch: options.fetch }, ); if (!result.ok) { @@ -83,15 +80,9 @@ export async function completeLintResult( attrs: LintResultAttributes, options: LintResultRealmOptions & { projectCardUrl?: string }, ): Promise<{ updated: boolean; error?: string }> { - let fetchOptions = { - authorization: options.authorization, - fetch: options.fetch, - }; - - let readResult = await readFile( + let readResult = await options.client.read( options.targetRealmUrl, lintResultId, - fetchOptions, ); if (!readResult.ok || !readResult.document) { @@ -101,6 +92,8 @@ export async function completeLintResult( }; } + let doc = readResult.document as unknown as LooseSingleCardDocument; + let completionAttrs: Record = { status: attrs.status, completedAt: new Date().toISOString(), @@ -111,25 +104,24 @@ export async function completeLintResult( completionAttrs.errorMessage = attrs.errorMessage; } - readResult.document.data.attributes = { - ...readResult.document.data.attributes, + doc.data.attributes = { + ...doc.data.attributes, ...completionAttrs, }; if (options.projectCardUrl) { let existingRelationships = - (readResult.document.data as Record).relationships ?? {}; - (readResult.document.data as Record).relationships = { + (doc.data as Record).relationships ?? {}; + (doc.data as Record).relationships = { ...(existingRelationships as Record), project: { links: { self: options.projectCardUrl } }, }; } - let writeResult = await writeFile( + let writeResult = await options.client.write( options.targetRealmUrl, `${lintResultId}.json`, - JSON.stringify(readResult.document, null, 2), - fetchOptions, + JSON.stringify(doc, null, 2), ); if (!writeResult.ok) { diff --git a/packages/software-factory/src/realm-issue-relationship-loader.ts b/packages/software-factory/src/realm-issue-relationship-loader.ts index ee35bdafafa..667668c1b2b 100644 --- a/packages/software-factory/src/realm-issue-relationship-loader.ts +++ b/packages/software-factory/src/realm-issue-relationship-loader.ts @@ -13,8 +13,10 @@ import type { import type { IssueRelationshipLoader } from './factory-context-builder'; +import type { LooseSingleCardDocument } from '@cardstack/runtime-common'; +import type { BoxelCLIClient } from '@cardstack/boxel-cli/api'; + import { logger } from './logger'; -import { readFile, type RealmFetchOptions } from './realm-operations'; let log = logger('realm-issue-loader'); @@ -24,7 +26,7 @@ let log = logger('realm-issue-loader'); export interface RealmIssueRelationshipLoaderConfig { realmUrl: string; - options?: RealmFetchOptions; + client: BoxelCLIClient; } // --------------------------------------------------------------------------- @@ -33,11 +35,11 @@ export interface RealmIssueRelationshipLoaderConfig { export class RealmIssueRelationshipLoader implements IssueRelationshipLoader { private realmUrl: string; - private options: RealmFetchOptions | undefined; + private client: BoxelCLIClient; constructor(config: RealmIssueRelationshipLoaderConfig) { this.realmUrl = config.realmUrl; - this.options = config.options; + this.client = config.client; } /** @@ -58,7 +60,7 @@ export class RealmIssueRelationshipLoader implements IssueRelationshipLoader { } let cardId = resolveRelativeLink(projectLink); - let result = await readFile(this.realmUrl, cardId, this.options); + let result = await this.client.read(this.realmUrl, cardId); if (!result.ok || !result.document) { log.warn( @@ -67,13 +69,14 @@ export class RealmIssueRelationshipLoader implements IssueRelationshipLoader { return undefined; } + let doc = result.document as unknown as LooseSingleCardDocument; return { id: cardId, - ...result.document.data.attributes, - ...(result.document.data.relationships - ? { relationships: result.document.data.relationships } + ...doc.data.attributes, + ...(doc.data.relationships + ? { relationships: doc.data.relationships } : {}), - meta: result.document.data.meta, + meta: doc.data.meta, } as ProjectData; } @@ -96,12 +99,13 @@ export class RealmIssueRelationshipLoader implements IssueRelationshipLoader { for (let link of knowledgeLinks) { let cardId = resolveRelativeLink(link); try { - let result = await readFile(this.realmUrl, cardId, this.options); + let result = await this.client.read(this.realmUrl, cardId); if (result.ok && result.document) { + let doc = result.document as unknown as LooseSingleCardDocument; articles.push({ id: cardId, - ...result.document.data.attributes, - meta: result.document.data.meta, + ...doc.data.attributes, + meta: doc.data.meta, } as KnowledgeArticleData); } else { log.warn( @@ -126,7 +130,7 @@ export class RealmIssueRelationshipLoader implements IssueRelationshipLoader { private async fetchFullIssue( issueId: string, ): Promise | undefined> { - let result = await readFile(this.realmUrl, issueId, this.options); + let result = await this.client.read(this.realmUrl, issueId); if (!result.ok || !result.document) { log.warn( `Could not fetch full issue "${issueId}" (status ${result.status ?? 'N/A'}): ${result.error ?? 'not found'}`, @@ -134,13 +138,14 @@ export class RealmIssueRelationshipLoader implements IssueRelationshipLoader { return undefined; } + let doc = result.document as unknown as LooseSingleCardDocument; return { id: issueId, - ...result.document.data.attributes, - ...(result.document.data.relationships - ? { relationships: result.document.data.relationships } + ...doc.data.attributes, + ...(doc.data.relationships + ? { relationships: doc.data.relationships } : {}), - meta: result.document.data.meta, + meta: doc.data.meta, }; } } diff --git a/packages/software-factory/src/realm-operations.ts b/packages/software-factory/src/realm-operations.ts index 440a2f8426a..ed83678f440 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'; @@ -766,17 +764,28 @@ export async function getNextValidationSequenceNumber( moduleUrl: string, cardName: string, options: RealmFetchOptions & { targetRealmUrl: string }, + client?: import('@cardstack/boxel-cli/api').BoxelCLIClient, ): Promise { - let result = await searchRealm( - options.targetRealmUrl, - { - filter: { - on: { module: moduleUrl, name: cardName }, - }, - sort: [{ by: 'sequenceNumber', direction: 'desc' }], - }, - { authorization: options.authorization, fetch: options.fetch }, - ); + let result = client + ? await client.search( + options.targetRealmUrl, + { + filter: { + on: { module: moduleUrl, name: cardName }, + }, + sort: [{ by: 'sequenceNumber', direction: 'desc' }], + }, + ) + : await searchRealm( + options.targetRealmUrl, + { + filter: { + on: { module: moduleUrl, name: cardName }, + }, + sort: [{ by: 'sequenceNumber', direction: 'desc' }], + }, + { authorization: options.authorization, fetch: options.fetch }, + ); if (!result?.ok || !result.data) { return 1; @@ -877,91 +886,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/src/test-run-cards.ts b/packages/software-factory/src/test-run-cards.ts index c9dbe249d4b..f28e65cfc6f 100644 --- a/packages/software-factory/src/test-run-cards.ts +++ b/packages/software-factory/src/test-run-cards.ts @@ -1,6 +1,5 @@ import type { LooseSingleCardDocument } from '@cardstack/runtime-common'; -import { readFile, writeFile } from './realm-operations'; import type { CreateTestRunOptions, TestModuleResultData, @@ -32,11 +31,10 @@ export async function createTestRun( }, ); - let result = await writeFile( + let result = await options.client.write( options.targetRealmUrl, `${testRunId}.json`, JSON.stringify(document, null, 2), - { authorization: options.authorization, fetch: options.fetch }, ); if (!result.ok) { @@ -54,19 +52,13 @@ export async function completeTestRun( attrs: TestRunAttributes, options: TestRunRealmOptions & { projectCardUrl?: string }, ): Promise<{ updated: boolean; error?: string }> { - let fetchOptions = { - authorization: options.authorization, - fetch: options.fetch, - }; - // Retry the read — after a long spawnSync (Playwright), TCP connections // may be stale causing the first fetch to fail with "fetch failed". - let readResult: Awaited> | undefined; + let readResult: Awaited> | undefined; for (let attempt = 0; attempt < 3; attempt++) { - readResult = await readFile( + readResult = await options.client.read( options.targetRealmUrl, testRunId, - fetchOptions, ); if (readResult.ok && readResult.document) { break; @@ -83,6 +75,8 @@ export async function completeTestRun( }; } + let doc = readResult.document as unknown as LooseSingleCardDocument; + let completionAttrs: Record = { status: attrs.status, completedAt: new Date().toISOString(), @@ -93,8 +87,8 @@ export async function completeTestRun( completionAttrs.errorMessage = attrs.errorMessage; } - readResult.document.data.attributes = { - ...readResult.document.data.attributes, + doc.data.attributes = { + ...doc.data.attributes, ...completionAttrs, }; @@ -102,18 +96,17 @@ export async function completeTestRun( // the realm may not include relationships if indexing hasn't completed. if (options.projectCardUrl) { let existingRelationships = - (readResult.document.data as Record).relationships ?? {}; - (readResult.document.data as Record).relationships = { + (doc.data as Record).relationships ?? {}; + (doc.data as Record).relationships = { ...(existingRelationships as Record), project: { links: { self: options.projectCardUrl } }, }; } - let writeResult = await writeFile( + let writeResult = await options.client.write( options.targetRealmUrl, `${testRunId}.json`, - JSON.stringify(readResult.document, null, 2), - fetchOptions, + JSON.stringify(doc, null, 2), ); if (!writeResult.ok) { diff --git a/packages/software-factory/src/test-run-execution.ts b/packages/software-factory/src/test-run-execution.ts index 461ec9fdf87..b46788a92dd 100644 --- a/packages/software-factory/src/test-run-execution.ts +++ b/packages/software-factory/src/test-run-execution.ts @@ -9,7 +9,6 @@ import { chromium } from '@playwright/test'; import { ensureTrailingSlash, getNextValidationSequenceNumber, - searchRealm, } from './realm-operations'; import { createTestRun, completeTestRun } from './test-run-cards'; import { parseQunitResults } from './test-run-parsing'; @@ -43,8 +42,7 @@ export async function resolveTestRun( let realmOptions: TestRunRealmOptions = { targetRealmUrl: options.targetRealmUrl, testResultsModuleUrl: options.testResultsModuleUrl, - authorization: options.authorization, - fetch: options.fetch, + client: options.client, }; let resumeResult = options.forceNew @@ -97,7 +95,7 @@ async function findResumableTestRun( ): Promise { let targetRealmUrl = ensureTrailingSlash(options.targetRealmUrl); - let result = await searchRealm( + let result = await options.client.search( options.targetRealmUrl, { filter: { @@ -106,7 +104,6 @@ async function findResumableTestRun( sort: [{ by: 'sequenceNumber', direction: 'desc' }], page: { size: 1 }, }, - { authorization: options.authorization, fetch: options.fetch }, ); if (!result?.ok) { @@ -164,9 +161,8 @@ async function getNextSequenceNumber( 'TestRun', { targetRealmUrl: options.targetRealmUrl, - authorization: options.authorization, - fetch: options.fetch, }, + options.client, ); return Math.max(seq, minSequenceNumber + 1); } @@ -423,8 +419,7 @@ export async function executeTestRunFromRealm( let realmOptions: TestRunRealmOptions = { targetRealmUrl: options.targetRealmUrl, testResultsModuleUrl: options.testResultsModuleUrl, - authorization: options.authorization, - fetch: options.fetch, + client: options.client, }; let completeOptions = { ...realmOptions, @@ -500,12 +495,13 @@ export async function executeTestRunFromRealm( let realmParam = encodeURIComponent(options.targetRealmUrl); let pageUrl = `${testPageUrl}?liveTest=true&realmURL=${realmParam}&hidepassed`; - if (options.authorization) { + let authToken = options.authorization ?? await options.client.getRealmToken(options.targetRealmUrl); + if (authToken) { let realmOrigin = new URL(options.targetRealmUrl).origin; await page.route(`${realmOrigin}/**`, (route) => { let headers = { ...route.request().headers(), - Authorization: options.authorization!, + Authorization: authToken, }; route.continue({ headers }); }); diff --git a/packages/software-factory/src/test-run-types.ts b/packages/software-factory/src/test-run-types.ts index 531fe56c4a1..b0c252805fa 100644 --- a/packages/software-factory/src/test-run-types.ts +++ b/packages/software-factory/src/test-run-types.ts @@ -34,8 +34,7 @@ export interface TestRunRealmOptions { targetRealmUrl: string; /** URL to the test-results module in the source realm. Required, never inferred. */ testResultsModuleUrl: string; - authorization?: string; - fetch?: typeof globalThis.fetch; + client: import('@cardstack/boxel-cli/api').BoxelCLIClient; } /** Additional options for creating a new TestRun card. */ @@ -129,8 +128,9 @@ export interface ExecuteTestRunOptions { testResultsModuleUrl: string; slug: string; testNames: string[]; + client: import('@cardstack/boxel-cli/api').BoxelCLIClient; + /** Raw JWT for Playwright route injection. If not provided, obtained from client.getRealmToken(). */ authorization?: string; - fetch?: typeof globalThis.fetch; forceNew?: boolean; issueURL?: string; /** URL to the Project card — used for TestRun relationship. */ diff --git a/packages/software-factory/src/validators/lint-step.ts b/packages/software-factory/src/validators/lint-step.ts index 78056600313..bb199580813 100644 --- a/packages/software-factory/src/validators/lint-step.ts +++ b/packages/software-factory/src/validators/lint-step.ts @@ -7,15 +7,13 @@ * persists a LintResult card as the validation artifact. */ +import type { BoxelCLIClient, LintResult } from '@cardstack/boxel-cli/api'; + import type { ValidationStepResult } from '../factory-agent'; import { deriveIssueSlug } from '../factory-agent-types'; import { - fetchRealmFilenames, getNextValidationSequenceNumber, - lintFile, - readFile, - type LintFileResponse, type RealmFetchOptions, } from '../realm-operations'; import { @@ -35,24 +33,23 @@ let log = logger('lint-validation-step'); // --------------------------------------------------------------------------- export interface LintValidationStepConfig { - authorization?: string; - fetch?: typeof globalThis.fetch; + client: BoxelCLIClient; realmServerUrl: string; lintResultsModuleUrl: string; issueId?: string; - /** Injected for testing — defaults to fetchRealmFilenames. */ + /** Injected for testing — defaults to client.listFiles(). */ fetchFilenames?: ( realmUrl: string, options?: RealmFetchOptions, ) => Promise<{ filenames: string[]; error?: string }>; - /** Injected for testing — defaults to lintFile from realm-operations. */ + /** Injected for testing — defaults to client.lint(). */ lintFileFn?: ( realmUrl: string, source: string, filename: string, options?: RealmFetchOptions, - ) => Promise; - /** Injected for testing — defaults to readFile from realm-operations. */ + ) => Promise; + /** Injected for testing — defaults to client.read(). */ readFileFn?: ( realmUrl: string, path: string, @@ -95,7 +92,7 @@ export class LintValidationStep implements ValidationStepRunner { source: string, filename: string, options?: RealmFetchOptions, - ) => Promise; + ) => Promise; private readFileFn: ( realmUrl: string, path: string, @@ -108,9 +105,17 @@ export class LintValidationStep implements ValidationStepRunner { constructor(config: LintValidationStepConfig) { this.config = config; - this.fetchFilenamesFn = config.fetchFilenames ?? fetchRealmFilenames; - this.lintFileFn = config.lintFileFn ?? lintFile; - this.readFileFn = config.readFileFn ?? readFile; + this.fetchFilenamesFn = + config.fetchFilenames ?? + (async (realmUrl: string) => config.client.listFiles(realmUrl)); + this.lintFileFn = + config.lintFileFn ?? + (async (realmUrl: string, source: string, filename: string) => + config.client.lint(realmUrl, source, filename)); + this.readFileFn = + config.readFileFn ?? + (async (realmUrl: string, path: string) => + config.client.read(realmUrl, path)); this.getNextSeqFn = config.getNextSequenceNumber ?? ((slug: string, targetRealmUrl: string) => @@ -121,9 +126,8 @@ export class LintValidationStep implements ValidationStepRunner { 'LintResult', { targetRealmUrl, - authorization: config.authorization, - fetch: config.fetch, }, + config.client, )); } @@ -184,8 +188,7 @@ export class LintValidationStep implements ValidationStepRunner { this.config.lintResultsModuleUrl, { targetRealmUrl, - authorization: this.config.authorization, - fetch: this.config.fetch, + client: this.config.client, sequenceNumber: seq, issueURL, }, @@ -210,14 +213,10 @@ export class LintValidationStep implements ValidationStepRunner { let startedAt = Date.now(); let allFileResults: LintFileResultData[] = []; let allViolations: LintValidationDetails['violations'] = []; - let fetchOpts: RealmFetchOptions = { - authorization: this.config.authorization, - fetch: this.config.fetch, - }; for (let file of lintableFiles) { try { - let readResult = await this.readFileFn(targetRealmUrl, file, fetchOpts); + let readResult = await this.readFileFn(targetRealmUrl, file); if (!readResult.ok) { let message = `Could not read ${file}: ${readResult.error ?? 'read failed'}`; log.warn(message); @@ -261,7 +260,6 @@ export class LintValidationStep implements ValidationStepRunner { targetRealmUrl, readResult.content, file, - fetchOpts, ); let violations: LintViolationData[] = lintResponse.messages.map( @@ -322,8 +320,7 @@ export class LintValidationStep implements ValidationStepRunner { }, { targetRealmUrl, - authorization: this.config.authorization, - fetch: this.config.fetch, + client: this.config.client, }, ); if (!completeResult.updated) { @@ -398,10 +395,7 @@ export class LintValidationStep implements ValidationStepRunner { private async discoverLintableFiles( targetRealmUrl: string, ): Promise { - let result = await this.fetchFilenamesFn(targetRealmUrl, { - authorization: this.config.authorization, - fetch: this.config.fetch, - }); + let result = await this.fetchFilenamesFn(targetRealmUrl); if (result.error) { log.warn(`Failed to fetch realm filenames: ${result.error}`); diff --git a/packages/software-factory/src/validators/test-step.ts b/packages/software-factory/src/validators/test-step.ts index d12e29de03d..8cf97b81c4f 100644 --- a/packages/software-factory/src/validators/test-step.ts +++ b/packages/software-factory/src/validators/test-step.ts @@ -10,16 +10,14 @@ * reads after boxel-cli integration — cheap rather than HTTP round-trips. */ +import type { BoxelCLIClient } from '@cardstack/boxel-cli/api'; + import type { ValidationStepResult } from '../factory-agent'; import { deriveIssueSlug } from '../factory-agent-types'; import type { LooseSingleCardDocument } from '@cardstack/runtime-common'; import { executeTestRunFromRealm } from '../test-run-execution'; -import { - fetchRealmFilenames, - readFile, - type RealmFetchOptions, -} from '../realm-operations'; +import type { RealmFetchOptions } from '../realm-operations'; import type { ExecuteTestRunOptions, TestRunHandle } from '../test-run-types'; import { logger } from '../logger'; @@ -32,20 +30,19 @@ let log = logger('test-validation-step'); // --------------------------------------------------------------------------- export interface TestValidationStepConfig { - authorization?: string; - fetch?: typeof globalThis.fetch; + client: BoxelCLIClient; realmServerUrl: string; hostAppUrl: string; testResultsModuleUrl: string; issueId?: string; /** Injected for testing — defaults to executeTestRunFromRealm. */ executeTestRun?: (options: ExecuteTestRunOptions) => Promise; - /** Injected for testing — defaults to fetchRealmFilenames. */ + /** Injected for testing — defaults to client.listFiles(). */ fetchFilenames?: ( realmUrl: string, options?: RealmFetchOptions, ) => Promise<{ filenames: string[]; error?: string }>; - /** Injected for testing — defaults to readFile from realm-operations. */ + /** Injected for testing — defaults to client.read(). */ readCard?: ( realmUrl: string, path: string, @@ -104,8 +101,19 @@ export class TestValidationStep implements ValidationStepRunner { constructor(config: TestValidationStepConfig) { this.config = config; this.executeTestRunFn = config.executeTestRun ?? executeTestRunFromRealm; - this.fetchFilenamesFn = config.fetchFilenames ?? fetchRealmFilenames; - this.readCardFn = config.readCard ?? readFile; + this.fetchFilenamesFn = + config.fetchFilenames ?? + (async (realmUrl: string) => config.client.listFiles(realmUrl)); + this.readCardFn = + config.readCard ?? + (async (realmUrl: string, path: string) => { + let result = await config.client.read(realmUrl, path); + return { + ok: result.ok, + document: result.document as LooseSingleCardDocument | undefined, + error: result.error, + }; + }); } async run(targetRealmUrl: string): Promise { @@ -150,8 +158,7 @@ export class TestValidationStep implements ValidationStepRunner { testResultsModuleUrl: this.config.testResultsModuleUrl, slug, testNames: [], - authorization: this.config.authorization, - fetch: this.config.fetch, + client: this.config.client, realmServerUrl: this.config.realmServerUrl, hostAppUrl: this.config.hostAppUrl, forceNew: true, @@ -258,10 +265,7 @@ export class TestValidationStep implements ValidationStepRunner { // ------------------------------------------------------------------------- private async discoverTestFiles(targetRealmUrl: string): Promise { - let result = await this.fetchFilenamesFn(targetRealmUrl, { - authorization: this.config.authorization, - fetch: this.config.fetch, - }); + let result = await this.fetchFilenamesFn(targetRealmUrl); if (result.error) { log.warn(`Failed to fetch realm filenames: ${result.error}`); @@ -276,10 +280,7 @@ export class TestValidationStep implements ValidationStepRunner { testRunId: string, ): Promise { try { - let result = await this.readCardFn(targetRealmUrl, testRunId, { - authorization: this.config.authorization, - fetch: this.config.fetch, - }); + let result = await this.readCardFn(targetRealmUrl, testRunId); if (!result.ok || !result.document) { log.warn( diff --git a/packages/software-factory/src/validators/validation-pipeline.ts b/packages/software-factory/src/validators/validation-pipeline.ts index ec03f4f6f2a..83d067deb52 100644 --- a/packages/software-factory/src/validators/validation-pipeline.ts +++ b/packages/software-factory/src/validators/validation-pipeline.ts @@ -141,8 +141,7 @@ export class ValidationPipeline implements Validator { // --------------------------------------------------------------------------- export interface ValidationPipelineConfig { - authorization?: string; - fetch?: typeof globalThis.fetch; + client: import('@cardstack/boxel-cli/api').BoxelCLIClient; realmServerUrl: string; hostAppUrl: string; testResultsModuleUrl: string; @@ -160,8 +159,7 @@ export function createDefaultPipeline( config: ValidationPipelineConfig, ): ValidationPipeline { let testConfig: TestValidationStepConfig = { - authorization: config.authorization, - fetch: config.fetch, + client: config.client, realmServerUrl: config.realmServerUrl, hostAppUrl: config.hostAppUrl, testResultsModuleUrl: config.testResultsModuleUrl, @@ -170,8 +168,7 @@ export function createDefaultPipeline( }; let lintConfig: LintValidationStepConfig = { - authorization: config.authorization, - fetch: config.fetch, + client: config.client, realmServerUrl: config.realmServerUrl, lintResultsModuleUrl: config.lintResultsModuleUrl, issueId: config.issueId, diff --git a/packages/software-factory/tests/factory-entrypoint.test.ts b/packages/software-factory/tests/factory-entrypoint.test.ts index 2aa5485ab6c..42213041015 100644 --- a/packages/software-factory/tests/factory-entrypoint.test.ts +++ b/packages/software-factory/tests/factory-entrypoint.test.ts @@ -31,7 +31,6 @@ const bootstrappedTargetRealm: FactoryTargetRealmBootstrapResult = { serverUrl: 'https://realms.example.test/', ownerUsername: 'hassan', createdRealm: true, - authorization: 'Bearer token', }; const mockSeedResult: SeedIssueResult = { issueId: 'Issues/bootstrap-seed', @@ -235,7 +234,6 @@ module('factory-entrypoint', function (hooks) { url: resolution.url, serverUrl: resolution.serverUrl, createdRealm: false, - authorization: 'Bearer target-realm-token', }), createSeed: async (_brief, _url, options) => { capturedDarkfactoryModuleUrl = options.darkfactoryModuleUrl; diff --git a/packages/software-factory/tests/factory-seed.spec.ts b/packages/software-factory/tests/factory-seed.spec.ts index aee927b8234..b47e66a2e96 100644 --- a/packages/software-factory/tests/factory-seed.spec.ts +++ b/packages/software-factory/tests/factory-seed.spec.ts @@ -7,6 +7,7 @@ import type { FactoryBrief } from '../src/factory-brief'; import { RealmIssueStore } from '../src/issue-scheduler'; import { expect, test } from './fixtures'; import { buildAuthenticatedFetch } from './helpers/matrix-auth'; +import { createMockClient } from './helpers/mock-client'; const bootstrapTargetDir = resolve( process.cwd(), @@ -54,7 +55,7 @@ function buildSeedContext(realm: { realmURL: URL; ownerBearerToken: string }) { return { authenticatedFetch, darkfactoryModuleUrl, - seedOptions: { fetch: authenticatedFetch, darkfactoryModuleUrl }, + seedOptions: { client: createMockClient({ fetch: authenticatedFetch }), fetch: authenticatedFetch, darkfactoryModuleUrl }, }; } @@ -111,7 +112,7 @@ test('creates bootstrap seed issue in a live realm', async ({ realm }) => { let issueStore = new RealmIssueStore({ realmUrl: realm.realmURL.href, darkfactoryModuleUrl, - options: { fetch: authenticatedFetch }, + client: createMockClient({ fetch: authenticatedFetch }), }); let issues = await issueStore.listIssues(); diff --git a/packages/software-factory/tests/factory-target-realm.test.ts b/packages/software-factory/tests/factory-target-realm.test.ts index 1b44ce3a3ed..1d622df0083 100644 --- a/packages/software-factory/tests/factory-target-realm.test.ts +++ b/packages/software-factory/tests/factory-target-realm.test.ts @@ -90,14 +90,12 @@ module('factory-target-realm', function (hooks) { return { createdRealm: true, url: resolution.url, - authorization: 'Bearer target-realm-token', }; }, }); assert.strictEqual(createCalls, 1); assert.true(result.createdRealm); - assert.strictEqual(result.authorization, 'Bearer target-realm-token'); }); test('bootstrapFactoryTargetRealm reports when the realm already exists', async function (assert) { @@ -111,12 +109,10 @@ module('factory-target-realm', function (hooks) { createRealm: async () => ({ createdRealm: false, url: resolution.url, - authorization: 'Bearer target-realm-token', }), }); assert.false(result.createdRealm); - assert.strictEqual(result.authorization, 'Bearer target-realm-token'); }); test('bootstrapFactoryTargetRealm uses the canonical realm URL returned by create-realm', async function (assert) { @@ -130,7 +126,6 @@ module('factory-target-realm', function (hooks) { createRealm: async () => ({ createdRealm: true, url: 'https://realms.example.test/hassan/personal/', - authorization: 'Bearer target-realm-token', }), }); @@ -138,7 +133,6 @@ module('factory-target-realm', function (hooks) { result.url, 'https://realms.example.test/hassan/personal/', ); - assert.strictEqual(result.authorization, 'Bearer target-realm-token'); }); }); diff --git a/packages/software-factory/tests/factory-test-realm.spec.ts b/packages/software-factory/tests/factory-test-realm.spec.ts index 0155f64d41d..3bcd6370cc9 100644 --- a/packages/software-factory/tests/factory-test-realm.spec.ts +++ b/packages/software-factory/tests/factory-test-realm.spec.ts @@ -8,6 +8,7 @@ import { type TestRunRealmOptions, } from '../src/factory-test-realm'; import { readFile, waitForRealmFile, writeFile } from '../src/realm-operations'; +import { createMockClient } from './helpers/mock-client'; const fixtureRealmDir = resolve( process.cwd(), @@ -105,8 +106,8 @@ test.describe('factory-test-realm e2e', () => { realmServerUrl: realm.realmServerURL.href, slug: 'hello-e2e', testNames: [], + client: createMockClient({ fetch: globalThis.fetch }), authorization, - fetch: globalThis.fetch, hostAppUrl: realm.hostAppUrl, }); @@ -172,8 +173,8 @@ test.describe('factory-test-realm e2e', () => { realmServerUrl: realm.realmServerURL.href, slug: 'hello-fail', testNames: [], + client: createMockClient({ fetch: globalThis.fetch }), authorization, - fetch: globalThis.fetch, hostAppUrl: realm.hostAppUrl, }); @@ -213,7 +214,7 @@ test.describe('factory-test-realm e2e', () => { let options: TestRunRealmOptions = { targetRealmUrl: 'http://localhost:1/', testResultsModuleUrl: 'http://localhost:1/software-factory/test-results', - fetch: globalThis.fetch, + client: createMockClient({ fetch: globalThis.fetch }), }; let result = await createTestRun('error-test', ['test A'], options); diff --git a/packages/software-factory/tests/factory-test-realm.test.ts b/packages/software-factory/tests/factory-test-realm.test.ts index ce157490693..b717c14c90e 100644 --- a/packages/software-factory/tests/factory-test-realm.test.ts +++ b/packages/software-factory/tests/factory-test-realm.test.ts @@ -3,6 +3,7 @@ import { module, test } from 'qunit'; import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type'; import type { TestResult } from '../src/factory-agent'; +import { createMockClient } from './helpers/mock-client'; import { buildTestRunCardDocument, completeTestRun, @@ -12,7 +13,6 @@ import { resolveTestRun, type TestRunAttributes, } from '../src/factory-test-realm'; -import { pullRealmFiles } from '../src/realm-operations'; // --------------------------------------------------------------------------- // Shared helpers @@ -23,8 +23,16 @@ const testRealmOptions = { testResultsModuleUrl: 'https://realms.example.test/software-factory/test-results', realmServerUrl: 'https://realms.example.test/', + client: createMockClient(), }; +function testRealmOptionsWithFetch(mockFetch: typeof globalThis.fetch) { + return { + ...testRealmOptions, + client: createMockClient({ fetch: mockFetch }), + }; +} + // --------------------------------------------------------------------------- // parseQunitResults — converts QUnit browser results to TestRunAttributes // --------------------------------------------------------------------------- @@ -407,9 +415,7 @@ module('factory-test-realm > createTestRun', function () { 'define-sticky-note', ['test A', 'test B'], { - ...testRealmOptions, - authorization: 'Bearer test-token', - fetch: mockFetch, + ...testRealmOptionsWithFetch(mockFetch), sequenceNumber: 1, }, ); @@ -426,8 +432,8 @@ module('factory-test-realm > createTestRun', function () { assert.strictEqual(capturedInit?.method, 'POST'); let headers = capturedInit?.headers as Record; - assert.strictEqual(headers['Content-Type'], SupportedMimeType.CardSource); - assert.strictEqual(headers['Authorization'], 'Bearer test-token'); + assert.strictEqual(headers['Content-Type'], 'application/vnd.card+source'); + // Auth is now handled by BoxelCLIClient internally let body = JSON.parse(capturedInit?.body as string); assert.strictEqual(body.data.meta.adoptsFrom.name, 'TestRun'); @@ -440,8 +446,7 @@ module('factory-test-realm > createTestRun', function () { }) as typeof globalThis.fetch; let result = await createTestRun('my-test', ['test A'], { - ...testRealmOptions, - fetch: mockFetch, + ...testRealmOptionsWithFetch(mockFetch), }); assert.false(result.created); @@ -454,8 +459,7 @@ module('factory-test-realm > createTestRun', function () { }) as typeof globalThis.fetch; let result = await createTestRun('my-test', ['test A'], { - ...testRealmOptions, - fetch: mockFetch, + ...testRealmOptionsWithFetch(mockFetch), }); assert.false(result.created); @@ -518,7 +522,7 @@ module('factory-test-realm > completeTestRun', function () { let result = await completeTestRun( 'Validations/test_define-sticky-note-1', attrs, - { ...testRealmOptions, fetch: mockFetch }, + testRealmOptionsWithFetch(mockFetch), ); assert.true(result.updated); @@ -544,8 +548,7 @@ module('factory-test-realm > completeTestRun', function () { }; let result = await completeTestRun('Validations/test_missing-1', attrs, { - ...testRealmOptions, - fetch: mockFetch, + ...testRealmOptionsWithFetch(mockFetch), }); assert.false(result.updated); @@ -610,7 +613,7 @@ module('factory-test-realm > resolveTestRun', function () { testNames: ['test A'], realmServerUrl: 'https://realms.example.test/', hostAppUrl: 'https://realms.example.test/', - fetch: buildMockSearchFetch([]), + client: createMockClient({ fetch: buildMockSearchFetch([]) }), }); assert.strictEqual(handle.status, 'running'); @@ -625,13 +628,13 @@ module('factory-test-realm > resolveTestRun', function () { testNames: ['test A'], realmServerUrl: 'https://realms.example.test/', hostAppUrl: 'https://realms.example.test/', - fetch: buildMockSearchFetch([ + client: createMockClient({ fetch: buildMockSearchFetch([ { id: 'Validations/test_my-issue-2', status: 'passed', sequenceNumber: 2, }, - ]), + ]) }), }); assert.strictEqual(handle.status, 'running'); @@ -646,7 +649,7 @@ module('factory-test-realm > resolveTestRun', function () { testNames: ['test A', 'test B'], realmServerUrl: 'https://realms.example.test/', hostAppUrl: 'https://realms.example.test/', - fetch: buildMockSearchFetch([ + client: createMockClient({ fetch: buildMockSearchFetch([ { id: 'Validations/test_my-issue-2', status: 'running', @@ -660,7 +663,7 @@ module('factory-test-realm > resolveTestRun', function () { }, ], }, - ]), + ]) }), }); assert.strictEqual(handle.status, 'running'); @@ -676,13 +679,13 @@ module('factory-test-realm > resolveTestRun', function () { forceNew: true, realmServerUrl: 'https://realms.example.test/', hostAppUrl: 'https://realms.example.test/', - fetch: buildMockSearchFetch([ + client: createMockClient({ fetch: buildMockSearchFetch([ { id: 'Validations/test_my-issue-2', status: 'running', sequenceNumber: 2, }, - ]), + ]) }), }); assert.strictEqual(handle.status, 'running'); @@ -700,13 +703,13 @@ module('factory-test-realm > resolveTestRun', function () { testNames: ['test A'], realmServerUrl: 'https://realms.example.test/', hostAppUrl: 'https://realms.example.test/', - fetch: buildMockSearchFetch([ + client: createMockClient({ fetch: buildMockSearchFetch([ { id: 'Validations/test_my-issue-3', status: 'passed', sequenceNumber: 3, }, - ]), + ]) }), }); assert.strictEqual(handle.status, 'running'); @@ -721,13 +724,13 @@ module('factory-test-realm > resolveTestRun', function () { testNames: ['test A'], realmServerUrl: 'https://realms.example.test/', hostAppUrl: 'https://realms.example.test/', - fetch: buildMockSearchFetch([ + client: createMockClient({ fetch: buildMockSearchFetch([ { id: 'Validations/test_my-issue-7', status: 'failed', sequenceNumber: 7, }, - ]), + ]) }), }); assert.strictEqual(handle.testRunId, 'Validations/test_my-issue-8'); @@ -745,7 +748,7 @@ module('factory-test-realm > resolveTestRun', function () { forceNew: true, realmServerUrl: 'https://realms.example.test/', hostAppUrl: 'https://realms.example.test/', - fetch: buildMockSearchFetch([]), + client: createMockClient({ fetch: buildMockSearchFetch([]) }), }); assert.strictEqual(handle1.testRunId, 'Validations/test_my-issue-1'); @@ -760,13 +763,13 @@ module('factory-test-realm > resolveTestRun', function () { forceNew: true, realmServerUrl: 'https://realms.example.test/', hostAppUrl: 'https://realms.example.test/', - fetch: buildMockSearchFetch([ + client: createMockClient({ fetch: buildMockSearchFetch([ { id: 'Validations/test_my-issue-1', status: 'running', sequenceNumber: 1, }, - ]), + ]) }), }); assert.strictEqual(handle2.testRunId, 'Validations/test_my-issue-2'); @@ -791,7 +794,7 @@ module('factory-test-realm > resolveTestRun', function () { forceNew: true, realmServerUrl: 'https://realms.example.test/', hostAppUrl: 'https://realms.example.test/', - fetch: buildMockSearchFetch([]), + client: createMockClient({ fetch: buildMockSearchFetch([]) }), }); assert.strictEqual(handle1.testRunId, 'Validations/test_my-ticket-1'); @@ -807,7 +810,7 @@ module('factory-test-realm > resolveTestRun', function () { lastSequenceNumber: 1, realmServerUrl: 'https://realms.example.test/', hostAppUrl: 'https://realms.example.test/', - fetch: buildMockSearchFetch([]), + client: createMockClient({ fetch: buildMockSearchFetch([]) }), }); assert.strictEqual( @@ -826,7 +829,7 @@ module('factory-test-realm > resolveTestRun', function () { lastSequenceNumber: 2, realmServerUrl: 'https://realms.example.test/', hostAppUrl: 'https://realms.example.test/', - fetch: buildMockSearchFetch([]), + client: createMockClient({ fetch: buildMockSearchFetch([]) }), }); assert.strictEqual( @@ -837,128 +840,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 diff --git a/packages/software-factory/tests/factory-tool-builder.test.ts b/packages/software-factory/tests/factory-tool-builder.test.ts index 1473ecc53df..ba4069f6547 100644 --- a/packages/software-factory/tests/factory-tool-builder.test.ts +++ b/packages/software-factory/tests/factory-tool-builder.test.ts @@ -12,6 +12,7 @@ import { } from '../src/factory-tool-builder'; import type { ToolExecutor } from '../src/factory-tool-executor'; import { ToolRegistry } from '../src/factory-tool-registry'; +import { createMockClient } from './helpers/mock-client'; // --------------------------------------------------------------------------- // Helpers @@ -19,8 +20,6 @@ import { ToolRegistry } from '../src/factory-tool-registry'; const TARGET_REALM = 'https://realms.example.test/user/target/'; const TEST_REALM = 'https://realms.example.test/user/target-tests/'; -const TARGET_TOKEN = 'Bearer target-jwt-123'; -const TEST_TOKEN = 'Bearer test-jwt-456'; const DEFAULT_CARD_TYPE_SCHEMAS = new Map< string, @@ -59,15 +58,13 @@ const DEFAULT_CARD_TYPE_SCHEMAS = new Map< ]); function makeConfig(overrides?: Partial): ToolBuilderConfig { + let mockFetch = overrides?.fetch as typeof globalThis.fetch | undefined; return { targetRealmUrl: TARGET_REALM, darkfactoryModuleUrl: 'https://realms.example.test/software-factory/darkfactory', realmServerUrl: 'https://realms.example.test/', - realmTokens: { - [TARGET_REALM]: TARGET_TOKEN, - [TEST_REALM]: TEST_TOKEN, - }, + client: createMockClient({ fetch: mockFetch }), cardTypeSchemas: DEFAULT_CARD_TYPE_SCHEMAS, ...overrides, }; @@ -301,7 +298,7 @@ module('factory-tool-builder > realm targeting and auth', function () { await writeTool.execute({ path: 'card.gts', content: 'content' }); assert.strictEqual(requests[0].url, `${TARGET_REALM}card.gts`); - assert.strictEqual(requests[0].headers['Authorization'], TARGET_TOKEN); + // Auth is now handled by BoxelCLIClient internally }); test('read_file uses correct JWT for target realm', async function (assert) { @@ -316,7 +313,8 @@ module('factory-tool-builder > realm targeting and auth', function () { await readTool.execute({ path: 'card.gts' }); - assert.strictEqual(requests[0].headers['Authorization'], TARGET_TOKEN); + // Auth is now handled by BoxelCLIClient — verify request was made + assert.true(requests.length > 0, 'client made at least one request'); }); test('update_issue uses target realm JWT', async function (assert) { @@ -346,7 +344,7 @@ module('factory-tool-builder > realm targeting and auth', function () { // First request is GET (read), second is POST (write) let writeRequest = requests.find((r) => r.method === 'POST')!; - assert.strictEqual(writeRequest.headers['Authorization'], TARGET_TOKEN); + // Auth is now handled by BoxelCLIClient internally assert.strictEqual(writeRequest.url, `${TARGET_REALM}Issues/1.json`); }); @@ -363,7 +361,7 @@ module('factory-tool-builder > realm targeting and auth', function () { attributes: { articleTitle: 'Guide' }, }); - assert.strictEqual(requests[0].headers['Authorization'], TARGET_TOKEN); + // Auth is now handled by BoxelCLIClient internally assert.strictEqual(requests[0].url, `${TARGET_REALM}Knowledge/deploy.json`); }); @@ -379,14 +377,15 @@ module('factory-tool-builder > realm targeting and auth', function () { query: { filter: { type: { name: 'Issue' } } }, }); - assert.strictEqual(requests[0].headers['Authorization'], TARGET_TOKEN); + // Auth is now handled by BoxelCLIClient — verify request was made + assert.true(requests.length > 0, 'client made at least one request'); }); test('omits Authorization when token not found for realm', async function (assert) { let { fetch: mockFetch, requests } = createMockFetch(200, {}); let registry = new ToolRegistry(); let { executor } = createMockToolExecutor(new Map()); - let config = makeConfig({ fetch: mockFetch, realmTokens: {} }); + let config = makeConfig({ fetch: mockFetch }); let tools = buildFactoryTools(config, executor, registry); let writeTool = findTool(tools, 'write_file'); @@ -463,10 +462,10 @@ module('factory-tool-builder > registered tool delegation', function () { // Registered tool per-realm JWT resolution // --------------------------------------------------------------------------- -const SERVER_TOKEN = 'Bearer server-jwt-789'; - -module('factory-tool-builder > registered tool JWT resolution', function () { - test('realm-api tool with realm-url gets per-realm JWT for target realm', async function (assert) { +module('factory-tool-builder > registered tool delegation (auth)', function () { + test('realm-api tool with realm-url delegates to executor without explicit auth', async function (assert) { + // Auth is now handled by BoxelCLIClient — registered tools + // no longer inject per-realm or server JWTs via the executor options. let toolResult: ToolResult = { tool: 'realm-read', exitCode: 0, @@ -477,7 +476,7 @@ module('factory-tool-builder > registered tool JWT resolution', function () { new Map([['realm-read', toolResult]]), ); let registry = new ToolRegistry(); - let config = makeConfig({ serverToken: SERVER_TOKEN }); + let config = makeConfig(); let tools = buildFactoryTools(config, executor, registry); let realmReadTool = findTool(tools, 'realm-read'); @@ -489,12 +488,12 @@ module('factory-tool-builder > registered tool JWT resolution', function () { assert.strictEqual(calls.length, 1); assert.strictEqual( calls[0].authorization, - TARGET_TOKEN, - 'should use target realm JWT', + undefined, + 'no explicit authorization — client handles auth', ); }); - test('realm-api tool with realm-url gets per-realm JWT for test realm', async function (assert) { + test('realm-api tool with realm-url for test realm delegates without explicit auth', async function (assert) { let toolResult: ToolResult = { tool: 'realm-read', exitCode: 0, @@ -505,7 +504,7 @@ module('factory-tool-builder > registered tool JWT resolution', function () { new Map([['realm-read', toolResult]]), ); let registry = new ToolRegistry(); - let config = makeConfig({ serverToken: SERVER_TOKEN }); + let config = makeConfig(); let tools = buildFactoryTools(config, executor, registry); let realmReadTool = findTool(tools, 'realm-read'); @@ -517,12 +516,12 @@ module('factory-tool-builder > registered tool JWT resolution', function () { assert.strictEqual(calls.length, 1); assert.strictEqual( calls[0].authorization, - TEST_TOKEN, - 'should use test realm JWT', + undefined, + 'no explicit authorization — client handles auth', ); }); - test('realm-server-url tools get server JWT instead of per-realm JWT', async function (assert) { + test('realm-server-url tools delegate to executor without explicit auth (client handles it)', async function (assert) { let toolResult: ToolResult = { tool: 'realm-auth', exitCode: 0, @@ -533,7 +532,7 @@ module('factory-tool-builder > registered tool JWT resolution', function () { new Map([['realm-auth', toolResult]]), ); let registry = new ToolRegistry(); - let config = makeConfig({ serverToken: SERVER_TOKEN }); + let config = makeConfig(); let tools = buildFactoryTools(config, executor, registry); let realmAuthTool = findTool(tools, 'realm-auth'); @@ -544,12 +543,12 @@ module('factory-tool-builder > registered tool JWT resolution', function () { assert.strictEqual(calls.length, 1); assert.strictEqual( calls[0].authorization, - SERVER_TOKEN, - 'should use server JWT for realm-server-url tools', + undefined, + 'no explicit authorization — client handles server auth', ); }); - test('realm-create gets server JWT', async function (assert) { + test('realm-create delegates to executor without explicit auth', async function (assert) { let toolResult: ToolResult = { tool: 'realm-create', exitCode: 0, @@ -560,7 +559,7 @@ module('factory-tool-builder > registered tool JWT resolution', function () { new Map([['realm-create', toolResult]]), ); let registry = new ToolRegistry(); - let config = makeConfig({ serverToken: SERVER_TOKEN }); + let config = makeConfig(); let tools = buildFactoryTools(config, executor, registry); let realmCreateTool = findTool(tools, 'realm-create'); @@ -573,12 +572,12 @@ module('factory-tool-builder > registered tool JWT resolution', function () { assert.strictEqual(calls.length, 1); assert.strictEqual( calls[0].authorization, - SERVER_TOKEN, - 'should use server JWT for realm-create', + undefined, + 'no explicit authorization — client handles auth', ); }); - test('script tools do not get per-realm JWT override', async function (assert) { + test('script tools do not get any auth override', async function (assert) { let toolResult: ToolResult = { tool: 'search-realm', exitCode: 0, @@ -589,7 +588,7 @@ module('factory-tool-builder > registered tool JWT resolution', function () { new Map([['search-realm', toolResult]]), ); let registry = new ToolRegistry(); - let config = makeConfig({ serverToken: SERVER_TOKEN }); + let config = makeConfig(); let tools = buildFactoryTools(config, executor, registry); let searchRealmTool = findTool(tools, 'search-realm'); @@ -614,7 +613,7 @@ module('factory-tool-builder > registered tool JWT resolution', function () { new Map([['realm-read', toolResult]]), ); let registry = new ToolRegistry(); - let config = makeConfig({ serverToken: SERVER_TOKEN }); + let config = makeConfig(); let tools = buildFactoryTools(config, executor, registry); let realmReadTool = findTool(tools, 'realm-read'); diff --git a/packages/software-factory/tests/factory-tool-executor.integration.test.ts b/packages/software-factory/tests/factory-tool-executor.integration.test.ts index 14d04dfd0f9..df0cf6241e5 100644 --- a/packages/software-factory/tests/factory-tool-executor.integration.test.ts +++ b/packages/software-factory/tests/factory-tool-executor.integration.test.ts @@ -5,6 +5,7 @@ import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type import { ToolExecutor } from '../src/factory-tool-executor'; import { ToolRegistry } from '../src/factory-tool-registry'; +import { createMockClient } from './helpers/mock-client'; // --------------------------------------------------------------------------- // Test server helpers @@ -89,8 +90,8 @@ module('factory-tool-executor integration > realm-api requests', function () { let realmUrl = `${origin}/user/target/`; let executor = new ToolExecutor(registry, { packageRoot: '/fake', + client: createMockClient({ fetch: globalThis.fetch }), targetRealmUrl: realmUrl, - authorization: 'Bearer realm-jwt-for-user', }); let result = await executor.execute('realm-read', { @@ -101,13 +102,11 @@ module('factory-tool-executor integration > realm-api requests', function () { assert.strictEqual(result.exitCode, 0, 'exitCode is 0'); assert.strictEqual(captured!.method, 'GET'); assert.strictEqual(captured!.url, '/user/target/Card/hello.gts'); - assert.strictEqual( - captured!.headers.authorization, - 'Bearer realm-jwt-for-user', - ); + // Auth is now handled by BoxelCLIClient internally — mock client + // does not inject Authorization headers. assert.strictEqual( captured!.headers.accept, - SupportedMimeType.CardSource, + 'application/vnd.card+source', ); } finally { await stopServer(server); @@ -127,8 +126,8 @@ module('factory-tool-executor integration > realm-api requests', function () { let realmUrl = `${origin}/user/target/`; let executor = new ToolExecutor(registry, { packageRoot: '/fake', + client: createMockClient({ fetch: globalThis.fetch }), targetRealmUrl: realmUrl, - authorization: 'Bearer realm-jwt-for-user', }); let result = await executor.execute('realm-write', { @@ -140,13 +139,9 @@ module('factory-tool-executor integration > realm-api requests', function () { assert.strictEqual(result.exitCode, 0); assert.strictEqual(captured!.method, 'POST'); assert.strictEqual(captured!.url, '/user/target/CardDef/my-card.gts'); - assert.strictEqual( - captured!.headers.authorization, - 'Bearer realm-jwt-for-user', - ); assert.strictEqual( captured!.headers['content-type'], - SupportedMimeType.CardSource, + 'application/vnd.card+source', ); assert.strictEqual( captured!.body, @@ -170,8 +165,8 @@ module('factory-tool-executor integration > realm-api requests', function () { let realmUrl = `${origin}/user/target/`; let executor = new ToolExecutor(registry, { packageRoot: '/fake', + client: createMockClient({ fetch: globalThis.fetch }), targetRealmUrl: realmUrl, - authorization: 'Bearer realm-jwt-for-user', }); let result = await executor.execute('realm-delete', { @@ -182,10 +177,6 @@ module('factory-tool-executor integration > realm-api requests', function () { assert.strictEqual(result.exitCode, 0); assert.strictEqual(captured!.method, 'DELETE'); assert.strictEqual(captured!.url, '/user/target/Card/old-card.json'); - assert.strictEqual( - captured!.headers.authorization, - 'Bearer realm-jwt-for-user', - ); } finally { await stopServer(server); } @@ -204,8 +195,8 @@ module('factory-tool-executor integration > realm-api requests', function () { let realmUrl = `${origin}/user/target/`; let executor = new ToolExecutor(registry, { packageRoot: '/fake', + client: createMockClient({ fetch: globalThis.fetch }), targetRealmUrl: realmUrl, - authorization: 'Bearer realm-jwt-for-user', }); let query = JSON.stringify({ @@ -222,14 +213,10 @@ module('factory-tool-executor integration > realm-api requests', function () { assert.strictEqual(result.exitCode, 0); assert.strictEqual(captured!.method, 'QUERY'); assert.strictEqual(captured!.url, '/user/target/_search'); - assert.strictEqual( - captured!.headers.authorization, - 'Bearer realm-jwt-for-user', - ); - assert.strictEqual(captured!.headers.accept, SupportedMimeType.CardJson); + assert.strictEqual(captured!.headers.accept, 'application/vnd.card+json'); assert.strictEqual( captured!.headers['content-type'], - SupportedMimeType.JSON, + 'application/json', ); assert.strictEqual(captured!.body, query); } finally { @@ -247,10 +234,12 @@ module('factory-tool-executor integration > realm-api requests', function () { try { let registry = new ToolRegistry(); + // The authedServerFetch in the mock client delegates to the given fetch, + // so the mock server will receive the request directly. let executor = new ToolExecutor(registry, { packageRoot: '/fake', + client: createMockClient({ fetch: globalThis.fetch }), targetRealmUrl: `${origin}/user/target/`, - authorization: 'Bearer realm-server-jwt-xyz', }); let result = await executor.execute('realm-auth', { @@ -260,10 +249,6 @@ module('factory-tool-executor integration > realm-api requests', function () { assert.strictEqual(result.exitCode, 0); assert.strictEqual(captured!.method, 'POST'); assert.strictEqual(captured!.url, '/user/target/_realm-auth'); - assert.strictEqual( - captured!.headers.authorization, - 'Bearer realm-server-jwt-xyz', - ); } finally { await stopServer(server); } @@ -283,6 +268,7 @@ module('factory-tool-executor integration > realm-api requests', function () { let registry = new ToolRegistry(); let executor = new ToolExecutor(registry, { packageRoot: '/fake', + client: createMockClient({ fetch: globalThis.fetch }), targetRealmUrl: `${origin}/user/target/`, }); @@ -334,6 +320,7 @@ module('factory-tool-executor integration > safety constraints', function () { let registry = new ToolRegistry(); let executor = new ToolExecutor(registry, { packageRoot: '/fake', + client: createMockClient(), targetRealmUrl: `${origin}/user/target/`, }); @@ -368,6 +355,7 @@ module('factory-tool-executor integration > safety constraints', function () { let sourceUrl = `${origin}/user/source/`; let executor = new ToolExecutor(registry, { packageRoot: '/fake', + client: createMockClient(), targetRealmUrl: `${origin}/user/target/`, sourceRealmUrl: sourceUrl, }); @@ -402,6 +390,7 @@ module('factory-tool-executor integration > safety constraints', function () { let registry = new ToolRegistry(); let executor = new ToolExecutor(registry, { packageRoot: '/fake', + client: createMockClient(), targetRealmUrl: `${origin}/user/target/`, }); diff --git a/packages/software-factory/tests/factory-tool-executor.spec.ts b/packages/software-factory/tests/factory-tool-executor.spec.ts index 4967345ed6a..750bfbd50f5 100644 --- a/packages/software-factory/tests/factory-tool-executor.spec.ts +++ b/packages/software-factory/tests/factory-tool-executor.spec.ts @@ -22,15 +22,19 @@ import { DEFAULT_REALM_OWNER, sourceRealmURLFor, } from '../src/harness/shared'; +import { createMockClient } from './helpers/mock-client'; +import { buildAuthenticatedFetch } from './helpers/matrix-auth'; + test('realm-read fetches .realm.json from the test realm', async ({ realm, }) => { + let authedFetch = buildAuthenticatedFetch(realm.ownerBearerToken, fetch); let registry = new ToolRegistry(); let executor = new ToolExecutor(registry, { + client: createMockClient({ fetch: authedFetch }), packageRoot: process.cwd(), targetRealmUrl: realm.realmURL.href, allowedRealmPrefixes: [realm.realmURL.origin + '/'], - authorization: `Bearer ${realm.ownerBearerToken}`, }); let result = await executor.execute('realm-read', { @@ -43,12 +47,13 @@ test('realm-read fetches .realm.json from the test realm', async ({ }); test('realm-search returns results from the test realm', async ({ realm }) => { + let authedFetch = buildAuthenticatedFetch(realm.ownerBearerToken, fetch); let registry = new ToolRegistry(); let executor = new ToolExecutor(registry, { + client: createMockClient({ fetch: authedFetch }), packageRoot: process.cwd(), targetRealmUrl: realm.realmURL.href, allowedRealmPrefixes: [realm.realmURL.origin + '/'], - authorization: `Bearer ${realm.ownerBearerToken}`, }); let result = await executor.execute('realm-search', { @@ -72,12 +77,13 @@ test('realm-search returns results from the test realm', async ({ realm }) => { test('realm-write creates a card and realm-read retrieves it', async ({ realm, }) => { + let authedFetch = buildAuthenticatedFetch(realm.ownerBearerToken, fetch); let registry = new ToolRegistry(); let executor = new ToolExecutor(registry, { + client: createMockClient({ fetch: authedFetch }), packageRoot: process.cwd(), targetRealmUrl: realm.realmURL.href, allowedRealmPrefixes: [realm.realmURL.origin + '/'], - authorization: `Bearer ${realm.ownerBearerToken}`, }); let cardJson = JSON.stringify({ @@ -117,12 +123,13 @@ test('realm-write creates a card and realm-read retrieves it', async ({ }); test('realm-delete removes a card from the test realm', async ({ realm }) => { + let authedFetch = buildAuthenticatedFetch(realm.ownerBearerToken, fetch); let registry = new ToolRegistry(); let executor = new ToolExecutor(registry, { + client: createMockClient({ fetch: authedFetch }), packageRoot: process.cwd(), targetRealmUrl: realm.realmURL.href, allowedRealmPrefixes: [realm.realmURL.origin + '/'], - authorization: `Bearer ${realm.ownerBearerToken}`, }); // First, write a card to delete @@ -167,11 +174,12 @@ test('realm-delete removes a card from the test realm', async ({ realm }) => { test('unregistered tool is rejected without reaching the server', async ({ realm, }) => { + let authedFetch = buildAuthenticatedFetch(realm.ownerBearerToken, fetch); let registry = new ToolRegistry(); let executor = new ToolExecutor(registry, { + client: createMockClient({ fetch: authedFetch }), packageRoot: process.cwd(), targetRealmUrl: realm.realmURL.href, - authorization: `Bearer ${realm.ownerBearerToken}`, }); await expect( @@ -193,12 +201,14 @@ async function buildToolsForRealm(realm: { realmServerURL: URL; ownerBearerToken: string; }): Promise { + let authedFetch = buildAuthenticatedFetch(realm.ownerBearerToken, fetch); + let client = createMockClient({ fetch: authedFetch }); let registry = new ToolRegistry(); let executor = new ToolExecutor(registry, { + client, packageRoot: process.cwd(), targetRealmUrl: realm.realmURL.href, allowedRealmPrefixes: [realm.realmURL.origin + '/'], - authorization: `Bearer ${realm.ownerBearerToken}`, }); // Fetch schemas via _run-command. The realmUrl targets the test realm @@ -244,9 +254,7 @@ async function buildToolsForRealm(realm: { targetRealmUrl: realm.realmURL.href, darkfactoryModuleUrl: `${realm.realmServerURL.href}software-factory/darkfactory`, realmServerUrl: realm.realmServerURL.href, - realmTokens: { - [realm.realmURL.href]: `Bearer ${realm.ownerBearerToken}`, - }, + client, cardTypeSchemas, }, executor, @@ -459,12 +467,13 @@ test.describe('realm-search with seeded fixture data', () => { // The darkfactory-adopter fixture type module uses a placeholder URL // that gets remapped at runtime. Discover the live module URL by // reading a known card and extracting its adoptsFrom module. + let authedFetch = buildAuthenticatedFetch(realm.ownerBearerToken, fetch); let registry = new ToolRegistry(); let executor = new ToolExecutor(registry, { + client: createMockClient({ fetch: authedFetch }), packageRoot: process.cwd(), targetRealmUrl: realm.realmURL.href, allowedRealmPrefixes: [realm.realmURL.origin + '/'], - authorization: `Bearer ${realm.ownerBearerToken}`, }); let projectRead = await executor.execute('realm-read', { @@ -544,11 +553,12 @@ test.describe('realm-search on a private realm', () => { let registry = new ToolRegistry(); // Discover the live module URL from the fixture data + let authedFetch = buildAuthenticatedFetch(realm.ownerBearerToken, fetch); let ownerExecutor = new ToolExecutor(registry, { + client: createMockClient({ fetch: authedFetch }), packageRoot: process.cwd(), targetRealmUrl: realm.realmURL.href, allowedRealmPrefixes: [realm.realmURL.origin + '/'], - authorization: `Bearer ${realm.ownerBearerToken}`, }); let projectRead = await ownerExecutor.execute('realm-read', { @@ -588,6 +598,7 @@ test.describe('realm-search on a private realm', () => { // Unauthenticated search — should fail with 401 let noAuthExecutor = new ToolExecutor(registry, { + client: createMockClient(), packageRoot: process.cwd(), targetRealmUrl: realm.realmURL.href, allowedRealmPrefixes: [realm.realmURL.origin + '/'], @@ -605,11 +616,12 @@ test.describe('realm-search on a private realm', () => { // Search with a token for a different user who has no permissions — should fail with 403 let unauthorizedToken = realm.createBearerToken('@stranger:localhost', []); + let unauthorizedFetch = buildAuthenticatedFetch(unauthorizedToken, fetch); let unauthorizedExecutor = new ToolExecutor(registry, { + client: createMockClient({ fetch: unauthorizedFetch }), packageRoot: process.cwd(), targetRealmUrl: realm.realmURL.href, allowedRealmPrefixes: [realm.realmURL.origin + '/'], - authorization: `Bearer ${unauthorizedToken}`, }); let unauthorizedResult = await unauthorizedExecutor.execute( diff --git a/packages/software-factory/tests/factory-tool-executor.test.ts b/packages/software-factory/tests/factory-tool-executor.test.ts index 22b8fe0500f..f9c1d41bf84 100644 --- a/packages/software-factory/tests/factory-tool-executor.test.ts +++ b/packages/software-factory/tests/factory-tool-executor.test.ts @@ -12,6 +12,7 @@ import { type ToolExecutorConfig, } from '../src/factory-tool-executor'; import { ToolRegistry } from '../src/factory-tool-registry'; +import { createMockClient } from './helpers/mock-client'; // --------------------------------------------------------------------------- // Helpers @@ -20,9 +21,11 @@ import { ToolRegistry } from '../src/factory-tool-registry'; function makeConfig( overrides?: Partial, ): ToolExecutorConfig { + let mockFetch = overrides?.fetch as typeof globalThis.fetch | undefined; return { packageRoot: '/fake/software-factory', targetRealmUrl: 'https://realms.example.test/user/target/', + client: createMockClient({ fetch: mockFetch }), ...overrides, }; } @@ -370,15 +373,16 @@ module('factory-tool-executor > realm-api execution', function () { assert.true(error.startsWith('HTTP 404')); }); - test('includes authorization header when configured', async function (assert) { - let capturedHeaders: Headers | undefined; + test('realm-read delegates to client (auth handled internally)', async function (assert) { + // Auth is now handled by BoxelCLIClient. Verify the executor still + // delegates to the client for realm-read operations. + let capturedUrl: string | undefined; let registry = new ToolRegistry(); let config = makeConfig({ - authorization: 'Bearer test-token-123', - fetch: (async (_input: RequestInfo | URL, init?: RequestInit) => { - capturedHeaders = new Headers(init?.headers as HeadersInit); - return new Response(JSON.stringify({}), { + fetch: (async (input: RequestInfo | URL, _init?: RequestInit) => { + capturedUrl = String(input); + return new Response(JSON.stringify({ data: { type: 'card', attributes: {} } }), { status: 200, headers: { 'Content-Type': SupportedMimeType.JSON }, }); @@ -386,15 +390,13 @@ module('factory-tool-executor > realm-api execution', function () { }); let executor = new ToolExecutor(registry, config); - await executor.execute('realm-read', { + let result = await executor.execute('realm-read', { 'realm-url': 'https://realms.example.test/user/target/', path: 'foo.json', }); - assert.strictEqual( - capturedHeaders!.get('Authorization'), - 'Bearer test-token-123', - ); + assert.strictEqual(result.exitCode, 0, 'realm-read succeeds'); + assert.true(capturedUrl?.includes('foo.json'), 'client sent request to correct URL'); }); test('realm-auth makes POST to _realm-auth', async function (assert) { @@ -468,7 +470,7 @@ module('factory-tool-executor > realm-api execution', function () { // Auth header propagation // --------------------------------------------------------------------------- -module('factory-tool-executor > auth header propagation', function () { +module('factory-tool-executor > auth delegation to client', function () { function createHeaderCapturingFetch(): { fetch: typeof globalThis.fetch; getCapturedHeaders: () => Headers | undefined; @@ -486,119 +488,103 @@ module('factory-tool-executor > auth header propagation', function () { }; } - test('realm-read sends realm JWT in Authorization header', async function (assert) { - let { fetch, getCapturedHeaders } = createHeaderCapturingFetch(); + // Auth for realm-read/write/delete/search is now handled by BoxelCLIClient. + // These tests verify the executor delegates to the client correctly. + test('realm-read delegates to client (auth handled internally)', async function (assert) { + let { fetch } = createHeaderCapturingFetch(); let registry = new ToolRegistry(); let executor = new ToolExecutor( registry, - makeConfig({ authorization: 'Bearer realm-jwt-abc', fetch }), + makeConfig({ fetch }), ); - await executor.execute('realm-read', { + let result = await executor.execute('realm-read', { 'realm-url': 'https://realms.example.test/user/target/', path: 'Card/foo.json', }); - assert.strictEqual( - getCapturedHeaders()!.get('Authorization'), - 'Bearer realm-jwt-abc', - ); + assert.strictEqual(result.exitCode, 0, 'realm-read delegates to client successfully'); }); - test('realm-write sends realm JWT in Authorization header', async function (assert) { - let { fetch, getCapturedHeaders } = createHeaderCapturingFetch(); + test('realm-write delegates to client (auth handled internally)', async function (assert) { + let { fetch } = createHeaderCapturingFetch(); let registry = new ToolRegistry(); let executor = new ToolExecutor( registry, - makeConfig({ authorization: 'Bearer realm-jwt-abc', fetch }), + makeConfig({ fetch }), ); - await executor.execute('realm-write', { + let result = await executor.execute('realm-write', { 'realm-url': 'https://realms.example.test/user/target/', path: 'Card/new.gts', content: 'export class NewCard {}', }); - assert.strictEqual( - getCapturedHeaders()!.get('Authorization'), - 'Bearer realm-jwt-abc', - ); + assert.strictEqual(result.exitCode, 0, 'realm-write delegates to client successfully'); }); - test('realm-delete sends realm JWT in Authorization header', async function (assert) { - let { fetch, getCapturedHeaders } = createHeaderCapturingFetch(); + test('realm-delete delegates to client (auth handled internally)', async function (assert) { + let { fetch } = createHeaderCapturingFetch(); let registry = new ToolRegistry(); let executor = new ToolExecutor( registry, - makeConfig({ authorization: 'Bearer realm-jwt-abc', fetch }), + makeConfig({ fetch }), ); - await executor.execute('realm-delete', { + let result = await executor.execute('realm-delete', { 'realm-url': 'https://realms.example.test/user/target/', path: 'Card/old.json', }); - assert.strictEqual( - getCapturedHeaders()!.get('Authorization'), - 'Bearer realm-jwt-abc', - ); + assert.strictEqual(result.exitCode, 0, 'realm-delete delegates to client successfully'); }); - test('realm-search sends realm JWT in Authorization header', async function (assert) { - let { fetch, getCapturedHeaders } = createHeaderCapturingFetch(); + test('realm-search delegates to client (auth handled internally)', async function (assert) { + let { fetch } = createHeaderCapturingFetch(); let registry = new ToolRegistry(); let executor = new ToolExecutor( registry, - makeConfig({ authorization: 'Bearer realm-jwt-abc', fetch }), + makeConfig({ fetch }), ); - await executor.execute('realm-search', { + let result = await executor.execute('realm-search', { 'realm-url': 'https://realms.example.test/user/target/', query: '{}', }); - assert.strictEqual( - getCapturedHeaders()!.get('Authorization'), - 'Bearer realm-jwt-abc', - ); + assert.strictEqual(result.exitCode, 0, 'realm-search delegates to client successfully'); }); - test('realm-auth sends server JWT in Authorization header', async function (assert) { - let { fetch, getCapturedHeaders } = createHeaderCapturingFetch(); + test('realm-auth delegates to client authedServerFetch', async function (assert) { + let { fetch } = createHeaderCapturingFetch(); let registry = new ToolRegistry(); let executor = new ToolExecutor( registry, - makeConfig({ authorization: 'Bearer realm-jwt-abc', fetch }), + makeConfig({ fetch }), ); - await executor.execute('realm-auth', { + let result = await executor.execute('realm-auth', { 'realm-server-url': 'https://realms.example.test/user/target/', }); - assert.strictEqual( - getCapturedHeaders()!.get('Authorization'), - 'Bearer realm-jwt-abc', - ); + assert.strictEqual(result.exitCode, 0, 'realm-auth delegates to client successfully'); }); - test('no Authorization header when authorization is not configured', async function (assert) { - let { fetch, getCapturedHeaders } = createHeaderCapturingFetch(); + test('realm-read works without explicit authorization config', async function (assert) { + // Auth is now handled by BoxelCLIClient — verify the executor still works + let { fetch } = createHeaderCapturingFetch(); let registry = new ToolRegistry(); let executor = new ToolExecutor( registry, - makeConfig({ fetch }), // no authorization + makeConfig({ fetch }), ); - await executor.execute('realm-read', { + let result = await executor.execute('realm-read', { 'realm-url': 'https://realms.example.test/user/target/', path: 'Card/foo.json', }); - assert.strictEqual( - getCapturedHeaders()!.get('Authorization'), - null, - 'no Authorization header sent', - ); + assert.strictEqual(result.exitCode, 0, 'realm-read works without explicit authorization'); }); }); diff --git a/packages/software-factory/tests/helpers/mock-client.ts b/packages/software-factory/tests/helpers/mock-client.ts new file mode 100644 index 00000000000..214f946c9a2 --- /dev/null +++ b/packages/software-factory/tests/helpers/mock-client.ts @@ -0,0 +1,220 @@ +/** + * Mock BoxelCLIClient for unit tests. + * + * Wraps a mock fetch function so tests can control responses while + * using the same BoxelCLIClient interface that production code expects. + */ +import type { + BoxelCLIClient, + ReadResult, + WriteResult, + DeleteResult, + SearchResult, +} from '@cardstack/boxel-cli/api'; + +interface MockClientOptions { + /** + * Mock fetch — used to simulate realm API responses. + * If not provided, all operations return ok: true with empty data. + */ + fetch?: typeof globalThis.fetch; +} + +/** + * Create a mock BoxelCLIClient that delegates to a mock fetch. + * Tests can pass in their own fetch mock to control responses. + * + * The mock client implements the subset of BoxelCLIClient that + * production code uses: read, write, delete, search, getRealmToken. + */ +export function createMockClient(options?: MockClientOptions): BoxelCLIClient { + let defaultCardDoc = JSON.stringify({ data: { type: 'card', attributes: {}, meta: { adoptsFrom: { module: 'mock', name: 'Mock' } } } }); + let fetchImpl = options?.fetch ?? ((() => + Promise.resolve(new Response(defaultCardDoc, { status: 200 }))) as typeof globalThis.fetch); + + function ensureTrailingSlash(url: string): string { + return url.endsWith('/') ? url : `${url}/`; + } + + return { + getActiveProfile: () => ({ + matrixId: '@test:example.test', + realmServerUrl: 'https://realms.example.test/', + }), + + getRealmToken: async (_realmUrl: string) => 'Bearer mock-realm-token', + + async read(realmUrl: string, path: string): Promise { + let url = new URL(path, ensureTrailingSlash(realmUrl)).href; + try { + let response = await fetchImpl(url, { + method: 'GET', + headers: { Accept: 'application/vnd.card+source' }, + }); + if (!response.ok) { + let body = await response.text(); + return { + ok: false, + status: response.status, + error: `HTTP ${response.status}: ${body.slice(0, 300)}`, + }; + } + let text = await response.text(); + try { + let document = JSON.parse(text) as Record; + return { ok: true, status: response.status, document }; + } catch { + return { ok: true, status: response.status, content: text }; + } + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } + }, + + async write(realmUrl: string, path: string, content: string): Promise { + let url = new URL(path, ensureTrailingSlash(realmUrl)).href; + try { + let response = await fetchImpl(url, { + method: 'POST', + headers: { + Accept: 'application/vnd.card+source', + 'Content-Type': 'application/vnd.card+source', + }, + body: content, + }); + if (!response.ok) { + let body = await response.text(); + return { ok: false, error: `HTTP ${response.status}: ${body.slice(0, 300)}` }; + } + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + + async delete(realmUrl: string, path: string): Promise { + let url = new URL(path, ensureTrailingSlash(realmUrl)).href; + try { + let response = await fetchImpl(url, { + method: 'DELETE', + headers: { Accept: 'application/vnd.card+source' }, + }); + if (!response.ok) { + let body = await response.text(); + return { ok: false, error: `HTTP ${response.status}: ${body.slice(0, 300)}` }; + } + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + + async search(realmUrl: string, query: Record): Promise { + let searchUrl = `${ensureTrailingSlash(realmUrl)}_search`; + try { + let response = await fetchImpl(searchUrl, { + method: 'QUERY', + headers: { + Accept: 'application/vnd.card+json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(query), + }); + if (!response.ok) { + let body = await response.text(); + return { + ok: false, + status: response.status, + error: `HTTP ${response.status}: ${body.slice(0, 300)}`, + }; + } + let result = (await response.json()) as { data?: Record[] }; + return { ok: true, data: result.data }; + } catch (err) { + return { + ok: false, + status: 0, + error: err instanceof Error ? err.message : String(err), + }; + } + }, + + async listFiles(realmUrl: string) { + let normalizedRealmUrl = ensureTrailingSlash(realmUrl); + let mtimesUrl = `${normalizedRealmUrl}_mtimes`; + try { + let response = await fetchImpl(mtimesUrl, { + method: 'GET', + headers: { Accept: 'application/vnd.api+json' }, + }); + if (!response.ok) { + let body = await response.text(); + return { filenames: [] as string[], error: `HTTP ${response.status}: ${body.slice(0, 300)}` }; + } + let json = (await response.json()) as { data?: { attributes?: { mtimes?: Record } } }; + let mtimes = json?.data?.attributes?.mtimes ?? (json as unknown as Record); + let filenames: string[] = []; + for (let fullUrl of Object.keys(mtimes)) { + if (!fullUrl.startsWith(normalizedRealmUrl)) continue; + let relativePath = fullUrl.slice(normalizedRealmUrl.length); + if (!relativePath || relativePath.endsWith('/')) continue; + filenames.push(relativePath); + } + return { filenames: filenames.sort() }; + } catch (err) { + return { filenames: [] as string[], error: err instanceof Error ? err.message : String(err) }; + } + }, + + async lint(realmUrl: string, source: string, filename: string) { + let lintUrl = `${ensureTrailingSlash(realmUrl)}_lint`; + try { + let response = await fetchImpl(lintUrl, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ source, filename }), + }); + if (!response.ok) { + let body = await response.text(); + return { fixed: false, output: source, messages: [], error: `HTTP ${response.status}: ${body.slice(0, 300)}` }; + } + return await response.json(); + } catch (err) { + return { fixed: false, output: source, messages: [], error: err instanceof Error ? err.message : String(err) }; + } + }, + + async runCommand(realmServerUrl: string, realmUrl: string, command: string, commandInput?: Record) { + let url = `${ensureTrailingSlash(realmServerUrl)}_run-command`; + let body = { data: { type: 'run-command', attributes: { realmURL: realmUrl, command, commandInput: commandInput ?? null } } }; + try { + let response = await fetchImpl(url, { + method: 'POST', + headers: { 'Content-Type': 'application/vnd.api+json', Accept: 'application/vnd.api+json' }, + body: JSON.stringify(body), + }); + if (!response.ok) { + return { status: 'error' as const, result: null, error: `HTTP ${response.status}` }; + } + let json = (await response.json()) as { data?: { attributes?: { status?: string; cardResultString?: string | null; error?: string | null } } }; + let attrs = json.data?.attributes; + return { status: (attrs?.status as 'ready' | 'error' | 'unusable') ?? 'error', result: attrs?.cardResultString ?? null, error: attrs?.error ?? null }; + } catch (err) { + return { status: 'error' as const, result: null, error: err instanceof Error ? err.message : String(err) }; + } + }, + + pull: async () => ({ files: [] }), + createRealm: async () => ({ realmUrl: '', created: false }), + ensureProfile: async () => {}, + waitForFile: async () => true, + waitForReady: async () => true, + authedServerFetch: async (input: string | URL | Request, init?: RequestInit) => { + let url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + return fetchImpl(url, init); + }, + } as unknown as BoxelCLIClient; +} diff --git a/packages/software-factory/tests/lint-step.test.ts b/packages/software-factory/tests/lint-step.test.ts index ede1fe7b8b1..943833859ba 100644 --- a/packages/software-factory/tests/lint-step.test.ts +++ b/packages/software-factory/tests/lint-step.test.ts @@ -11,6 +11,7 @@ import type { LintFileResponse, RealmFetchOptions, } from '../src/realm-operations'; +import { createMockClient } from './helpers/mock-client'; // --------------------------------------------------------------------------- // Mock helpers @@ -20,6 +21,7 @@ function makeConfig( overrides: Partial = {}, ): LintValidationStepConfig { return { + client: createMockClient(), realmServerUrl: 'https://example.test/', lintResultsModuleUrl: 'https://example.test/lint-result', // Default to a no-op sequence resolver for unit tests diff --git a/packages/software-factory/tests/lint-validation.spec.ts b/packages/software-factory/tests/lint-validation.spec.ts index 0dde1fee9cc..5a6eab734df 100644 --- a/packages/software-factory/tests/lint-validation.spec.ts +++ b/packages/software-factory/tests/lint-validation.spec.ts @@ -3,6 +3,7 @@ import { resolve } from 'node:path'; import { expect, test } from './fixtures'; import { + fetchRealmFilenames, lintFile, readFile, writeFile, @@ -10,6 +11,8 @@ import { } from '../src/realm-operations'; import { LintValidationStep } from '../src/validators/lint-step'; import type { LintValidationDetails } from '../src/validators/lint-step'; +import { createMockClient } from './helpers/mock-client'; +import { buildAuthenticatedFetch } from './helpers/matrix-auth'; const fixtureRealmDir = resolve( process.cwd(), @@ -97,12 +100,19 @@ test.describe('lint-validation e2e', () => { let authorization = realm.authorizationHeaders()['Authorization']; let lintResultsModuleUrl = `${realmServerUrl}software-factory/lint-result`; + let authedFetch = buildAuthenticatedFetch(authorization.replace('Bearer ', ''), globalThis.fetch); let step = new LintValidationStep({ - authorization, - fetch: globalThis.fetch, + client: createMockClient({ fetch: authedFetch }), realmServerUrl, lintResultsModuleUrl, issueId: 'Issues/lint-e2e', + // Use raw realm-operations functions with explicit auth for integration tests + fetchFilenames: (realmUrl, _opts) => + fetchRealmFilenames(realmUrl, { authorization }), + lintFileFn: (realmUrl, source, filename, _opts) => + lintFile(realmUrl, source, filename, { authorization }), + readFileFn: (realmUrl, path, _opts) => + readFile(realmUrl, path, { authorization }), }); let result = await step.run(realmUrl); diff --git a/packages/software-factory/tests/test-step.test.ts b/packages/software-factory/tests/test-step.test.ts index 36df8dbd26d..a891862ebe4 100644 --- a/packages/software-factory/tests/test-step.test.ts +++ b/packages/software-factory/tests/test-step.test.ts @@ -15,6 +15,7 @@ import type { ExecuteTestRunOptions, } from '../src/test-run-types'; import type { RealmFetchOptions } from '../src/realm-operations'; +import { createMockClient } from './helpers/mock-client'; // --------------------------------------------------------------------------- // Mock helpers @@ -24,6 +25,7 @@ function makeConfig( overrides: Partial = {}, ): TestValidationStepConfig { return { + client: createMockClient(), realmServerUrl: 'https://example.test/', hostAppUrl: 'https://example.test/', testResultsModuleUrl: 'https://example.test/test-results', diff --git a/packages/software-factory/tests/validation-pipeline.test.ts b/packages/software-factory/tests/validation-pipeline.test.ts index 7c17621a381..20b11b268e6 100644 --- a/packages/software-factory/tests/validation-pipeline.test.ts +++ b/packages/software-factory/tests/validation-pipeline.test.ts @@ -1,5 +1,6 @@ import { module, test } from 'qunit'; +import { createMockClient } from './helpers/mock-client'; import type { ValidationStep, ValidationStepResult, @@ -204,6 +205,7 @@ module('ValidationPipeline', function () { test('createDefaultPipeline creates 5 steps in correct order', async function (assert) { let pipeline = createDefaultPipeline({ + client: createMockClient(), realmServerUrl: 'https://example.test/', hostAppUrl: 'https://example.test/', testResultsModuleUrl: 'https://example.test/test-results',