From 7032f44633bbf4725d44fdd7e7796c3bc873b9a1 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 22 Apr 2026 12:04:03 -0400 Subject: [PATCH] fix(#2544): exit 1 on missing key in config-get (#2588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The configGet query handler previously threw GSDError with ErrorClassification.Validation, which maps to exit code 10. Callers using `if ! gsd-sdk query config-get key; then fallback; fi` could not detect missing keys through the exit code alone, because exit 10 is still truthy-failure but the intent (and documented UNIX convention — cf. `git config --get`) is exit 1 for absent key. Change the classification for the two 'Key not found' throw sites to ErrorClassification.Execution so the CLI exits 1 on missing key. Usage/schema errors (no key argument, malformed JSON, missing config.json) remain Validation. Closes #2544 --- sdk/src/query/config-query.test.ts | 37 +++++++++++++++++++++++++++++- sdk/src/query/config-query.ts | 6 +++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/sdk/src/query/config-query.test.ts b/sdk/src/query/config-query.test.ts index 4da2a1db..03cf0e98 100644 --- a/sdk/src/query/config-query.test.ts +++ b/sdk/src/query/config-query.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { GSDError } from '../errors.js'; +import { GSDError, ErrorClassification, exitCodeFor } from '../errors.js'; // ─── Test setup ───────────────────────────────────────────────────────────── @@ -58,6 +58,41 @@ describe('configGet', () => { await expect(configGet(['nonexistent.key'], tmpDir)).rejects.toThrow(GSDError); }); + it('throws GSDError that maps to exit code 1 for missing key (bug #2544)', async () => { + const { configGet } = await import('./config-query.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'quality' }), + ); + try { + await configGet(['nonexistent.key'], tmpDir); + throw new Error('expected configGet to throw for missing key'); + } catch (err) { + expect(err).toBeInstanceOf(GSDError); + const gsdErr = err as GSDError; + // UNIX convention: missing config key should exit 1 (like `git config --get`). + // Validation (exit 10) is the previous buggy classification — see issue #2544. + expect(gsdErr.classification).toBe(ErrorClassification.Execution); + expect(exitCodeFor(gsdErr.classification)).toBe(1); + } + }); + + it('throws GSDError that maps to exit code 1 when traversing into non-object (bug #2544)', async () => { + const { configGet } = await import('./config-query.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'quality' }), + ); + try { + await configGet(['model_profile.subkey'], tmpDir); + throw new Error('expected configGet to throw'); + } catch (err) { + expect(err).toBeInstanceOf(GSDError); + const gsdErr = err as GSDError; + expect(exitCodeFor(gsdErr.classification)).toBe(1); + } + }); + it('reads raw config without merging defaults', async () => { const { configGet } = await import('./config-query.js'); // Write config with only model_profile -- no workflow section diff --git a/sdk/src/query/config-query.ts b/sdk/src/query/config-query.ts index f00b9a04..da2866d5 100644 --- a/sdk/src/query/config-query.ts +++ b/sdk/src/query/config-query.ts @@ -104,12 +104,14 @@ export const configGet: QueryHandler = async (args, projectDir, _workstream) => let current: unknown = config; for (const key of keys) { if (current === undefined || current === null || typeof current !== 'object') { - throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Validation); + // UNIX convention (cf. `git config --get`): missing key exits 1, not 10. + // See issue #2544 — callers use `if ! gsd-sdk query config-get k; then` patterns. + throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Execution); } current = (current as Record)[key]; } if (current === undefined) { - throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Validation); + throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Execution); } return { data: current };