diff --git a/.changeset/cool-monkeys-smell.md b/.changeset/cool-monkeys-smell.md new file mode 100644 index 000000000..cdfb98b42 --- /dev/null +++ b/.changeset/cool-monkeys-smell.md @@ -0,0 +1,6 @@ +--- +type: Changed +pr: 3074 +--- + +**query CLI path extracted into a dedicated Query CLI Adapter Module** — `sdk/src/cli.ts` now delegates query-specific dispatch, error mapping, and output/exit handling to `sdk/src/query/query-cli-adapter.ts` for better locality and testability. diff --git a/sdk/src/cli.ts b/sdk/src/cli.ts index 74ff07071..c4ef46a2d 100644 --- a/sdk/src/cli.ts +++ b/sdk/src/cli.ts @@ -18,6 +18,7 @@ import { InitRunner } from './init-runner.js'; import { validateWorkstreamName } from './workstream-utils.js'; import { loadConfig } from './config.js'; import { assertRuntimeSupportsAutoMode } from './runtime-gate.js'; +import { runQueryCliCommand } from './query/query-cli-adapter.js'; // ─── Parsed CLI args ───────────────────────────────────────────────────────── @@ -276,13 +277,6 @@ async function readStdin(): Promise { }); } -/** When false, unknown `gsd-sdk query` commands error instead of shelling out to gsd-tools.cjs. */ -function queryFallbackToCjsEnabled(): boolean { - const v = process.env.GSD_QUERY_FALLBACK?.toLowerCase(); - if (v === 'off' || v === 'never' || v === 'false' || v === '0') return false; - return true; -} - // ─── Main ──────────────────────────────────────────────────────────────────── @@ -316,72 +310,35 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise registry.dispatch(cmd, argv, args.projectDir, args.ws), - }, args.queryArgv ?? []); - - for (const line of out.stderr) console.error(line); - if (!out.ok) { - console.error(out.error.message); - process.exitCode = out.exit_code; - return; - } - if (out.stdout) process.stdout.write(out.stdout); - } catch (err) { - if (err instanceof GSDError) { - console.error(`Error: ${err.message}`); - process.exitCode = exitCodeFor(err.classification); - } else if (err instanceof GSDToolsError) { - console.error(`Error: ${err.message}`); - process.exitCode = err.exitCode ?? 1; - } else { - console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exitCode = 1; - } - } - return; - } - if (args.command !== 'run' && args.command !== 'init' && args.command !== 'auto') { console.error('Error: Expected "gsd-sdk run ", "gsd-sdk auto", "gsd-sdk init [input]", or "gsd-sdk query "'); console.error(USAGE); diff --git a/sdk/src/query/query-cli-adapter.test.ts b/sdk/src/query/query-cli-adapter.test.ts new file mode 100644 index 000000000..8e4c5f2c9 --- /dev/null +++ b/sdk/src/query/query-cli-adapter.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const dispatchSpy = vi.hoisted(() => vi.fn()); +const runQueryDispatchSpy = vi.hoisted(() => vi.fn()); + +vi.mock('./helpers.js', () => ({ + findProjectRoot: (projectDir: string) => projectDir, +})); + +vi.mock('./index.js', () => ({ + createRegistry: () => ({ dispatch: dispatchSpy }), +})); + +vi.mock('./query-dispatch.js', () => ({ + runQueryDispatch: (...args: unknown[]) => runQueryDispatchSpy(...args), +})); + +import { runQueryCliCommand } from './query-cli-adapter.js'; + +describe('query-cli-adapter', () => { + beforeEach(() => { + dispatchSpy.mockReset(); + runQueryDispatchSpy.mockReset(); + }); + + it('returns validation failure for missing query command', async () => { + runQueryDispatchSpy.mockResolvedValueOnce({ + ok: false, + exit_code: 10, + stdout: '', + stderr: [], + error: { kind: 'validation_error', message: 'query requires a command', details: {} }, + }); + + const out = await runQueryCliCommand({ + projectDir: process.cwd(), + queryArgv: [], + }); + + expect(out.exitCode).toBe(10); + expect(out.stderrLines.join('\n')).toContain('requires a command'); + }); + + it('forwards ws to registry.dispatch via dispatchNative', async () => { + runQueryDispatchSpy.mockImplementationOnce(async (input: any) => { + await input.dispatchNative('state', ['show']); + return { ok: true, exit_code: 0, stdout: '', stderr: [] }; + }); + + await runQueryCliCommand({ + projectDir: process.cwd(), + ws: 'alpha', + queryArgv: ['state', 'show'], + }); + + expect(dispatchSpy).toHaveBeenCalledWith('state', ['show'], process.cwd(), 'alpha'); + }); +}); diff --git a/sdk/src/query/query-cli-adapter.ts b/sdk/src/query/query-cli-adapter.ts new file mode 100644 index 000000000..5aa78950c --- /dev/null +++ b/sdk/src/query/query-cli-adapter.ts @@ -0,0 +1,71 @@ +import { findProjectRoot } from './helpers.js'; +import { createRegistry } from './index.js'; +import { runQueryDispatch } from './query-dispatch.js'; +import { resolveGsdToolsPath, GSDToolsError } from '../gsd-tools.js'; +import { GSDError, exitCodeFor } from '../errors.js'; +import { validateWorkstreamName } from '../workstream-utils.js'; + +export interface QueryCliAdapterInput { + projectDir: string; + ws?: string; + queryArgv?: string[]; +} + +export interface QueryCliAdapterOutput { + exitCode: number; + stdoutChunks: string[]; + stderrLines: string[]; +} + +function queryFallbackToCjsEnabled(): boolean { + const v = process.env.GSD_QUERY_FALLBACK?.toLowerCase(); + if (v === 'off' || v === 'never' || v === 'false' || v === '0') return false; + return true; +} + +function resolveQueryWorkstream(ws: string | undefined): string | undefined { + if (ws !== undefined) { + return validateWorkstreamName(ws) ? ws : undefined; + } + const envWs = process.env.GSD_WORKSTREAM; + if (!envWs) return undefined; + return validateWorkstreamName(envWs) ? envWs : undefined; +} + +export async function runQueryCliCommand(input: QueryCliAdapterInput): Promise { + const stderrLines: string[] = []; + const stdoutChunks: string[] = []; + const ws = resolveQueryWorkstream(input.ws); + + try { + const projectDir = findProjectRoot(input.projectDir); + const registry = createRegistry(); + const out = await runQueryDispatch({ + registry, + projectDir, + ws, + cjsFallbackEnabled: queryFallbackToCjsEnabled(), + resolveGsdToolsPath, + dispatchNative: (cmd, argv) => registry.dispatch(cmd, argv, projectDir, ws), + }, input.queryArgv ?? []); + + stderrLines.push(...out.stderr); + if (!out.ok) { + stderrLines.push(out.error.message); + return { exitCode: out.exit_code, stdoutChunks, stderrLines }; + } + if (out.stdout) stdoutChunks.push(out.stdout); + return { exitCode: 0, stdoutChunks, stderrLines }; + } catch (err) { + if (err instanceof GSDError) { + stderrLines.push(`Error: ${err.message}`); + return { exitCode: exitCodeFor(err.classification), stdoutChunks, stderrLines }; + } + if (err instanceof GSDToolsError) { + stderrLines.push(`Error: ${err.message}`); + return { exitCode: err.exitCode ?? 1, stdoutChunks, stderrLines }; + } + stderrLines.push(`Error: ${err instanceof Error ? err.message : String(err)}`); + return { exitCode: 1, stdoutChunks, stderrLines }; + } +} diff --git a/tests/bug-2524-sdk-query-ws-flag.test.cjs b/tests/bug-2524-sdk-query-ws-flag.test.cjs index 8b85c2f7a..5567730dc 100644 --- a/tests/bug-2524-sdk-query-ws-flag.test.cjs +++ b/tests/bug-2524-sdk-query-ws-flag.test.cjs @@ -7,7 +7,7 @@ /** * Bug #2524: gsd-sdk query --ws silently ignores the workstream flag. * Tests that --ws is forwarded through the call chain: - * cli.ts -> registry.dispatch() -> planningPaths() + * cli.ts -> runQueryCliCommand() -> registry.dispatch() -> planningPaths() * * Uses static source-file text assertions (no sdk/dist/ build required in CI). */ @@ -88,13 +88,6 @@ describe('QueryRegistry.dispatch() workstream threading', () => { // ─── Layer 1: CLI forwards args.ws to registry.dispatch() ───────────────── describe('CLI forwards --ws to registry.dispatch()', () => { - test('cli.ts passes args.ws as the workstream argument to registry.dispatch()', () => { - // The CLI now uses a dispatchNative callback pattern. - assert.ok( - cliTs.includes('dispatchNative') && cliTs.includes('args.ws'), - 'cli.ts must forward args.ws to registry.dispatch() as the workstream argument', - ); - }); test('cli.ts defines a ws field in ParsedCliArgs', () => { assert.ok( @@ -109,4 +102,5 @@ describe('CLI forwards --ws to registry.dispatch()', () => { 'cli.ts query permissive parser must handle the --ws flag', ); }); + });