From ff99bf7673e8962bb1e943abcbb58f0247ed041a Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 16 Apr 2026 17:53:48 +0700 Subject: [PATCH 1/3] CS-10677: Add `boxel run-command` CLI command Add a top-level `boxel run-command` command that executes host commands on the realm server via the `/_run-command` endpoint. Includes a programmatic `runCommand()` API and unit tests covering success, error, HTTP failure, network failure, missing profile, and malformed responses. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/cs-10677-run-command-plan.md | 18 ++ .../boxel-cli/src/commands/run-command.ts | 160 ++++++++++++ packages/boxel-cli/src/index.ts | 2 + .../tests/commands/run-command.test.ts | 236 ++++++++++++++++++ 4 files changed, 416 insertions(+) create mode 100644 docs/cs-10677-run-command-plan.md create mode 100644 packages/boxel-cli/src/commands/run-command.ts create mode 100644 packages/boxel-cli/tests/commands/run-command.test.ts diff --git a/docs/cs-10677-run-command-plan.md b/docs/cs-10677-run-command-plan.md new file mode 100644 index 00000000000..91b8b13bfb6 --- /dev/null +++ b/docs/cs-10677-run-command-plan.md @@ -0,0 +1,18 @@ +# CS-10677: Add `boxel run-command` + +## Goal +Add a top-level `boxel run-command` CLI command (and programmatic API) that executes host commands on the realm server via `/_run-command`. + +## Files +- **CREATE** `packages/boxel-cli/src/commands/run-command.ts` +- **CREATE** `packages/boxel-cli/tests/commands/run-command.test.ts` +- **MODIFY** `packages/boxel-cli/src/index.ts` + +## Design +- Top-level command: `boxel run-command --realm [--input '{}'] [--json]` +- Uses `authedRealmServerFetch` (server-level JWT) +- JSON:API request/response matching `realm-operations.ts` +- Exports programmatic `runCommand()` function + +## Test Plan +Unit tests mocking `authedRealmServerFetch` covering success, error, HTTP failures, no profile, request shape validation. 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 00000000000..b3b73d7559c --- /dev/null +++ b/packages/boxel-cli/src/commands/run-command.ts @@ -0,0 +1,160 @@ +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; +} + +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 = (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, + }; +} + +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: Record) => { + let input: Record | undefined; + if (opts.input) { + try { + input = JSON.parse(opts.input); + } 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 d19bfaf97dd..dd14250f925 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/commands/run-command.test.ts b/packages/boxel-cli/tests/commands/run-command.test.ts new file mode 100644 index 00000000000..bfe6f0ee3a4 --- /dev/null +++ b/packages/boxel-cli/tests/commands/run-command.test.ts @@ -0,0 +1,236 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ProfileManager } from '../../src/lib/profile-manager.js'; +import { runCommand, type RunCommandResult } from '../../src/commands/run-command.js'; + +function makeJsonApiResponse( + status: string, + cardResultString?: string | null, + error?: string | null, +) { + return { + data: { + type: 'run-command-result', + attributes: { + status, + cardResultString: cardResultString ?? null, + error: error ?? null, + }, + }, + }; +} + +function mockResponse( + body: unknown, + httpStatus = 200, + ok = true, +): Response { + return { + ok, + status: httpStatus, + statusText: ok ? 'OK' : 'Error', + json: async () => body, + text: async () => JSON.stringify(body), + headers: new Headers(), + redirected: false, + type: 'basic', + url: '', + clone: () => mockResponse(body, httpStatus, ok), + body: null, + bodyUsed: false, + arrayBuffer: async () => new ArrayBuffer(0), + blob: async () => new Blob(), + formData: async () => new FormData(), + bytes: async () => new Uint8Array(), + } as Response; +} + +describe('runCommand', () => { + let tmpDir: string; + let profileManager: ProfileManager; + let fetchSpy: ReturnType; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-runcmd-test-')); + profileManager = new ProfileManager(tmpDir); + + // Add a profile manually to avoid real Matrix login + let configPath = path.join(tmpDir, 'profiles.json'); + let config = { + activeProfile: '@testuser:stack.cards', + profiles: { + '@testuser:stack.cards': { + displayName: 'Test User', + password: 'pass', + matrixUrl: 'https://matrix-staging.stack.cards', + realmServerUrl: 'https://realms-staging.stack.cards/', + realmServerToken: 'mock-server-token', + realmTokens: {}, + }, + }, + }; + fs.writeFileSync(configPath, JSON.stringify(config)); + profileManager = new ProfileManager(tmpDir); + + fetchSpy = vi.fn(); + profileManager.authedRealmServerFetch = fetchSpy; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('sends correct JSON:API request and returns ready result', async () => { + let resultPayload = makeJsonApiResponse( + 'ready', + '{"schema": {"type": "object"}}', + ); + fetchSpy.mockResolvedValue(mockResponse(resultPayload)); + + let result = await runCommand( + '@cardstack/boxel-host/commands/get-card-type-schema/default', + 'http://localhost:4201/test/', + { + input: { cardURL: 'http://localhost:4201/test/MyCard' }, + profileManager, + }, + ); + + expect(result).toEqual({ + status: 'ready', + result: '{"schema": {"type": "object"}}', + error: null, + }); + + // Verify the request shape + expect(fetchSpy).toHaveBeenCalledOnce(); + let [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe('https://realms-staging.stack.cards/_run-command'); + expect(init.method).toBe('POST'); + expect(init.headers['Content-Type']).toBe('application/vnd.api+json'); + expect(init.headers['Accept']).toBe('application/vnd.api+json'); + + let body = JSON.parse(init.body); + expect(body).toEqual({ + data: { + type: 'run-command', + attributes: { + realmURL: 'http://localhost:4201/test/', + command: + '@cardstack/boxel-host/commands/get-card-type-schema/default', + commandInput: { cardURL: 'http://localhost:4201/test/MyCard' }, + }, + }, + }); + }); + + it('sends null commandInput when no input provided', async () => { + fetchSpy.mockResolvedValue( + mockResponse(makeJsonApiResponse('ready')), + ); + + await runCommand('some-command', 'http://localhost:4201/test/', { + profileManager, + }); + + let body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.data.attributes.commandInput).toBeNull(); + }); + + it('returns error result when command returns error status', async () => { + fetchSpy.mockResolvedValue( + mockResponse(makeJsonApiResponse('error', null, 'Command failed: timeout')), + ); + + let result = await runCommand('some-command', 'http://localhost:4201/test/', { + profileManager, + }); + + expect(result).toEqual({ + status: 'error', + result: null, + error: 'Command failed: timeout', + }); + }); + + it('returns unusable status', async () => { + fetchSpy.mockResolvedValue( + mockResponse(makeJsonApiResponse('unusable', null, 'Prerenderer unavailable')), + ); + + let result = await runCommand('some-command', 'http://localhost:4201/test/', { + profileManager, + }); + + expect(result.status).toBe('unusable'); + expect(result.error).toBe('Prerenderer unavailable'); + }); + + it('handles HTTP error responses gracefully', async () => { + fetchSpy.mockResolvedValue( + mockResponse('Internal Server Error', 500, false), + ); + + let result = await runCommand('some-command', 'http://localhost:4201/test/', { + profileManager, + }); + + expect(result.status).toBe('error'); + expect(result.error).toContain('run-command HTTP 500'); + }); + + it('handles network failure gracefully', async () => { + fetchSpy.mockRejectedValue(new Error('ECONNREFUSED')); + + let result = await runCommand('some-command', 'http://localhost:4201/test/', { + profileManager, + }); + + expect(result.status).toBe('error'); + expect(result.error).toContain('run-command fetch failed'); + expect(result.error).toContain('ECONNREFUSED'); + }); + + 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('some-command', 'http://localhost:4201/test/', { + profileManager: emptyManager, + }), + ).rejects.toThrow('No active profile'); + + fs.rmSync(emptyDir, { recursive: true, force: true }); + }); + + it('strips trailing slash from realm server URL before appending endpoint', async () => { + fetchSpy.mockResolvedValue( + mockResponse(makeJsonApiResponse('ready')), + ); + + await runCommand('some-command', 'http://localhost:4201/test/', { + profileManager, + }); + + let [url] = fetchSpy.mock.calls[0]; + expect(url).toBe('https://realms-staging.stack.cards/_run-command'); + expect(url).not.toContain('//_run-command'); + }); + + it('handles malformed JSON response gracefully', async () => { + fetchSpy.mockResolvedValue( + mockResponse({}), // empty JSON, no data.attributes + ); + + let result = await runCommand('some-command', 'http://localhost:4201/test/', { + profileManager, + }); + + expect(result.status).toBe('error'); + expect(result.result).toBeNull(); + }); +}); From 3a2afeb813c980afee235f43e0e69f358ab1983f Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 16 Apr 2026 19:06:53 +0700 Subject: [PATCH 2/3] lint fix --- .../tests/commands/run-command.test.ts | 236 ------------------ .../tests/integration/run-command.test.ts | 145 +++++++++++ 2 files changed, 145 insertions(+), 236 deletions(-) delete mode 100644 packages/boxel-cli/tests/commands/run-command.test.ts create mode 100644 packages/boxel-cli/tests/integration/run-command.test.ts diff --git a/packages/boxel-cli/tests/commands/run-command.test.ts b/packages/boxel-cli/tests/commands/run-command.test.ts deleted file mode 100644 index bfe6f0ee3a4..00000000000 --- a/packages/boxel-cli/tests/commands/run-command.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { ProfileManager } from '../../src/lib/profile-manager.js'; -import { runCommand, type RunCommandResult } from '../../src/commands/run-command.js'; - -function makeJsonApiResponse( - status: string, - cardResultString?: string | null, - error?: string | null, -) { - return { - data: { - type: 'run-command-result', - attributes: { - status, - cardResultString: cardResultString ?? null, - error: error ?? null, - }, - }, - }; -} - -function mockResponse( - body: unknown, - httpStatus = 200, - ok = true, -): Response { - return { - ok, - status: httpStatus, - statusText: ok ? 'OK' : 'Error', - json: async () => body, - text: async () => JSON.stringify(body), - headers: new Headers(), - redirected: false, - type: 'basic', - url: '', - clone: () => mockResponse(body, httpStatus, ok), - body: null, - bodyUsed: false, - arrayBuffer: async () => new ArrayBuffer(0), - blob: async () => new Blob(), - formData: async () => new FormData(), - bytes: async () => new Uint8Array(), - } as Response; -} - -describe('runCommand', () => { - let tmpDir: string; - let profileManager: ProfileManager; - let fetchSpy: ReturnType; - - beforeEach(async () => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-runcmd-test-')); - profileManager = new ProfileManager(tmpDir); - - // Add a profile manually to avoid real Matrix login - let configPath = path.join(tmpDir, 'profiles.json'); - let config = { - activeProfile: '@testuser:stack.cards', - profiles: { - '@testuser:stack.cards': { - displayName: 'Test User', - password: 'pass', - matrixUrl: 'https://matrix-staging.stack.cards', - realmServerUrl: 'https://realms-staging.stack.cards/', - realmServerToken: 'mock-server-token', - realmTokens: {}, - }, - }, - }; - fs.writeFileSync(configPath, JSON.stringify(config)); - profileManager = new ProfileManager(tmpDir); - - fetchSpy = vi.fn(); - profileManager.authedRealmServerFetch = fetchSpy; - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - vi.restoreAllMocks(); - }); - - it('sends correct JSON:API request and returns ready result', async () => { - let resultPayload = makeJsonApiResponse( - 'ready', - '{"schema": {"type": "object"}}', - ); - fetchSpy.mockResolvedValue(mockResponse(resultPayload)); - - let result = await runCommand( - '@cardstack/boxel-host/commands/get-card-type-schema/default', - 'http://localhost:4201/test/', - { - input: { cardURL: 'http://localhost:4201/test/MyCard' }, - profileManager, - }, - ); - - expect(result).toEqual({ - status: 'ready', - result: '{"schema": {"type": "object"}}', - error: null, - }); - - // Verify the request shape - expect(fetchSpy).toHaveBeenCalledOnce(); - let [url, init] = fetchSpy.mock.calls[0]; - expect(url).toBe('https://realms-staging.stack.cards/_run-command'); - expect(init.method).toBe('POST'); - expect(init.headers['Content-Type']).toBe('application/vnd.api+json'); - expect(init.headers['Accept']).toBe('application/vnd.api+json'); - - let body = JSON.parse(init.body); - expect(body).toEqual({ - data: { - type: 'run-command', - attributes: { - realmURL: 'http://localhost:4201/test/', - command: - '@cardstack/boxel-host/commands/get-card-type-schema/default', - commandInput: { cardURL: 'http://localhost:4201/test/MyCard' }, - }, - }, - }); - }); - - it('sends null commandInput when no input provided', async () => { - fetchSpy.mockResolvedValue( - mockResponse(makeJsonApiResponse('ready')), - ); - - await runCommand('some-command', 'http://localhost:4201/test/', { - profileManager, - }); - - let body = JSON.parse(fetchSpy.mock.calls[0][1].body); - expect(body.data.attributes.commandInput).toBeNull(); - }); - - it('returns error result when command returns error status', async () => { - fetchSpy.mockResolvedValue( - mockResponse(makeJsonApiResponse('error', null, 'Command failed: timeout')), - ); - - let result = await runCommand('some-command', 'http://localhost:4201/test/', { - profileManager, - }); - - expect(result).toEqual({ - status: 'error', - result: null, - error: 'Command failed: timeout', - }); - }); - - it('returns unusable status', async () => { - fetchSpy.mockResolvedValue( - mockResponse(makeJsonApiResponse('unusable', null, 'Prerenderer unavailable')), - ); - - let result = await runCommand('some-command', 'http://localhost:4201/test/', { - profileManager, - }); - - expect(result.status).toBe('unusable'); - expect(result.error).toBe('Prerenderer unavailable'); - }); - - it('handles HTTP error responses gracefully', async () => { - fetchSpy.mockResolvedValue( - mockResponse('Internal Server Error', 500, false), - ); - - let result = await runCommand('some-command', 'http://localhost:4201/test/', { - profileManager, - }); - - expect(result.status).toBe('error'); - expect(result.error).toContain('run-command HTTP 500'); - }); - - it('handles network failure gracefully', async () => { - fetchSpy.mockRejectedValue(new Error('ECONNREFUSED')); - - let result = await runCommand('some-command', 'http://localhost:4201/test/', { - profileManager, - }); - - expect(result.status).toBe('error'); - expect(result.error).toContain('run-command fetch failed'); - expect(result.error).toContain('ECONNREFUSED'); - }); - - 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('some-command', 'http://localhost:4201/test/', { - profileManager: emptyManager, - }), - ).rejects.toThrow('No active profile'); - - fs.rmSync(emptyDir, { recursive: true, force: true }); - }); - - it('strips trailing slash from realm server URL before appending endpoint', async () => { - fetchSpy.mockResolvedValue( - mockResponse(makeJsonApiResponse('ready')), - ); - - await runCommand('some-command', 'http://localhost:4201/test/', { - profileManager, - }); - - let [url] = fetchSpy.mock.calls[0]; - expect(url).toBe('https://realms-staging.stack.cards/_run-command'); - expect(url).not.toContain('//_run-command'); - }); - - it('handles malformed JSON response gracefully', async () => { - fetchSpy.mockResolvedValue( - mockResponse({}), // empty JSON, no data.attributes - ); - - let result = await runCommand('some-command', 'http://localhost:4201/test/', { - profileManager, - }); - - expect(result.status).toBe('error'); - expect(result.result).toBeNull(); - }); -}); 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 00000000000..743ebd78300 --- /dev/null +++ b/packages/boxel-cli/tests/integration/run-command.test.ts @@ -0,0 +1,145 @@ +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('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(); + } + }); +}); From 9f1f77412345fd2ac7eb394f8e2688bb42adf094 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Fri, 17 Apr 2026 12:23:46 +0700 Subject: [PATCH 3/3] Address PR review comments for run-command - Remove plan doc from repo (reviewer feedback) - Type Commander opts with RunCommandCliOptions interface - Validate --input is a plain JSON object (reject arrays/nulls/primitives) - Wrap response.json() in try/catch for malformed response handling - Add error-handling tests (non-2xx, invalid JSON, fetch throw) Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/cs-10677-run-command-plan.md | 18 -------- .../boxel-cli/src/commands/run-command.ts | 32 +++++++++++-- .../tests/integration/run-command.test.ts | 45 +++++++++++++++++++ 3 files changed, 74 insertions(+), 21 deletions(-) delete mode 100644 docs/cs-10677-run-command-plan.md diff --git a/docs/cs-10677-run-command-plan.md b/docs/cs-10677-run-command-plan.md deleted file mode 100644 index 91b8b13bfb6..00000000000 --- a/docs/cs-10677-run-command-plan.md +++ /dev/null @@ -1,18 +0,0 @@ -# CS-10677: Add `boxel run-command` - -## Goal -Add a top-level `boxel run-command` CLI command (and programmatic API) that executes host commands on the realm server via `/_run-command`. - -## Files -- **CREATE** `packages/boxel-cli/src/commands/run-command.ts` -- **CREATE** `packages/boxel-cli/tests/commands/run-command.test.ts` -- **MODIFY** `packages/boxel-cli/src/index.ts` - -## Design -- Top-level command: `boxel run-command --realm [--input '{}'] [--json]` -- Uses `authedRealmServerFetch` (server-level JWT) -- JSON:API request/response matching `realm-operations.ts` -- Exports programmatic `runCommand()` function - -## Test Plan -Unit tests mocking `authedRealmServerFetch` covering success, error, HTTP failures, no profile, request shape validation. diff --git a/packages/boxel-cli/src/commands/run-command.ts b/packages/boxel-cli/src/commands/run-command.ts index b3b73d7559c..9d19fe98e42 100644 --- a/packages/boxel-cli/src/commands/run-command.ts +++ b/packages/boxel-cli/src/commands/run-command.ts @@ -14,6 +14,12 @@ export interface RunCommandOptions { profileManager?: ProfileManager; } +interface RunCommandCliOptions { + realm: string; + input?: string; + json?: boolean; +} + export async function runCommand( commandSpecifier: string, realmUrl: string, @@ -66,7 +72,7 @@ export async function runCommand( }; } - let json = (await response.json()) as { + let json: { data?: { attributes?: { status?: string; @@ -76,6 +82,15 @@ export async function runCommand( }; }; + 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', @@ -100,11 +115,22 @@ export function registerRunCommand(program: Command): void { ) .option('--input ', 'JSON string of command input') .option('--json', 'Output raw JSON response') - .action(async (commandSpecifier: string, opts: Record) => { + .action(async (commandSpecifier: string, opts: RunCommandCliOptions) => { let input: Record | undefined; if (opts.input) { try { - input = JSON.parse(opts.input); + 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}`, diff --git a/packages/boxel-cli/tests/integration/run-command.test.ts b/packages/boxel-cli/tests/integration/run-command.test.ts index 743ebd78300..a4d025e492a 100644 --- a/packages/boxel-cli/tests/integration/run-command.test.ts +++ b/packages/boxel-cli/tests/integration/run-command.test.ts @@ -126,6 +126,51 @@ describe('run-command (integration)', () => { 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 {