Skip to content

fix: make init overwrite guard intentional (explicit ExitPromptError + non-TTY handling) - #48

Merged
ManningWorks merged 1 commit into
mainfrom
fix/init-exitprompt-guard
Oct 14, 2026
Merged

ManningWorks merged 1 commit into
mainfrom
fix/init-exitprompt-guard

Conversation

@ManningWorks

Copy link
Copy Markdown
Owner

Summary

Fixes the projex init overwrite guard. The guard wrapped both the config
existence check and the confirm prompt in one try with an empty catch, so
ExitPromptError (prompt force-closed, e.g. Ctrl+C) was silently swallowed
and init fell through to overwriting the existing projex.config.ts.

Changes

  1. Existence check — catches only the not-found case (ENOENT); any other
    access() error now surfaces instead of falling through to a write.
  2. Non-interactive guard — when stdin is not a TTY (e.g. stdin from
    /dev/null), init now explicitly refuses to overwrite and exits cleanly
    with a clear message. Previously @inquirer/confirm left the prompt
    promise pending and the process drained out mid-prompt — the "file
    untouched" outcome was incidental, never an intentional return.
  3. Explicit ExitPromptError handling — matches the sibling pattern
    (remove-project, edit-project, remove-learning): catch by name,
    print ✖ Cancelled., return cleanly. Every other prompt error rethrows
    (error output / non-zero exit). No error path can silently fall through
    to an overwrite.

Tests

New packages/core/src/commands/__tests__/init.test.ts (6 cases, following
the sibling test mocking pattern — the prompt is mocked and rejects with an
Error named ExitPromptError):

  • existing config + cancelled prompt → file untouched, clean exit
  • existing config + user answers no → file untouched, clean exit
  • existing config + non-TTY stdin (EOF) → no prompt, file untouched, clean exit
  • existing config + unexpected prompt error → error surfaces, no overwrite
  • no existing config → generated without prompting
  • --yes with existing config → overwrites without prompting

Verification

  • pnpm install && pnpm build && pnpm typecheck && pnpm lint && pnpm test —
    all green (64 test files / 1043 tests).
  • Verifier: pnpm --dir packages/core exec vitest run src/commands/__tests__/init.test.ts — 6 passed (6).
  • End-to-end against the built CLI:
    • init < /dev/null with an existing config → exit 0, explicit
      "refusing to overwrite in a non-interactive session" message, file
      untouched
    • interactive pty, Ctrl+C at the prompt → ✖ Cancelled., exit 0, file
      untouched
    • interactive pty, Enter (default No) → ✖ Init cancelled., exit 0, file
      untouched
    • interactive pty, y → overwrites as intended, exit 0

Closes #41

The init overwrite guard wrapped the config existence check and the confirm
prompt in one try block with an empty catch, so ExitPromptError (prompt
force-closed, e.g. Ctrl+C) was swallowed and init silently fell through to
overwriting the existing config.

- Existence check now catches only ENOENT; any other access error surfaces
  instead of falling through to a write.
- Non-interactive sessions (stdin not a TTY) are handled explicitly: refuse
  to overwrite with a clear message instead of leaving the inquirer prompt
  pending until the process drains out.
- ExitPromptError is handled by name (sibling pattern: remove-project /
  edit-project / remove-learning): print Cancelled and return cleanly;
  every other prompt error rethrows.

Adds src/commands/__tests__/init.test.ts: cancelled prompt and non-TTY EOF
(config untouched, clean exit), fresh generation without prompting, --yes
overwrite, and unexpected prompt errors surfacing without overwrite.
@vercel

vercel Bot commented Oct 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
projex-docs Ready Ready Preview Oct 13, 2026 11:37pm UTC

@coderabbitai

coderabbitai Bot commented Oct 13, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 31793bba-4085-455d-a2f0-a2e77044fbdd
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ManningWorks

Copy link
Copy Markdown
Owner Author

Review: APPROVE

