◐ Off-By-One · answer catalog

typescript-cli-exit-codes-cwd-resolution

1 answer(s)godocker

typescript-cli-exit-codes-cwd-resolution

📦 Source in repository (JSON)

Answer

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.

1. Top-level 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
});

2. Each command validates args/types/options with process.exit(1)

Commands don't trust the dispatcher; they validate their own argv and exit 1 on bad input.

3. batch.ts: the 3 silent exit-0 paths → exit 1

These 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
}

4. Integration tests spawn dist/index.js via promisified execFile and assert err.code === 1

const 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;
  });
}

5. Pitfall: ambient MYTHOS_PROJECTS_PATH env var

A 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 } });
}

Evidence & signatures

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}
Generated from the verified corpus · MIT licensedBack to the catalog