fix: unblock agent/CI non-interactive CLI paths#187
Conversation
Removes the two hard dead-ends an AI agent or CI pipeline hits first when driving the CLI non-interactively: - Bare `workos` in a non-TTY shell now emits the machine-readable command tree (JSON) or the fully-configured help, instead of a degenerate two-line block on stderr with empty stdout. Agents and CI can now discover `install`. - Non-interactive installs no longer hang on interactive prompts. `abortIfCancelled` fails fast with a structured `non_interactive_prompt` error (Layer 1), and Next.js, React Router, and upload-env apply graceful per-site defaults so installs succeed (Layer 2). - `--router app|pages` deterministically selects the Next.js router, winning over detection. WORKOS_MODE=ci now enforces the same required-arg validation as the hidden --ci flag and bridges to the downstream `options.ci` paths (git checks auto-continue, package-manager auto-select). Behavior change: `WORKOS_MODE=ci workos install` without --api-key/--client-id/--install-dir now errors with a structured missing_args message instead of falling through to auto-provisioning.
| if (!isPromptAllowed()) { | ||
| // Never await `input` in non-interactive mode — awaiting a never-resolving | ||
| // prompt is exactly the hang this guard fixes. Fail fast with a structured | ||
| // error instead. Placed before analytics.shutdown('cancelled') so a | ||
| // non-interactive block is not mislabeled as a user cancel. | ||
| exitWithError({ | ||
| code: 'non_interactive_prompt', | ||
| message: | ||
| `This step requires interactive input${integration ? ` for ${integration}` : ''}, but the CLI is running ` + | ||
| `in a non-interactive mode (agent/CI/non-TTY). Pass the required flags (e.g. --router app|pages for Next.js) ` + | ||
| `or run in an interactive terminal.`, | ||
| recovery: { | ||
| hints: [ | ||
| { | ||
| description: | ||
| 'Re-run in an interactive terminal, or pass the flags that answer this prompt (e.g. --router).', | ||
| }, | ||
| ], | ||
| }, | ||
| }); | ||
| } |
There was a problem hiding this comment.
🔍 Behavioral change: all unguarded prompts now hard-fail in agent mode instead of clean-exiting
The new guard in abortIfCancelled (src/utils/clack-utils.ts:64-84) changes the behavior for ALL callers that reach it in non-interactive mode. Previously, clack prompts in non-TTY environments would typically resolve with a cancel symbol, causing abortIfCancelled to call process.exit(0) (a clean cancellation). Now, any unguarded prompt path throws CliExit with error code non_interactive_prompt and a non-zero exit code (from resolveErrorCode). This is intentional but affects callers like installPackage (line 317 — the 'already installed, update?' confirm), ensurePackageIsInstalled (line 437), and confirmContinueIfPackageVersionNotSupported (line 253) which don't have their own isPromptAllowed() or options.ci guards. In agent mode, hitting any of these paths will now produce a structured error instead of silently exiting 0.
Was this helpful? React with 👍 or 👎 to provide feedback.
| }); | ||
| } | ||
|
|
||
| await analytics.shutdown('cancelled'); |
There was a problem hiding this comment.
🔍 Pre-existing: analytics.shutdown('cancelled') runs on every successful abortIfCancelled call
At src/utils/clack-utils.ts:86, await analytics.shutdown('cancelled') executes unconditionally before resolving the input — even on the happy path where the user doesn't cancel. This means analytics is shut down with a 'cancelled' reason on every prompt interaction. This is a pre-existing issue (not introduced by this PR) but is now more visible since the new guard draws attention to this function. It likely works because analytics.shutdown is idempotent or a no-op after the first call, but it's semantically misleading.
Was this helpful? React with 👍 or 👎 to provide feedback.
Greptile SummaryThis PR makes non-interactive CLI runs deterministic. The main changes are:
Confidence Score: 5/5Safe to merge with minimal risk. The changes are focused on CLI mode handling and prompt fallbacks, with tests covering the main non-interactive paths. No blocking functional or security issues were identified in the reviewed changes. No files require special attention.
What T-Rex did
Important Files Changed
|
|
Closing in favor of a single combined PR from nicknisi/akshay. |
What
$0in non-TTY:npx workoswith no args now emits the full machine-readable command tree as JSON on stdout (exit 0) instead of a degenerate two-option help dump — the previous handler calledyargs(rawArgs).showHelp()on a fresh yargs instance with no commands registered. Human/TTY mode shows the real configured help via the parser closure.abortIfCancellednow fails fast with a structurednon_interactive_prompterror when prompts are disallowed (agent/ci mode), instead of hanging forever on input that will never come. This is a global invariant: any future unguarded prompt dies loudly.--router app|pagesflag (flag always wins). Without the flag, non-interactive mode defaults to the app router with a warning instead of blocking on a prompt; human mode prompts as before. Same graceful-default treatment for React Router's ambiguous branches and the upload-env-variables prompt.WORKOS_MODE=cibridges--ci: the env var now enforces the same validation and downstream behavior as the hidden--ciflag (dirty-tree auto-continue, package-manager auto-select) — previously these were two disconnected notions of "CI".Why
From the friction log: these are the first things every CI pipeline and AI agent hits. Bare
npx workos@latestin a non-TTY shell dead-ended with no discoverable subcommands, andWORKOS_MODE=ciinstalls still blocked forever on the interactive router prompt.Testing
bin-default-command.integration.spec.ts(mirrors the telemetry integration harness): agent/JSON, CI,WORKOS_FORCE_TTYhuman, and--help --jsonregression paths.clack-utils.spec.ts(guard),nextjs/utils.spec.ts+react-router/utils.spec.ts(ambiguous fixtures across human/ci/flag paths),upload-environment-variables/index.spec.ts.Stack 3/7 (
akshay-friction-log): merge after #186, then rebase ontomain.