typescript-cli-exit-codes-cwd-resolution
The bug class: fatal/actionable CLI errors resolved quietly and exited 0, so mythos ... && next chains silently continued past failures. The fix has four parts plus one hermeticity pitfall.
main().catch maps thrown errors → process.exit(1) (src/index.ts)Every error path funnels into one exit-code owner. loadProject throws CliError("No project loaded …") with actionable guidance; the catch maps any thrown error to exit 1 (previously the error was printed but the process exited 0).
async function main(argv: string[]): Promise<void> {
const [command, ...rest] = argv;
if (!command) { console.error("usage: mythos <command> [args]"); process.exit(1); }
const project = loadProject(process.cwd()); // throws CliError when no .mythos/project.json
switch (command) {
case "batch": return runBatch(rest);
case "entities": return runEntities(project);
default:
console.error(`error: unknown command ${JSON.stringify(command)}`);
process.exit(1);
}
}
main(process.argv.slice(2)).catch((err: unknown) => {
if (err instanceof CliError) console.error(`error: ${err.message}`);
else console.error(err instanceof Error ? err.stack : String(err));
process.exit(err instanceof CliError ? err.exitCode : 1); // never exit 0 on failure
});
process.exit(1)Commands don't trust the dispatcher; they validate their own argv and exit 1 on bad input.
batch.ts: the 3 silent exit-0 paths → exit 1These previously printed/returned quietly and exited 0. Now each exits 1 explicitly:
// path #1 — missing --yes
if (!rest.includes("--yes")) {
console.error("error: batch submit requires --yes to confirm submission (no prompts in non-TTY runs)");
process.exit(1); // was exit 0
}
// path #2 — submitted job disappears
const jobId = await submitJob(file);
if (!jobId) {
console.error("error: submitted job disappeared before it could be tracked; retry");
process.exit(1); // was exit 0
}
// path #3 — job failed / incomplete
if (status !== "completed") {
console.error(`error: batch job ${jobId} finished ${status} — not completed`);
process.exit(1); // was exit 0
}
dist/index.js via promisified execFile and assert err.code === 1const execFileP = promisify(execFile);
const CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "dist", "index.js");
async function expectCliExitOne(args, opts) {
await assert.rejects(runCli(args, opts), (err) => {
assert.equal(err.code, 1, `expected exit code 1, got ${err.code}`);
assert.ok(err.stderr, `expected stderr diagnostics`);
return true;
});
}
MYTHOS_PROJECTS_PATH env varA developer-shell MYTHOS_PROJECTS_PATH redirects repo-root runs to a different project, making entity-count assertions environment-dependent. Tests must be hermetic:
function runCli(args, { cwd, env } = {}) {
const cleanEnv = { ...process.env };
delete cleanEnv.MYTHOS_PROJECTS_PATH; // no ambient redirect
delete cleanEnv.MYTHOS_CACHE_PATH; // no ambient cache
return execFileP(process.execPath, [CLI, ...args], { cwd, env: { ...cleanEnv, ...env } });
}
Full verification in a reconstructed minimal Mythos CLI (`tsc` build, `node --test`): ``` # tests 12 # pass 12 # fail 0 ``` Covered edge cases (each asserting rejection `err.code === 1`): - **No project loaded** (empty cwd) → exit 1 with actionable guidance - **No command / unknown command / unknown batch subcommand / batch submit without file** → exit 1 - **batch missing `--yes`** (was exit 0) → exit 1 - **disappearing submitted job** (`ghost.job`, was exit 0) → exit 1 - **failed job** (`bad.job`) and **incomplete job** (`slow.job`, both were exit 0) → exit 1 - **success path** (`good.job --yes`) → exit 0, `batch job-1 completed` - **`&&` chain semantics**: `mythos entities && echo CHAIN_CONTINUED` from a project-less cwd → `chain-exit=1`, `CHAIN_CONTINUED` **not** printed; from a fixture cwd → exit 0 and chain continues - **Hermeticity (the pitfall)**: with ambient `MYTHOS_PROJECTS_PATH` left in place, a fixture run reports the ambient project (`999 entities`) instead of the fixture (`42 entities`); the hermetic `runCli` strips the var and the assertion is environment-independent Manual spot-check of built CLI output (matches test expectations): ``` $ node dist/index.js entities # empty cwd error: No project loaded. Run `mythos init` in this directory to create .mythos/project.json, … exit=1 $ node dist/index.js batch submit good.job # missing --yes error: batch submit requires --yes to confirm submission … exit=1 $ node dist/index.js batch submit ghost.job --yes # disappearing job error: submitted job disappeared before it could be tracked; retry exit=1 $ node dist/index.js batch submit good.job --yes # success batch job-1 completed exit=0 ```
{"model": "deepseek-v4-flash", "problem_class": "typescript-cli-exit-codes-cwd-resolution", "result": "passed", "tests": 12}