diff --git a/packages/boxel-cli/src/commands/run-command.ts b/packages/boxel-cli/src/commands/run-command.ts new file mode 100644 index 0000000000..9d19fe98e4 --- /dev/null +++ b/packages/boxel-cli/src/commands/run-command.ts @@ -0,0 +1,186 @@ +import type { Command } from 'commander'; +import { getProfileManager, type ProfileManager } from '../lib/profile-manager'; +import { FG_GREEN, FG_RED, FG_CYAN, DIM, RESET } from '../lib/colors'; + +export interface RunCommandResult { + status: 'ready' | 'error' | 'unusable'; + result?: string | null; + error?: string | null; +} + +export interface RunCommandOptions { + input?: Record; + json?: boolean; + profileManager?: ProfileManager; +} + +interface RunCommandCliOptions { + realm: string; + input?: string; + json?: boolean; +} + +export async function runCommand( + commandSpecifier: string, + realmUrl: string, + options?: RunCommandOptions, +): Promise { + let pm = options?.profileManager ?? getProfileManager(); + let active = pm.getActiveProfile(); + if (!active) { + throw new Error( + 'No active profile. Run `boxel profile add` to create one.', + ); + } + + let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); + let url = `${realmServerUrl}/_run-command`; + + let body = { + data: { + type: 'run-command', + attributes: { + realmURL: realmUrl, + command: commandSpecifier, + commandInput: options?.input ?? null, + }, + }, + }; + + let response: Response; + try { + response = await 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) { + let text = await response.text().catch(() => '(no body)'); + return { + status: 'error', + error: `run-command HTTP ${response.status}: ${text}`, + }; + } + + let json: { + data?: { + attributes?: { + status?: string; + cardResultString?: string | null; + error?: string | null; + }; + }; + }; + + try { + json = await response.json(); + } catch { + return { + status: 'error', + error: `run-command response was not valid JSON (HTTP ${response.status})`, + }; + } + + let attrs = json.data?.attributes; + return { + status: (attrs?.status as RunCommandResult['status']) ?? 'error', + result: attrs?.cardResultString ?? null, + error: attrs?.error ?? null, + }; +} + +export function registerRunCommand(program: Command): void { + program + .command('run-command') + .description( + 'Execute a host command on the realm server via the prerenderer', + ) + .argument( + '', + 'Command module path (e.g. @cardstack/boxel-host/commands/get-card-type-schema/default)', + ) + .requiredOption( + '--realm ', + 'The realm URL context for the command', + ) + .option('--input ', 'JSON string of command input') + .option('--json', 'Output raw JSON response') + .action(async (commandSpecifier: string, opts: RunCommandCliOptions) => { + let input: Record | undefined; + if (opts.input) { + try { + let parsed = JSON.parse(opts.input); + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) + ) { + console.error( + `${FG_RED}Error:${RESET} --input must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`, + ); + process.exit(1); + } + input = parsed; + } catch { + console.error( + `${FG_RED}Error:${RESET} --input is not valid JSON: ${opts.input}`, + ); + process.exit(1); + } + } + + let result: RunCommandResult; + try { + result = await runCommand(commandSpecifier, opts.realm, { input }); + } catch (err) { + console.error( + `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log( + `${DIM}Status:${RESET} ${statusColor(result.status)}${result.status}${RESET}`, + ); + if (result.result) { + console.log(`${DIM}Result:${RESET}`); + try { + console.log(JSON.stringify(JSON.parse(result.result), null, 2)); + } catch { + console.log(result.result); + } + } + if (result.error) { + console.error(`${FG_RED}Error:${RESET} ${result.error}`); + } + } + + if (result.status === 'error' || result.status === 'unusable') { + process.exit(1); + } + }); +} + +function statusColor(status: string): string { + switch (status) { + case 'ready': + return FG_GREEN; + case 'error': + return FG_RED; + default: + return FG_CYAN; + } +} diff --git a/packages/boxel-cli/src/index.ts b/packages/boxel-cli/src/index.ts index d19bfaf97d..dd14250f92 100644 --- a/packages/boxel-cli/src/index.ts +++ b/packages/boxel-cli/src/index.ts @@ -4,6 +4,7 @@ import { readFileSync } from 'fs'; import { resolve } from 'path'; import { profileCommand } from './commands/profile'; import { registerRealmCommand } from './commands/realm/index'; +import { registerRunCommand } from './commands/run-command'; const pkg = JSON.parse( readFileSync(resolve(__dirname, '../package.json'), 'utf-8'), @@ -41,5 +42,6 @@ program ); registerRealmCommand(program); +registerRunCommand(program); program.parse(); diff --git a/packages/boxel-cli/tests/integration/run-command.test.ts b/packages/boxel-cli/tests/integration/run-command.test.ts new file mode 100644 index 0000000000..a4d025e492 --- /dev/null +++ b/packages/boxel-cli/tests/integration/run-command.test.ts @@ -0,0 +1,190 @@ +import '../helpers/setup-realm-server'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { runCommand } from '../../src/commands/run-command'; +import { createRealm } from '../../src/commands/realm/create'; +import { ProfileManager } from '../../src/lib/profile-manager'; +import { + startTestRealmServer, + stopTestRealmServer, + createTestProfileDir, + setupTestProfile, + uniqueRealmName, +} from '../helpers/integration'; + +let profileManager: ProfileManager; +let cleanupProfile: () => void; +let realmUrl: string; + +async function createTestRealm(): Promise { + let name = uniqueRealmName(); + await createRealm(name, `Test ${name}`, { profileManager }); + + let realmTokens = + profileManager.getActiveProfile()!.profile.realmTokens ?? {}; + let entry = Object.entries(realmTokens).find(([url]) => url.includes(name)); + if (!entry) { + throw new Error(`No realm JWT stored for ${name}`); + } + return entry[0]; +} + +beforeAll(async () => { + await startTestRealmServer(); + + let testProfile = createTestProfileDir(); + profileManager = testProfile.profileManager; + cleanupProfile = testProfile.cleanup; + await setupTestProfile(profileManager); + + realmUrl = await createTestRealm(); +}); + +afterAll(async () => { + cleanupProfile?.(); + await stopTestRealmServer(); +}); + +describe('run-command (integration)', () => { + it('executes a command and returns a ready result', async () => { + let result = await runCommand( + '@cardstack/boxel-host/commands/get-card-type-schema/default', + realmUrl, + { profileManager }, + ); + + expect(result.status).toBe('ready'); + }); + + it('sends correct JSON:API request shape', async () => { + let fetchSpy = vi.spyOn(profileManager, 'authedRealmServerFetch'); + try { + await runCommand( + '@cardstack/boxel-host/commands/get-card-type-schema/default', + realmUrl, + { + input: { cardURL: `${realmUrl}MyCard` }, + profileManager, + }, + ); + + expect(fetchSpy).toHaveBeenCalledOnce(); + let [url, init] = fetchSpy.mock.calls[0]; + expect(url).toContain('/_run-command'); + expect(init!.method).toBe('POST'); + let headers = init!.headers as Record; + expect(headers['Content-Type']).toBe('application/vnd.api+json'); + expect(headers['Accept']).toBe('application/vnd.api+json'); + + let body = JSON.parse(init!.body as string); + expect(body).toEqual({ + data: { + type: 'run-command', + attributes: { + realmURL: realmUrl, + command: + '@cardstack/boxel-host/commands/get-card-type-schema/default', + commandInput: { cardURL: `${realmUrl}MyCard` }, + }, + }, + }); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('sends null commandInput when no input provided', async () => { + let fetchSpy = vi.spyOn(profileManager, 'authedRealmServerFetch'); + try { + await runCommand( + '@cardstack/boxel-host/commands/get-card-type-schema/default', + realmUrl, + { profileManager }, + ); + + let body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string); + expect(body.data.attributes.commandInput).toBeNull(); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('throws when no active profile', async () => { + let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-')); + let emptyManager = new ProfileManager(emptyDir); + + await expect( + runCommand( + '@cardstack/boxel-host/commands/get-card-type-schema/default', + realmUrl, + { profileManager: emptyManager }, + ), + ).rejects.toThrow('No active profile'); + + fs.rmSync(emptyDir, { recursive: true, force: true }); + }); + + it('returns error status on non-2xx HTTP response', async () => { + let fetchSpy = vi + .spyOn(profileManager, 'authedRealmServerFetch') + .mockResolvedValueOnce(new Response('Not Found', { status: 404 })); + try { + let result = await runCommand('some/command', realmUrl, { + profileManager, + }); + expect(result.status).toBe('error'); + expect(result.error).toContain('404'); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('returns error status when response body is not valid JSON', async () => { + let fetchSpy = vi + .spyOn(profileManager, 'authedRealmServerFetch') + .mockResolvedValueOnce(new Response('not json', { status: 200 })); + try { + let result = await runCommand('some/command', realmUrl, { + profileManager, + }); + expect(result.status).toBe('error'); + expect(result.error).toContain('not valid JSON'); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('returns error status when fetch throws', async () => { + let fetchSpy = vi + .spyOn(profileManager, 'authedRealmServerFetch') + .mockRejectedValueOnce(new Error('network failure')); + try { + let result = await runCommand('some/command', realmUrl, { + profileManager, + }); + expect(result.status).toBe('error'); + expect(result.error).toContain('network failure'); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('strips trailing slash from realm server URL before appending endpoint', async () => { + let fetchSpy = vi.spyOn(profileManager, 'authedRealmServerFetch'); + try { + await runCommand( + '@cardstack/boxel-host/commands/get-card-type-schema/default', + realmUrl, + { profileManager }, + ); + + let [url] = fetchSpy.mock.calls[0]; + expect(url).toContain('/_run-command'); + expect(url).not.toContain('//_run-command'); + } finally { + fetchSpy.mockRestore(); + } + }); +});