From b79b767629a3da923600d39123ab22756c48b20d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 6 Jun 2026 16:27:05 -0400 Subject: [PATCH] refactor(bin): replace process.exit() in CLI entrypoints + ratchet rule to error (#738) (#741) Part 2 of 2 of the n/no-process-exit cleanup (completes umbrella #738; part 1 was #739/scripts). Converts the 20 flagged process.exit() calls in the three hand-written gsd-core/bin CLI entrypoints and flips n/no-process-exit to error. - New src/cli-exit.cts -> gsd-core/bin/lib/cli-exit.cjs (ExitError + runMain), the gsd-core-side equivalent of scripts/lib/cli-exit.cjs; registered in .gitignore, eslint ignores, and the inventory manifest like its siblings. - gsd-tools.cjs: 13 apply-prompt-budget exits -> throw ExitError; main()->runMain. - verify-reapply-patches.cjs: 6 exits -> throw ExitError / return verdict; runMain. - check-latest-version.cjs: 1 exit -> return verdict; runMain. - eslint.config.mjs: n/no-process-exit warn -> error. Scope note: the gsd-core/bin/lib/*.cjs modules (core, state, profile-pipeline, roadmap-command-router, adr-parser, ui-safety-gate) are tsc-generated and eslint-ignored (ADR-457), so their process.exit calls were never flagged and are intentionally left untouched. Only the linted hand-written entrypoints are in scope. Exit codes verified unchanged for all three entrypoints. Closes #738 Co-authored-by: Claude Opus 4.8 --- .gitignore | 1 + docs/INVENTORY-MANIFEST.json | 3 +- docs/INVENTORY.md | 3 +- eslint.config.mjs | 3 +- gsd-core/bin/check-latest-version.cjs | 5 +-- gsd-core/bin/gsd-tools.cjs | 41 +++++++++---------------- gsd-core/bin/verify-reapply-patches.cjs | 19 +++++------- src/cli-exit.cts | 41 +++++++++++++++++++++++++ 8 files changed, 74 insertions(+), 42 deletions(-) create mode 100644 src/cli-exit.cts diff --git a/.gitignore b/.gitignore index 6059a5dba..fbaf91d64 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,7 @@ build/ /gsd-core/bin/lib/package-legitimacy.cjs /gsd-core/bin/lib/semver-compare.cjs /gsd-core/bin/lib/config-types.cjs +/gsd-core/bin/lib/cli-exit.cjs /gsd-core/bin/lib/code-review-flags.cjs /gsd-core/bin/lib/context-utilization.cjs /gsd-core/bin/lib/artifacts.cjs diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index ba8f4efdb..4abfd5dcb 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -1,5 +1,5 @@ { - "generated": "2026-06-05", + "generated": "2026-06-06", "families": { "agents": [ "gsd-advisor-researcher", @@ -272,6 +272,7 @@ "audit.cjs", "check-command-router.cjs", "cjs-command-router-adapter.cjs", + "cli-exit.cjs", "clock.cjs", "clusters.cjs", "code-review-flags.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 78e5dca6c..e346ed83a 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (85 shipped) +## CLI Modules (86 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -382,6 +382,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `artifacts.cjs` | Canonical artifact registry — known `.planning/` root file names; used by `gsd-health` W019 lint | | `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers | | `check-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools check` | +| `cli-exit.cjs` | `ExitError` class and `runMain()` helper — CLI entrypoints throw `ExitError` instead of calling `process.exit()`; `runMain()` translates the outcome into `process.exitCode` so output flushes cleanly | | `cjs-command-router-adapter.cjs` | Shared compatibility adapter for manifest-backed CJS command-family routers | | `clock.cjs` | Injectable clock seam (now/sleep) for deterministic lock testing | | `clusters.cjs` | Skill cluster definitions for the runtime surface module (ADR-0011 Phase 2) | diff --git a/eslint.config.mjs b/eslint.config.mjs index 52e045fe2..ccd7a87ca 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -35,6 +35,7 @@ export default tseslint.config( '**/*.generated.cjs', // ADR-457: tsc-generated runtime artifact — lint the src/*.cts source, not the emitted .cjs. 'gsd-core/bin/lib/semver-compare.cjs', + 'gsd-core/bin/lib/cli-exit.cjs', 'gsd-core/bin/lib/code-review-flags.cjs', 'gsd-core/bin/lib/context-utilization.cjs', 'gsd-core/bin/lib/artifacts.cjs', @@ -174,7 +175,7 @@ export default tseslint.config( 'no-useless-escape': 'warn', 'no-unsafe-finally': 'warn', // eslint-plugin-n rules - 'n/no-process-exit': 'warn', + 'n/no-process-exit': 'error', // Local rules — warn for now; flip to error after cleanup phases 'local/no-source-grep': 'warn', }, diff --git a/gsd-core/bin/check-latest-version.cjs b/gsd-core/bin/check-latest-version.cjs index 59fdbca70..d5f889e53 100755 --- a/gsd-core/bin/check-latest-version.cjs +++ b/gsd-core/bin/check-latest-version.cjs @@ -21,6 +21,7 @@ */ const { execNpm } = require('./lib/shell-command-projection.cjs'); +const { runMain } = require('./lib/cli-exit.cjs'); // Sourced from the single Package Identity seam (#498), not re-typed. The seam // bakes the value from package.json at build time, so it is a code constant — @@ -98,9 +99,9 @@ function main() { } else { process.stderr.write(`check-latest-version: ${r.reason}: ${r.detail}\n`); } - process.exit(r.ok ? 0 : 1); + return r.ok ? 0 : 1; } -if (require.main === module) main(); +if (require.main === module) runMain(main); module.exports = { checkLatestVersion, CHECK_REASON, PACKAGE_NAME }; diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 7e33347e6..d12f9e69c 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -170,6 +170,7 @@ const fs = require('fs'); const path = require('path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const core = require('./lib/core.cjs'); const { error, ERROR_REASON } = core; // Resolve findProjectRoot lazily at call time rather than binding it at module @@ -1527,33 +1528,26 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand // ── Validate required args ───────────────────────────────────────── if (!budgetStr) { - process.stderr.write('Error: --budget is required\n'); - process.exit(1); + throw new ExitError(1, 'Error: --budget is required'); } const budget = parseInt(budgetStr, 10); if (!Number.isFinite(budget) || budget <= 0) { - process.stderr.write('Error: --budget must be a positive integer\n'); - process.exit(1); + throw new ExitError(1, 'Error: --budget must be a positive integer'); } if (!instructionsFile) { - process.stderr.write('Error: --instructions-file is required\n'); - process.exit(1); + throw new ExitError(1, 'Error: --instructions-file is required'); } if (!roadmapFile) { - process.stderr.write('Error: --roadmap-file is required\n'); - process.exit(1); + throw new ExitError(1, 'Error: --roadmap-file is required'); } if (planFiles.length === 0) { - process.stderr.write('Error: at least one --plan-file is required\n'); - process.exit(1); + throw new ExitError(1, 'Error: at least one --plan-file is required'); } if (!outputPromptFile) { - process.stderr.write('Error: --output-prompt is required\n'); - process.exit(1); + throw new ExitError(1, 'Error: --output-prompt is required'); } if (!outputMetadataFile) { - process.stderr.write('Error: --output-metadata is required\n'); - process.exit(1); + throw new ExitError(1, 'Error: --output-metadata is required'); } // ── Validate and read required files ────────────────────────────── @@ -1563,11 +1557,9 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand return await fs.promises.readFile(resolved, 'utf8'); } catch (err) { if (err && err.code === 'ENOENT') { - process.stderr.write(`Error: file not found for ${flagName}: ${resolved}\n`); - process.exit(1); + throw new ExitError(1, `Error: file not found for ${flagName}: ${resolved}`); } - process.stderr.write(`Error: cannot read file for ${flagName}: ${resolved}\n`); - process.exit(1); + throw new ExitError(1, `Error: cannot read file for ${flagName}: ${resolved}`); } } @@ -1578,8 +1570,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand return await fs.promises.readFile(resolved, 'utf8'); } catch (err) { if (err && err.code === 'ENOENT') return null; - process.stderr.write(`Error: cannot read optional file: ${resolved}\n`); - process.exit(1); + throw new ExitError(1, `Error: cannot read optional file: ${resolved}`); } } @@ -1592,11 +1583,9 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand return { file: path.basename(p), content }; } catch (err) { if (err && err.code === 'ENOENT') { - process.stderr.write(`Error: plan file not found: ${resolved}\n`); - process.exit(1); + throw new ExitError(1, `Error: plan file not found: ${resolved}`); } - process.stderr.write(`Error: cannot read plan file: ${resolved}\n`); - process.exit(1); + throw new ExitError(1, `Error: cannot read plan file: ${resolved}`); } })); @@ -1625,7 +1614,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand await fs.promises.writeFile(path.resolve(outputPromptFile), prompt); if (metadata.hardFailed) { - process.exit(2); + throw new ExitError(2); } break; } @@ -1895,4 +1884,4 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand } } -main(); +runMain(main); diff --git a/gsd-core/bin/verify-reapply-patches.cjs b/gsd-core/bin/verify-reapply-patches.cjs index 2bd44d696..79e7965bb 100755 --- a/gsd-core/bin/verify-reapply-patches.cjs +++ b/gsd-core/bin/verify-reapply-patches.cjs @@ -32,6 +32,7 @@ const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const SIGNIFICANT_MIN_CHARS = 12; const GSD_HOOK_VERSION_LINE_RE = /^(?:\/\/|#)\s*gsd-hook-version:\s*\S+\s*$/i; @@ -48,10 +49,9 @@ function parseArgs(argv) { process.stdout.write( 'usage: verify-reapply-patches.cjs --patches-dir --config-dir [--pristine-dir ] [--json]\n', ); - process.exit(0); + throw new ExitError(0); } else { - process.stderr.write(`unknown argument: ${arg}\n`); - process.exit(2); + throw new ExitError(2, `unknown argument: ${arg}`); } } return opts; @@ -281,16 +281,13 @@ function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashe function main() { const opts = parseArgs(process.argv.slice(2)); if (!opts.patchesDir || !opts.configDir) { - process.stderr.write('--patches-dir and --config-dir are required\n'); - process.exit(2); + throw new ExitError(2, '--patches-dir and --config-dir are required'); } if (!fs.existsSync(opts.patchesDir)) { - process.stderr.write(`patches dir not found: ${opts.patchesDir}\n`); - process.exit(2); + throw new ExitError(2, `patches dir not found: ${opts.patchesDir}`); } if (!fs.existsSync(opts.configDir)) { - process.stderr.write(`config dir not found: ${opts.configDir}\n`); - process.exit(2); + throw new ExitError(2, `config dir not found: ${opts.configDir}`); } const files = walk(opts.patchesDir).filter((f) => !f.endsWith('backup-meta.json')); @@ -342,11 +339,11 @@ function main() { } } - process.exit(failures.length > 0 ? 1 : 0); + return failures.length > 0 ? 1 : 0; } if (require.main === module) { - main(); + runMain(main); } module.exports = { computeUserAddedLines, isSignificantLine, verifyFile, walk, REASON, readPristineHashes, sha256 }; diff --git a/src/cli-exit.cts b/src/cli-exit.cts new file mode 100644 index 000000000..7e4a0afda --- /dev/null +++ b/src/cli-exit.cts @@ -0,0 +1,41 @@ +/** + * Error carrying a process exit code. CLI logic throws this instead of calling + * process.exit() (banned by n/no-process-exit); runMain() translates it into + * process.exitCode at the entrypoint. + */ +class ExitError extends Error { + code: number; + hasUserMessage: boolean; + constructor(code = 1, message?: string) { + super(message === undefined ? `process exit ${code}` : message); + this.name = 'ExitError'; + this.code = code; + this.hasUserMessage = message !== undefined; + } +} + +/** + * Run a CLI main and translate its outcome into process.exitCode (never + * process.exit, so n/no-process-exit stays satisfied; output flushes and + * process.on('exit') cleanup still fires). main may be sync or async: + * number return -> process.exitCode = it + * thrown ExitError -> process.exitCode = err.code (+ stderr err.message if hasUserMessage && code!=0) + * other throw -> stderr stack + process.exitCode = 1 + */ +function runMain(main: () => number | void | Promise): void { + Promise.resolve() + .then(() => main()) + .then((code) => { if (typeof code === 'number') process.exitCode = code; }) + .catch((err: unknown) => { + if (err instanceof ExitError) { + if (err.hasUserMessage && err.code !== 0) process.stderr.write(`${err.message}\n`); + process.exitCode = err.code; + return; + } + const e = err as Error; + process.stderr.write(`${e && e.stack ? e.stack : String(err)}\n`); + process.exitCode = 1; + }); +} + +export = { ExitError, runMain };