Reviewed the diff against merge-base 76f90ee (one commit ee020ae, exactly 2 files: packages/core/src/commands/init.ts and a new packages/core/src/commands/__tests__/init.test.ts). Walked both axes — Standards (sibling pattern parity + coding-style baseline) and Spec (issue #41 acceptance criteria) — plus all four review asks. Verdict: APPROVE — no CRITICAL / HIGH / MEDIUM findings, two non-blocking LOW coverage observations. All 5 acceptance criteria met; the empty catch is gone; pattern parity with the siblings is byte-for-byte.

Live-verified, not assumed: re-ran the sanctioned single-file suite in the worktree — pnpm --dir packages/core exec vitest run src/commands/__tests__/init.test.ts → 6 passed (6).


What the fix does right

  • Empty catch removed (init.ts:56-64) — the existence check now catches only ENOENT via the new isNotFoundError helper (init.ts:360-366); every other access() error throws instead of falling through to a write. This is a genuine bug fix, not just a refactor: pre-fix, an EACCES (permission denied) on the existence check was swallowed by the empty catch and init silently fell through to overwrite the config.
  • Non-TTY guard placed before the prompt (init.ts:66-75) — hasExistingConfig && !options.yes + !process.stdin.isTTY → explicit refusal message + clean return. !isTTY correctly covers both undefined (piped / /dev/null) and false.
  • Explicit ExitPromptError handling (init.ts:87-95) — matches remove-project / edit-project / remove-learning byte-for-byte: error instanceof Error && error.name === 'ExitPromptError' → console.log(chalk.yellow('\n✖ Cancelled.')) → return; every other prompt error throws.
  • Consent model (init.ts:66) — --yes still overwrites non-interactively (explicit flag = consent), consistent with --force in add / remove. The refusal message documents the --yes escape hatch.
  • Tests have teeth — real fs in tmpdirs (not mocked), a process.exit spy that throws if invoked, file-identity assertions (readFileSync === EXISTING_CONFIG), the cancel test asserts the ✖ Cancelled line, the non-TTY test asserts confirm was never called. No tautologies.
  • Scope is clean — exactly the 2 files; no package.json version bump, no CHANGELOG (house merge-only pattern).

The four review asks

  1. Fix vs the issue brief — empty catch gone; ENOENT-only via helper; ExitPromptError handled by name; siblings rethrow. Parity with remove-project / edit-project / remove-learning is exact (same name check, same \n✖ Cancelled. phrasing, same throw error tail). ✅
  2. Non-TTY guard + consent model — placed before the prompt, covers undefined + false. --yes overwriting non-interactively is the right CLI call and is consistent with how --force skips the guard in add / remove. ✅
  3. Behavior change (non-ENOENT now throws) — cannot regress a legitimate path: ENOENT → generate (handled); EACCES / other → throw (correct — you should not overwrite a file you cannot read). Strict improvement. ✅
  4. Test quality — 6 cases, no tautologies, clean-exit claims have teeth via the throwing exit spy. See the two LOWs below. ✅

🟢 F1 — LOW · Introduced · non-blocking — no --yes + non-TTY combined test

packages/core/src/commands/__tests__/init.test.ts:169 (the --yes case) runs init({ yes: true }) with a TTY stdin. There is no case combining --yes with a non-TTY stdin. Ruling: redundant by design — --yes short-circuits the guard at init.ts:66 (!options.yes is false) before the !process.stdin.isTTY check at init.ts:70 is ever reached, so the TTY state has no effect on the code path taken. A test would only guard a future refactor that moved the TTY check outside the !options.yes guard — low probability. Author's discretion; do not block on it.

🟢 F2 — LOW · Introduced · non-blocking — existence-check non-ENOENT branch untested

init.ts:62 (else { throw error }) — the path where access() throws something other than ENOENT — has no dedicated test. The prompt-path unexpected-error surfacing is covered (test at init.test.ts:140), but the existence-check one is not. This branch is defensive/rare and awkward to exercise with the file's real-fs approach (it would need node:fs/promises mocked, which conflicts with the real-fs design used for the config-file assertions). The code is verified correct by reading — a non-ENOENT access error must surface, never silently overwrite. Non-blocking.


Adjacent (not a finding on this PR): the other sibling commands (add.ts et al.) still lack the non-TTY guard init now has — they rely on the incidental event-loop drain. That is pre-existing and explicitly out of scope here ("Refactoring the sibling commands' error handling"); worth a separate follow-up issue if you want the guard applied everywhere, but it does not block #48.


Approving. Ready for the merge-only tail.

@ManningWorks
ManningWorks merged commit 642f0b0 into main Oct 14, 2026
7 checks passed

This branch was successfully deployed

1 active deployment
Preview — ee020aeb Deployed Oct 13, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

init: empty catch{} around overwrite confirm() swallows ExitPromptError — non-TTY overwrite guard is incidental

1 participant