refactor(cli): extract Query CLI Adapter Module seam (#3074)
* refactor(cli): extract query adapter seam from cli entrypoint * test: update ws forwarding guard for query-cli-adapter seam * fix(query): close remaining CodeRabbit findings on cli adapter * test: address remaining CodeRabbit nitpicks on ws forwarding coverage
This commit is contained in:
6
.changeset/cool-monkeys-smell.md
Normal file
6
.changeset/cool-monkeys-smell.md
Normal file
@@ -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.
|
||||
@@ -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<string> {
|
||||
});
|
||||
}
|
||||
|
||||
/** 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<void
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── Query command ──────────────────────────────────────────────────────
|
||||
if (args.command === 'query') {
|
||||
const result = await runQueryCliCommand({
|
||||
projectDir: args.projectDir,
|
||||
ws: args.ws,
|
||||
queryArgv: args.queryArgv,
|
||||
});
|
||||
for (const line of result.stderrLines) console.error(line);
|
||||
for (const chunk of result.stdoutChunks) process.stdout.write(chunk);
|
||||
process.exitCode = result.exitCode;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to GSD_WORKSTREAM env var when --ws is not supplied (#2791).
|
||||
// gsd-tools.cjs resolves the active workstream via this env var; parity
|
||||
// means gsd-sdk query commands see the same .planning/ path as gsd-tools.
|
||||
// means gsd-sdk command paths see the same .planning/ path as gsd-tools.
|
||||
if (args.ws === undefined && process.env.GSD_WORKSTREAM) {
|
||||
const envWs = process.env.GSD_WORKSTREAM;
|
||||
if (validateWorkstreamName(envWs)) {
|
||||
args = { ...args, ws: envWs };
|
||||
}
|
||||
// If the env var contains an invalid name, silently ignore it (same as CJS).
|
||||
}
|
||||
|
||||
// Multi-repo project-root resolution (issue #2623).
|
||||
//
|
||||
// When the user launches `gsd-sdk` from inside a `sub_repos`-listed child repo,
|
||||
// `projectDir` defaults to `process.cwd()` which points at the child, not the
|
||||
// parent workspace that owns `.planning/`. Mirror the legacy `gsd-tools.cjs`
|
||||
// walk-up semantics so handlers see the correct project root.
|
||||
//
|
||||
// Idempotent: if `projectDir` already has its own `.planning/` (including an
|
||||
// explicit `--project-dir` pointing at the workspace root), findProjectRoot
|
||||
// returns it unchanged.
|
||||
{
|
||||
const { findProjectRoot } = await import('./query/helpers.js');
|
||||
args = { ...args, projectDir: findProjectRoot(args.projectDir) };
|
||||
}
|
||||
|
||||
// ─── Query command ──────────────────────────────────────────────────────
|
||||
if (args.command === 'query') {
|
||||
const { createRegistry } = await import('./query/index.js');
|
||||
const { runQueryDispatch } = await import('./query/query-dispatch.js');
|
||||
const { resolveGsdToolsPath, GSDToolsError } = await import('./gsd-tools.js');
|
||||
const { GSDError, exitCodeFor } = await import('./errors.js');
|
||||
|
||||
try {
|
||||
const registry = createRegistry();
|
||||
const out = await runQueryDispatch({
|
||||
registry,
|
||||
projectDir: args.projectDir,
|
||||
ws: args.ws,
|
||||
cjsFallbackEnabled: queryFallbackToCjsEnabled(),
|
||||
resolveGsdToolsPath,
|
||||
dispatchNative: (cmd, argv) => 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 <prompt>", "gsd-sdk auto", "gsd-sdk init [input]", or "gsd-sdk query <command>"');
|
||||
console.error(USAGE);
|
||||
|
||||
58
sdk/src/query/query-cli-adapter.test.ts
Normal file
58
sdk/src/query/query-cli-adapter.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
71
sdk/src/query/query-cli-adapter.ts
Normal file
71
sdk/src/query/query-cli-adapter.ts
Normal file
@@ -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<QueryCliAdapterOutput> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
/**
|
||||
* Bug #2524: gsd-sdk query --ws <name> 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',
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user