From a4126a082a9fa08a76b6e3dfd70d024f30023346 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sat, 28 Feb 2026 13:04:06 -0600 Subject: [PATCH 01/11] feat: add non-TTY core infrastructure for agent-friendly CLI Add foundational layer for non-interactive CLI usage following gh CLI patterns. Agents and CI pipelines can now use the CLI without TTY. - Add output mode system (src/utils/output.ts) with JSON/human modes, auto-detecting non-TTY and supporting --json global flag - Add standardized exit codes (0=success, 1=error, 2=cancelled, 4=auth) - Add WORKOS_NO_PROMPT and WORKOS_FORCE_TTY env var support - Guard ensure-auth.ts to exit code 4 instead of opening browser in non-TTY mode (silent token refresh still works) - Update handleApiError in org/user commands to use structured errors - Add ideation specs for remaining phases (management commands, help, headless installer) --- docs/ideation/non-tty/contract.md | 68 ++++++ docs/ideation/non-tty/spec-phase-1.md | 283 ++++++++++++++++++++++ docs/ideation/non-tty/spec-phase-2.md | 192 +++++++++++++++ docs/ideation/non-tty/spec-phase-3.md | 214 +++++++++++++++++ docs/ideation/non-tty/spec-phase-4.md | 332 ++++++++++++++++++++++++++ src/bin.ts | 12 + src/commands/organization.ts | 29 ++- src/commands/user.ts | 29 ++- src/lib/ensure-auth.spec.ts | 79 +++++- src/lib/ensure-auth.ts | 19 ++ src/utils/environment.ts | 10 + src/utils/exit-codes.spec.ts | 67 ++++++ src/utils/exit-codes.ts | 35 +++ src/utils/output.spec.ts | 142 +++++++++++ src/utils/output.ts | 98 ++++++++ 15 files changed, 1584 insertions(+), 25 deletions(-) create mode 100644 docs/ideation/non-tty/contract.md create mode 100644 docs/ideation/non-tty/spec-phase-1.md create mode 100644 docs/ideation/non-tty/spec-phase-2.md create mode 100644 docs/ideation/non-tty/spec-phase-3.md create mode 100644 docs/ideation/non-tty/spec-phase-4.md create mode 100644 src/utils/exit-codes.spec.ts create mode 100644 src/utils/exit-codes.ts create mode 100644 src/utils/output.spec.ts create mode 100644 src/utils/output.ts diff --git a/docs/ideation/non-tty/contract.md b/docs/ideation/non-tty/contract.md new file mode 100644 index 0000000..184c3c3 --- /dev/null +++ b/docs/ideation/non-tty/contract.md @@ -0,0 +1,68 @@ +# Non-TTY Mode for WorkOS CLI + +**Created**: 2026-02-28 +**Confidence Score**: 95/100 +**Status**: Draft + +## Problem Statement + +The WorkOS CLI currently assumes a human at a terminal. 26+ interactive prompts (via clack), browser-based OAuth, and chalk-formatted tables make it unusable by coding agents like Claude Code, Codex, or Cursor. Agents must either avoid the CLI entirely or rely on users to copy-paste credentials and command output — defeating the purpose of automation. + +This is a growing problem. AI coding agents are becoming primary consumers of developer tools, and CLIs that can't operate headlessly lose relevance. The `gh` CLI solved this well: it works identically for humans and agents, with automatic non-TTY detection, structured output, and env-var auth. The WorkOS CLI should follow this proven model. + +The constraint is clear: non-TTY support must not degrade the human experience. Interactive prompts, colored output, spinners, and the TUI dashboard remain the default for humans. Agents get a parallel path that's equally capable but designed for machine consumption. + +## Goals + +1. **All management commands work non-interactively** — `env`, `organization`, `user` commands accept all required inputs via flags and produce structured output without prompts. +2. **Structured JSON output everywhere** — Auto-detect non-TTY and switch to JSON. Provide `--json` flag for explicit control. Errors go to stderr as structured JSON. +3. **Consistent exit codes** — Follow gh convention: 0=success, 1=general error, 2=cancelled, 4=auth required. Agents can branch on exit codes without parsing output. +4. **Auth works without a browser** — Agents use pre-existing credentials (from prior `workos login`) or `WORKOS_API_KEY` env var. No TTY + no credentials = exit code 4 with clear message. +5. **Headless installer mode** — The installer runs non-interactively with flags for overrides (`--api-key`, `--client-id`) and sensible auto-defaults (create branch, auto-commit, skip confirmations). +6. **Zero breaking changes for humans** — All existing interactive behavior is preserved. Non-TTY detection is automatic. Humans never see JSON unless they ask for it. + +## Success Criteria + +- [ ] Running any management command piped (`workos org list | jq .`) produces valid JSON with no ANSI escape codes +- [ ] Running `workos org list --json` in a TTY produces JSON instead of a table +- [ ] Running `workos env add prod sk_live_xxx` in non-TTY succeeds silently (exit 0) with JSON confirmation to stdout +- [ ] Running `workos install` in non-TTY with `--api-key` and `--client-id` flags completes without prompts, using auto-defaults for branch/commit +- [ ] Running any authenticated command without credentials in non-TTY exits with code 4 and a JSON error to stderr +- [ ] Running `workos install` in a TTY with no flags behaves identically to today (interactive clack prompts, colored output) +- [ ] All commands in non-TTY produce structured errors to stderr as `{ "error": { "code": "...", "message": "..." } }` +- [ ] `WORKOS_API_KEY` env var is respected as auth for commands that need it, bypassing OAuth entirely +- [ ] A `GH_PROMPT_DISABLED`-equivalent env var (`WORKOS_NO_PROMPT`) explicitly prevents all interactive prompts +- [ ] Installer in non-TTY streams NDJSON progress events to stdout (one JSON object per line) so agents can monitor real-time status +- [ ] `workos --help --json` outputs a machine-readable command tree (commands, flags, types, descriptions) +- [ ] Every subcommand's `--help` output includes complete flag documentation with types and defaults + +## Scope + +### In Scope + +- **Non-TTY auto-detection** — Enhance `isNonInteractiveEnvironment()` to drive behavior throughout the CLI +- **JSON output mode** — Global `--json` flag + auto-detect for non-TTY. Strip ANSI, use JSON for all output +- **Structured errors** — JSON error objects to stderr with error codes +- **Exit code standardization** — Consistent exit codes across all commands (0, 1, 2, 4) +- **Management command non-interactive paths** — All `env`, `org`, `user` subcommands work with flags-only, no prompts +- **Auth in non-TTY** — Require pre-existing credentials or `WORKOS_API_KEY`. Exit 4 if neither available +- **Headless installer adapter** — New adapter (alongside CLI and Dashboard) for non-interactive installs with flag overrides and auto-defaults +- **`WORKOS_NO_PROMPT` env var** — Explicit prompt suppression (like `GH_PROMPT_DISABLED`) +- **`WORKOS_FORCE_TTY` env var** — Force TTY behavior when piped (like `GH_FORCE_TTY`) +- **NDJSON streaming for installer** — Headless installer outputs progress as newline-delimited JSON events to stdout (detection, auth, file changes, agent thinking, completion). Agents consume in real-time. +- **Agent-discoverable help** — `--help --json` outputs machine-readable command schema. All subcommand help is thorough with complete flag docs, types, defaults, and examples. Agents can introspect the CLI without guessing. + +### Out of Scope + +- **New TUI features** — Dashboard stays as-is. No changes to Ink components. +- **Service account / machine tokens** — Future consideration. Pre-existing OAuth tokens and API keys are sufficient for now. +- **`--jq` / `--template` flags** — Nice-to-have but not needed for initial non-TTY support. Agents can pipe to `jq` themselves. +- **Interactive NDJSON consumer** — No TUI for consuming NDJSON events. Agents read stdout directly. A future "agent dashboard" could visualize the stream. +- **CI-specific mode** — The existing `--ci` flag on install is subsumed by the broader non-TTY support. May deprecate later. + +### Future Considerations + +- `--jq` and `--template` flags for inline output transformation +- Service account tokens for long-lived automation +- MCP server mode (expose CLI commands as MCP tools directly) +- `workos status` command for agents to check auth/config state diff --git a/docs/ideation/non-tty/spec-phase-1.md b/docs/ideation/non-tty/spec-phase-1.md new file mode 100644 index 0000000..d35e9c0 --- /dev/null +++ b/docs/ideation/non-tty/spec-phase-1.md @@ -0,0 +1,283 @@ +# Spec: Core Infrastructure + Auth (Phase 1) + +**Effort**: L +**Blocked by**: None + +## Technical Approach + +Build the foundational layer that all other phases depend on: output mode detection, JSON formatting, structured errors, exit codes, and non-TTY auth behavior. This phase touches shared utilities and the CLI entry point but does NOT modify individual command implementations — that's Phase 2. + +The key design principle: **detect once, flow everywhere**. A single `OutputMode` resolved at startup drives all output and error formatting decisions through shared utilities that commands call. + +Pattern to follow: The existing `isNonInteractiveEnvironment()` in `src/utils/environment.ts` already detects TTY. We extend this into a richer `OutputMode` system. + +## Feedback Strategy + +- **Inner-loop command**: `pnpm test -- --filter output` +- **Playground**: Unit tests for output utilities + manual `echo | workos --help` pipe tests +- **Rationale**: Core utilities need thorough unit tests since every command depends on them + +## File Changes + +### New Files + +| File | Purpose | +| ------------------------------ | -------------------------------------------------------------- | +| `src/utils/output.ts` | Output mode detection, JSON formatter, structured error writer | +| `src/utils/exit-codes.ts` | Exit code constants and typed exit helper | +| `src/utils/output.spec.ts` | Tests for output utilities | +| `src/utils/exit-codes.spec.ts` | Tests for exit code helpers | + +### Modified Files + +| File | Change | +| ------------------------------ | ---------------------------------------------------------------------------------------------------- | +| `src/utils/environment.ts` | Add `WORKOS_NO_PROMPT`, `WORKOS_FORCE_TTY` env var support to `isNonInteractiveEnvironment()` | +| `src/bin.ts` | Add global `--json` flag, resolve `OutputMode` early, pass to commands. Add `--help --json` handler. | +| `src/lib/ensure-auth.ts` | In non-TTY mode, don't trigger `runLogin()` — exit with code 4 and structured error instead | +| `src/lib/api-key.ts` | Update error to use structured error format and exit code 4 | +| `src/lib/workos-api.ts` | Update `WorkOSApiError` to support structured JSON error output | +| `src/commands/organization.ts` | Update `handleApiError` to use structured error output (shared utility) | +| `src/commands/user.ts` | Update `handleApiError` to use structured error output (shared utility) | + +## Implementation Details + +### Component 1: Output Mode System (`src/utils/output.ts`) + +Pattern to follow: `src/utils/environment.ts` for env var reading pattern. + +```typescript +export type OutputMode = 'human' | 'json'; + +export function resolveOutputMode(jsonFlag?: boolean): OutputMode { + // Explicit --json flag always wins + if (jsonFlag) return 'json'; + // WORKOS_FORCE_TTY overrides auto-detection + if (process.env.WORKOS_FORCE_TTY) return 'human'; + // Auto-detect: non-TTY → JSON + if (!process.stdout.isTTY) return 'json'; + return 'human'; +} +``` + +**Key decisions:** + +- `OutputMode` is resolved once at startup in `bin.ts` and threaded through +- `outputJson(data)` writes `JSON.stringify(data)` to stdout (no pretty-print — agents parse it) +- `outputError(error)` writes structured JSON to stderr: `{ "error": { "code": string, "message": string, "details"?: unknown } }` +- `outputSuccess(message, data?)` writes either chalk-formatted success or JSON with `{ "status": "ok", "message": string, ...data }` +- When `OutputMode === 'json'`, all chalk calls are suppressed (strip ANSI) + +**Implementation steps:** + +1. Create `OutputMode` type and `resolveOutputMode()` function +2. Create `outputJson()`, `outputError()`, `outputSuccess()` helpers +3. Create `outputTable(columns, rows)` that delegates to `formatTable()` for human mode and JSON array for json mode +4. Add `stripAnsi()` utility (or use existing `chalk.level = 0` approach) + +**Feedback loop:** + +- Playground: Test suite +- Experiment: `resolveOutputMode({ jsonFlag: true })` returns `'json'`, `resolveOutputMode()` with mocked TTY returns `'human'` +- Check: `pnpm test -- --filter output` + +### Component 2: Exit Codes (`src/utils/exit-codes.ts`) + +```typescript +export const ExitCode = { + SUCCESS: 0, + GENERAL_ERROR: 1, + CANCELLED: 2, + AUTH_REQUIRED: 4, +} as const; + +export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode]; + +export function exitWithCode(code: ExitCodeValue, error?: { code: string; message: string }): never { + if (error) { + outputError(error); + } + process.exit(code); +} +``` + +**Implementation steps:** + +1. Define exit code constants +2. Create `exitWithCode()` helper that writes structured error then exits +3. Create `exitWithAuthRequired()` convenience for the common auth case + +**Feedback loop:** + +- Playground: Test suite +- Experiment: `exitWithCode(ExitCode.AUTH_REQUIRED, { code: 'auth_required', message: '...' })` exits 4 with JSON on stderr +- Check: `pnpm test -- --filter exit-codes` + +### Component 3: Environment Variable Support (`src/utils/environment.ts`) + +Update `isNonInteractiveEnvironment()`: + +```typescript +export function isNonInteractiveEnvironment(): boolean { + // WORKOS_NO_PROMPT forces non-interactive regardless of TTY + if (process.env.WORKOS_NO_PROMPT === '1' || process.env.WORKOS_NO_PROMPT === 'true') { + return true; + } + // WORKOS_FORCE_TTY forces interactive regardless of TTY + if (process.env.WORKOS_FORCE_TTY) { + return false; + } + if (IS_DEV) { + return false; + } + if (!process.stdout.isTTY || !process.stderr.isTTY) { + return true; + } + return false; +} +``` + +**Implementation steps:** + +1. Add `WORKOS_NO_PROMPT` check (highest priority — always non-interactive) +2. Add `WORKOS_FORCE_TTY` check (overrides TTY detection → interactive) +3. Preserve existing `IS_DEV` bypass + +### Component 4: Non-TTY Auth Guard (`src/lib/ensure-auth.ts`) + +In non-TTY mode, `ensureAuthenticated()` must never trigger `runLogin()` (which opens a browser). Instead, it should exit with code 4. + +```typescript +export async function ensureAuthenticated(): Promise { + const result: EnsureAuthResult = { authenticated: false, loginTriggered: false, tokenRefreshed: false }; + + if (!hasCredentials()) { + if (isNonInteractiveEnvironment()) { + exitWithCode(ExitCode.AUTH_REQUIRED, { + code: 'auth_required', + message: 'Not authenticated. Run `workos login` in an interactive terminal, or set WORKOS_API_KEY.', + }); + } + // ... existing interactive login flow + } + // ... rest of existing logic, with same pattern for expired tokens +} +``` + +**Implementation steps:** + +1. Import `isNonInteractiveEnvironment`, `exitWithCode`, `ExitCode` +2. Add non-TTY guard before every `runLogin()` call (4 locations) +3. Each guard uses `exitWithCode(ExitCode.AUTH_REQUIRED, ...)` with a helpful message +4. Token refresh still works silently (no user interaction needed) + +### Component 5: Global `--json` Flag and Help (`src/bin.ts`) + +Add `--json` as a global yargs option and resolve `OutputMode` at the top level. + +```typescript +// Global options +.option('json', { + type: 'boolean', + default: false, + describe: 'Output results as JSON (auto-enabled in non-TTY)', + global: true, +}) +``` + +For `--help --json`, intercept yargs help output and return a structured command tree: + +```typescript +// After yargs config, before .argv +.middleware((argv) => { + if (argv.help && argv.json) { + const commandTree = buildCommandTree(yargs); // Extract from yargs internal config + console.log(JSON.stringify(commandTree, null, 2)); + process.exit(0); + } +}) +``` + +**Implementation steps:** + +1. Add `--json` global option to yargs +2. Resolve `OutputMode` early using `resolveOutputMode(argv.json)` +3. Thread `OutputMode` to commands via yargs middleware or a shared singleton +4. Add `--help --json` interceptor that outputs machine-readable command schema +5. Update default command (`$0`) to output JSON help in non-TTY + +### Component 6: Structured Error Output for API Commands + +Update both `handleApiError` functions in `organization.ts` and `user.ts` to use the shared utility: + +```typescript +function handleApiError(error: unknown): never { + if (error instanceof WorkOSApiError) { + exitWithError({ + code: error.code || `http_${error.statusCode}`, + message: error.message, + details: error.errors, + }); + } + exitWithError({ + code: 'unknown_error', + message: error instanceof Error ? error.message : 'Unknown error', + }); +} +``` + +Where `exitWithError` uses `outputError()` + `process.exit(1)`, and in JSON mode outputs to stderr as JSON instead of `chalk.red()`. + +**Implementation steps:** + +1. Create shared `exitWithError()` in `src/utils/output.ts` +2. Update `handleApiError` in `organization.ts` to use it +3. Update `handleApiError` in `user.ts` to use it +4. Both still show chalk-formatted errors in human mode + +## Testing Requirements + +### Unit Tests + +| Test | Validates | +| ------------------------------------------------------------------------ | ------------------------- | +| `resolveOutputMode()` returns `'json'` when `--json` passed | Flag override works | +| `resolveOutputMode()` returns `'json'` when stdout not TTY | Auto-detection works | +| `resolveOutputMode()` returns `'human'` when `WORKOS_FORCE_TTY=1` | Force override works | +| `isNonInteractiveEnvironment()` returns `true` when `WORKOS_NO_PROMPT=1` | Env var suppression works | +| `outputJson()` writes valid JSON to stdout | JSON formatting | +| `outputError()` writes JSON to stderr in json mode | Structured errors | +| `outputError()` writes chalk.red to stderr in human mode | Human errors preserved | +| `exitWithCode(4)` exits with code 4 | Exit code propagation | +| `ensureAuthenticated()` exits 4 in non-TTY without credentials | Auth guard | +| `ensureAuthenticated()` still refreshes tokens silently in non-TTY | Token refresh unaffected | + +### Integration Tests + +| Test | Validates | +| -------------------------------------------------------------------------------- | ------------------------ | +| `echo '' \| workos org list --api-key invalid` exits 1 with JSON error on stderr | End-to-end non-TTY error | +| `workos org list --json --api-key valid` outputs JSON array | End-to-end JSON output | +| `WORKOS_NO_PROMPT=1 workos` exits 0 with JSON help | Prompt suppression | + +## Error Handling + +- Non-TTY + no auth → exit code 4, JSON error to stderr +- Non-TTY + API error → exit code 1, JSON error to stderr with error code +- Non-TTY + cancelled (Ctrl+C) → exit code 2 +- All errors include `code` field for machine parsing + +## Validation Commands + +```bash +pnpm typecheck +pnpm test -- --filter output +pnpm test -- --filter exit-codes +pnpm build +# Manual: echo '' | node dist/bin.js org list --api-key fake 2>&1 | jq . +``` + +## Open Items + +- Should `OutputMode` be a module-level singleton (like `IS_DEV`) or threaded via function args? Singleton is simpler but harder to test. Leaning toward singleton with `setOutputMode()` for tests. +- Exact schema for `--help --json` output — should it match a standard (e.g., JSON Schema for CLI args) or be custom? diff --git a/docs/ideation/non-tty/spec-phase-2.md b/docs/ideation/non-tty/spec-phase-2.md new file mode 100644 index 0000000..d15268f --- /dev/null +++ b/docs/ideation/non-tty/spec-phase-2.md @@ -0,0 +1,192 @@ +# Spec: Management Commands Non-Interactive (Phase 2) + +**Effort**: M +**Blocked by**: Phase 1 (Core Infrastructure) + +## Technical Approach + +Migrate all management commands (`env`, `organization`, `user`) to use the Phase 1 output utilities. Each command already has partial non-interactive support (they accept flags), but output is always human-formatted (chalk tables, colored success messages). This phase makes every command produce clean JSON in non-TTY mode while preserving the current human output. + +The pattern is consistent across all commands: + +1. Replace `console.log(chalk.green(...))` with `outputSuccess()` +2. Replace `formatTable()` calls with `outputTable()` +3. Replace `handleApiError()` with shared structured error output +4. Add non-interactive paths where interactive prompts exist (env add, env switch) + +Pattern to follow: `src/commands/env.ts` already has a non-interactive path for `env add` (lines 28-34). Extend this pattern to all commands. + +## Feedback Strategy + +- **Inner-loop command**: `pnpm test -- --filter commands` +- **Playground**: Test suite + manual pipe tests (`workos org list --api-key xxx | jq .`) +- **Rationale**: Each command is independent — test one, apply pattern to rest + +## File Changes + +### Modified Files + +| File | Change | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `src/commands/env.ts` | Use output utilities for all subcommands. Add JSON output for `env list`. Non-interactive `env switch` requires name arg. | +| `src/commands/organization.ts` | Use output utilities. Remove `handleApiError` in favor of shared. JSON output for `org list`. | +| `src/commands/user.ts` | Use output utilities. Remove `handleApiError` in favor of shared. JSON output for `user list`. | +| `src/commands/env.spec.ts` | Add tests for JSON output mode | +| `src/commands/organization.spec.ts` | Add tests for JSON output mode | +| `src/commands/user.spec.ts` | Add tests for JSON output mode | +| `src/utils/table.ts` | No changes needed — `outputTable()` from Phase 1 delegates to this in human mode | +| `src/bin.ts` | Ensure non-TTY `env switch` without name arg exits with error instead of prompting | + +## Implementation Details + +### Component 1: Env Commands (`src/commands/env.ts`) + +Pattern to follow: Existing non-interactive path in `runEnvAdd()` (lines 28-34). + +**`runEnvAdd()` changes:** + +- Non-interactive path: replace `clack.log.success()` with `outputSuccess('Environment added', { name, type, active: isFirst })` +- Interactive path: no changes (human output preserved) +- In non-TTY without required args → `exitWithError({ code: 'missing_args', message: 'Name and API key required in non-interactive mode' })` + +**`runEnvRemove()` changes:** + +- Replace `clack.log.error/success` with `outputError/outputSuccess` +- Error case: `exitWithError({ code: 'not_found', message: '...' })` + +**`runEnvSwitch()` changes:** + +- Non-interactive (name provided): replace `clack.log.success` with `outputSuccess` +- Non-interactive (no name, non-TTY): `exitWithError({ code: 'missing_args', message: 'Environment name required in non-interactive mode' })` +- Interactive (no name, TTY): unchanged + +**`runEnvList()` changes:** + +- JSON mode: `outputJson(Object.values(config.environments).map(env => ({ ...env, active: env.name === config.activeEnvironment })))` +- Human mode: existing chalk table (unchanged) + +**Implementation steps:** + +1. Import output utilities at top of file +2. Update each function to check output mode for formatting +3. Add non-TTY guards for interactive-only code paths +4. Add tests for JSON output of each subcommand + +**Feedback loop:** + +- Playground: Test suite +- Experiment: `runEnvList()` with json output mode → valid JSON array +- Check: `pnpm test -- --filter env` + +### Component 2: Organization Commands (`src/commands/organization.ts`) + +**`handleApiError()` → remove**, replaced by shared `exitWithApiError()` from Phase 1. + +**`runOrgCreate()` changes:** + +- Replace `console.log(chalk.green('Created organization'))` + `console.log(JSON.stringify(org, null, 2))` with `outputSuccess('Created organization', org)` +- In JSON mode, outputs: `{ "status": "ok", "message": "Created organization", "data": { ...org } }` + +**`runOrgGet()` changes:** + +- Replace `console.log(JSON.stringify(org, null, 2))` with `outputJson(org)` +- Both modes get JSON for single-resource responses (already JSON, just standardize) + +**`runOrgList()` changes:** + +- JSON mode: `outputJson({ data: result.data, list_metadata: result.list_metadata })` +- Human mode: existing `formatTable()` (unchanged) +- Empty state: JSON mode → `outputJson({ data: [], list_metadata: result.list_metadata })` (no "No organizations found." string) + +**`runOrgUpdate()` / `runOrgDelete()` changes:** + +- Same pattern as `runOrgCreate()` + +**Implementation steps:** + +1. Remove local `handleApiError()` — use shared utility +2. Update each function to use `outputSuccess/outputJson/outputTable` +3. Ensure empty list states produce valid JSON (not strings like "No organizations found.") +4. Add tests + +**Feedback loop:** + +- Playground: Test suite with mocked API +- Experiment: `runOrgList()` in JSON mode with mock data → valid JSON with `data` array +- Check: `pnpm test -- --filter organization` + +### Component 3: User Commands (`src/commands/user.ts`) + +Identical pattern to organization commands. Same changes: + +1. Remove local `handleApiError()` +2. Replace output calls with shared utilities +3. Ensure empty states are valid JSON +4. Add tests + +**Implementation steps:** Same as Component 2, applied to user commands. + +**Feedback loop:** + +- Playground: Test suite +- Experiment: `runUserList()` in JSON mode → valid JSON +- Check: `pnpm test -- --filter user` + +### Component 4: Non-TTY Guards in `bin.ts` + +Update command handlers in `bin.ts` to prevent interactive prompts in non-TTY: + +```typescript +// env switch without name in non-TTY +.command('switch [name]', 'Switch active environment', (yargs) => ..., async (argv) => { + if (!argv.name && isNonInteractiveEnvironment()) { + exitWithError({ code: 'missing_args', message: 'Environment name required. Usage: workos env switch ' }); + } + // ... existing handler +}) +``` + +**Implementation steps:** + +1. Add non-TTY guards for `env switch` (requires name) +2. Add non-TTY guard for default command (`$0`) — already shows help, but switch to JSON help in non-TTY +3. Ensure `env add` in non-TTY without args exits with structured error + +## Testing Requirements + +### Unit Tests + +| Test | Validates | +| --------------------------------------------------------------------- | --------------------- | +| `runEnvList()` in JSON mode outputs valid JSON array | JSON env list | +| `runEnvAdd()` in non-TTY without args exits with error | Non-interactive guard | +| `runEnvSwitch()` in non-TTY without name exits with error | Non-interactive guard | +| `runOrgList()` in JSON mode outputs `{ data: [...] }` | JSON org list | +| `runOrgCreate()` in JSON mode outputs `{ status: "ok", data: {...} }` | JSON success | +| `runUserList()` in JSON mode outputs `{ data: [...] }` | JSON user list | +| `handleApiError` uses structured JSON in json mode | Structured errors | +| Empty list in JSON mode outputs `{ data: [] }`, not string | Empty state | + +### Integration Tests + +| Test | Validates | +| ----------------------------------------------- | --------------- | +| `echo '' \| workos env list` outputs valid JSON | End-to-end pipe | +| `workos org list --json` outputs JSON in TTY | Explicit flag | + +## Validation Commands + +```bash +pnpm typecheck +pnpm test -- --filter env +pnpm test -- --filter organization +pnpm test -- --filter user +pnpm build +# Manual: echo '' | node dist/bin.js env list 2>&1 | jq . +# Manual: echo '' | node dist/bin.js org list --api-key sk_test_xxx 2>&1 | jq . +``` + +## Open Items + +- Pagination metadata: Should JSON output for list commands always include `list_metadata` (cursor info) even when there's only one page? Leaning yes — agents need to know if more pages exist. +- Should `org get` and `user get` return raw API JSON or wrap in `{ "data": ... }` for consistency with list commands? Leaning raw — simpler for agents to consume single resources. diff --git a/docs/ideation/non-tty/spec-phase-3.md b/docs/ideation/non-tty/spec-phase-3.md new file mode 100644 index 0000000..2e7eb38 --- /dev/null +++ b/docs/ideation/non-tty/spec-phase-3.md @@ -0,0 +1,214 @@ +# Spec: Agent-Discoverable Help (Phase 3) + +**Effort**: S +**Blocked by**: Phase 1 (Core Infrastructure) + +## Technical Approach + +Make the CLI self-documenting for agents. Two parts: (1) `--help --json` outputs a machine-readable command tree, and (2) all subcommand help text is thorough enough for an agent to use any command without external docs. + +Yargs already knows the full command tree internally — we extract it and serialize to JSON. For help text quality, we audit every command's `describe`, positional descriptions, and option descriptions. + +Pattern to follow: `gh` CLI doesn't have `--help --json`, but tools like `kubectl` and `terraform` have rich help. Our approach is simpler: JSON schema of commands when both `--help` and `--json` are passed. + +## Feedback Strategy + +- **Inner-loop command**: `pnpm test -- --filter help` +- **Playground**: Manual testing — `workos --help --json | jq .commands` +- **Rationale**: Help output is best verified manually + snapshot tests + +## File Changes + +### New Files + +| File | Purpose | +| ----------------------------- | ------------------------------------------------ | +| `src/utils/help-json.ts` | Extracts yargs command tree into structured JSON | +| `src/utils/help-json.spec.ts` | Tests for help JSON output | + +### Modified Files + +| File | Change | +| ------------ | ------------------------------------------------------------------------------------ | +| `src/bin.ts` | Add middleware to intercept `--help --json`, improve all command/option descriptions | + +## Implementation Details + +### Component 1: JSON Help Extractor (`src/utils/help-json.ts`) + +Build a function that takes a yargs instance and returns a structured command tree: + +```typescript +export interface CommandSchema { + name: string; + description: string; + commands?: CommandSchema[]; + options?: OptionSchema[]; + positionals?: PositionalSchema[]; + examples?: string[]; +} + +export interface OptionSchema { + name: string; + type: string; + description: string; + required: boolean; + default?: unknown; + alias?: string; + choices?: string[]; + hidden: boolean; +} + +export interface PositionalSchema { + name: string; + type: string; + description: string; + required: boolean; +} + +export function buildCommandTree(yargsInstance: yargs.Argv): CommandSchema { + // Extract from yargs internal command registry + // yargs.getCommandInstance().getCommands() gives command names + // yargs.getOptions() gives options for each command +} +``` + +**Output format:** + +```json +{ + "name": "workos", + "version": "0.7.3", + "description": "WorkOS CLI for AuthKit integration and resource management", + "commands": [ + { + "name": "login", + "description": "Authenticate with WorkOS via browser-based OAuth", + "options": [ + { "name": "insecure-storage", "type": "boolean", "description": "...", "required": false, "default": false, "hidden": false } + ] + }, + { + "name": "env", + "description": "Manage environment configurations (API keys, endpoints)", + "commands": [ + { + "name": "add", + "description": "Add an environment configuration", + "positionals": [ + { "name": "name", "type": "string", "description": "Environment name (lowercase, hyphens, underscores)", "required": false }, + { "name": "apiKey", "type": "string", "description": "WorkOS API key (sk_live_* or sk_test_*)", "required": false } + ], + "options": [ + { "name": "client-id", "type": "string", "description": "WorkOS client ID for this environment", "required": false, "hidden": false }, + { "name": "endpoint", "type": "string", "description": "Custom API endpoint URL", "required": false, "hidden": false } + ] + } + ] + }, + { + "name": "organization", + "description": "Manage WorkOS organizations (CRUD operations)", + "commands": [...] + } + ] +} +``` + +**Implementation steps:** + +1. Define `CommandSchema`, `OptionSchema`, `PositionalSchema` interfaces +2. Implement `buildCommandTree()` using yargs internal APIs (`getCommandInstance()`, `getOptions()`) +3. Handle nested commands (env → add/remove/switch/list) +4. Include hidden flag on options (agents may want to use hidden flags) +5. Add version field from `getVersion()` + +**Feedback loop:** + +- Playground: Manual pipe test +- Experiment: `workos --help --json | jq '.commands | length'` returns correct count +- Check: `pnpm test -- --filter help` + +### Component 2: Help Interception (`src/bin.ts`) + +Add yargs middleware to intercept `--help --json`: + +```typescript +.middleware((argv) => { + // Note: --help causes yargs to show help and exit before middleware normally. + // We need to intercept earlier or use a custom check. +}, /* applyBeforeValidation */ true) +``` + +Actually, yargs `--help` exits before middleware runs. Better approach: check for `--help` and `--json` in argv before yargs parses: + +```typescript +const rawArgs = hideBin(process.argv); +if (rawArgs.includes('--help') && rawArgs.includes('--json')) { + // Build yargs config but don't parse + const cli = buildYargsConfig(); // Extract yargs setup into function + const tree = buildCommandTree(cli); + console.log(JSON.stringify(tree, null, 2)); + process.exit(0); +} +``` + +**Implementation steps:** + +1. Extract yargs configuration into a `buildYargsConfig()` function (refactor from inline in bin.ts) +2. Add early `--help --json` check before `.argv` +3. Call `buildCommandTree()` and output JSON + +### Component 3: Help Text Quality Audit + +Audit and improve all command/option descriptions in `bin.ts`: + +**Current gaps found:** + +- `env` command: `'Manage environment configurations'` → `'Manage environment configurations (API keys, endpoints, active environment)'` +- `organization`: `'Manage organizations'` → `'Manage WorkOS organizations (create, update, get, list, delete)'` +- `user`: `'Manage users'` → `'Manage WorkOS user management users (get, list, update, delete)'` +- `install`: Missing description of what it does beyond "Install WorkOS AuthKit" +- Options like `--api-key`: `'WorkOS API key (overrides environment config)'` → add `'Format: sk_live_* or sk_test_*'` +- Positionals like `domains..`: `'Domains as domain:state'` → `'Domains in format domain.com:verified (state is optional, defaults to verified)'` + +**Implementation steps:** + +1. Review every `.describe()` and `.positional()` description +2. Ensure each describes: what it does, format/type expectations, default behavior +3. Add examples where helpful (yargs `.example()`) +4. Keep descriptions concise but complete — agents need enough to use the command correctly + +## Testing Requirements + +### Unit Tests + +| Test | Validates | +| ------------------------------------------------------------- | ----------------- | +| `buildCommandTree()` returns all top-level commands | Command discovery | +| `buildCommandTree()` includes nested subcommands | Nested commands | +| `buildCommandTree()` includes options with types and defaults | Option schema | +| `buildCommandTree()` includes positionals with required flag | Positional schema | +| Output is valid JSON parseable by `JSON.parse()` | JSON validity | + +### Snapshot Tests + +| Test | Validates | +| --------------------------------------- | --------------------------------------- | +| `--help --json` output matches snapshot | No accidental regression in help schema | + +## Validation Commands + +```bash +pnpm typecheck +pnpm test -- --filter help +pnpm build +# Manual: node dist/bin.js --help --json | jq . +# Manual: node dist/bin.js env --help --json | jq '.commands' +# Manual: node dist/bin.js organization --help --json | jq '.commands[0].positionals' +``` + +## Open Items + +- Should `--help --json` work for subcommands too? e.g., `workos env --help --json` → only the env subtree. Leaning yes — more useful for agents exploring one command group. +- Yargs internal APIs are not stable. Need to check if `getCommandInstance()` is reliable or if we need to build the tree from our own registry. May need a parallel command registry. diff --git a/docs/ideation/non-tty/spec-phase-4.md b/docs/ideation/non-tty/spec-phase-4.md new file mode 100644 index 0000000..1feaaa3 --- /dev/null +++ b/docs/ideation/non-tty/spec-phase-4.md @@ -0,0 +1,332 @@ +# Spec: Headless Installer + NDJSON Streaming (Phase 4) + +**Effort**: L +**Blocked by**: Phase 1 (Core Infrastructure) + +## Technical Approach + +Create a third adapter — `HeadlessAdapter` — alongside the existing `CLIAdapter` and `DashboardAdapter`. This adapter handles all state machine events non-interactively: auto-resolving decisions with sensible defaults, accepting overrides via CLI flags, and streaming progress as NDJSON to stdout. + +The adapter pattern is already well-established. The state machine (`installer-core.ts`) emits typed events; adapters subscribe and respond. The `HeadlessAdapter` subscribes to the same events but never prompts — it auto-responds with defaults or flag-provided values. + +Pattern to follow: `src/lib/adapters/cli-adapter.ts` for event subscription pattern and handler structure. + +## Feedback Strategy + +- **Inner-loop command**: `pnpm test -- --filter headless` +- **Playground**: Pipe test — `echo '' | workos install --api-key xxx --client-id yyy 2>&1 | head` +- **Rationale**: Adapter must be tested against the real event flow, but unit tests can mock the emitter + +## File Changes + +### New Files + +| File | Purpose | +| ------------------------------------------- | --------------------------------------------- | +| `src/lib/adapters/headless-adapter.ts` | Non-interactive adapter with NDJSON streaming | +| `src/lib/adapters/headless-adapter.spec.ts` | Tests for headless adapter | +| `src/utils/ndjson.ts` | NDJSON writer utility | +| `src/utils/ndjson.spec.ts` | Tests for NDJSON utility | + +### Modified Files + +| File | Change | +| -------------------------- | ------------------------------------------------------------------------------- | +| `src/lib/run-with-core.ts` | Add headless adapter selection when non-TTY detected | +| `src/commands/install.ts` | Remove non-TTY block — route to headless adapter instead of erroring | +| `src/bin.ts` | Ensure `--api-key` and `--client-id` are visible (not hidden) flags for install | + +## Implementation Details + +### Component 1: NDJSON Writer (`src/utils/ndjson.ts`) + +Simple utility for writing newline-delimited JSON events to stdout: + +```typescript +export interface NDJSONEvent { + type: string; + timestamp: string; + [key: string]: unknown; +} + +export function writeNDJSON(event: Omit): void { + const line: NDJSONEvent = { + ...event, + timestamp: new Date().toISOString(), + }; + process.stdout.write(JSON.stringify(line) + '\n'); +} +``` + +**Event types emitted:** + +| Event Type | Payload | When | +| --------------------- | ----------------------------------------- | -------------------------- | +| `detection:start` | `{}` | Framework detection begins | +| `detection:complete` | `{ integration: string }` | Framework detected | +| `detection:none` | `{}` | No framework detected | +| `auth:checking` | `{}` | Checking credentials | +| `auth:success` | `{}` | Authenticated | +| `auth:required` | `{ message: string }` | Auth failed (also exits 4) | +| `git:status` | `{ dirty: boolean, files?: string[] }` | Git status check | +| `git:decision` | `{ action: 'continue' \| 'cancel' }` | Auto-resolved git decision | +| `credentials:found` | `{ source: 'flag' \| 'env' \| 'stored' }` | Credentials resolved | +| `branch:created` | `{ name: string }` | Branch created | +| `branch:skipped` | `{ reason: string }` | Branch creation skipped | +| `agent:start` | `{}` | Agent execution begins | +| `agent:progress` | `{ message: string }` | Agent thinking/progress | +| `agent:tool` | `{ tool: string, input?: unknown }` | Agent using a tool | +| `agent:success` | `{}` | Agent completed | +| `agent:failure` | `{ error: string }` | Agent failed | +| `validation:start` | `{}` | Post-install validation | +| `validation:issue` | `{ severity: string, message: string }` | Validation finding | +| `validation:complete` | `{ issues: number }` | Validation done | +| `commit:created` | `{ sha: string, message: string }` | Auto-committed | +| `complete` | `{ success: boolean }` | Install finished | +| `error` | `{ code: string, message: string }` | Fatal error | + +**Implementation steps:** + +1. Define `NDJSONEvent` interface +2. Create `writeNDJSON()` that serializes + writes to stdout +3. Ensure no other stdout writes happen during headless mode (all human output suppressed) + +**Feedback loop:** + +- Playground: Test suite +- Experiment: `writeNDJSON({ type: 'detection:complete', integration: 'nextjs' })` outputs valid JSON line +- Check: `pnpm test -- --filter ndjson` + +### Component 2: Headless Adapter (`src/lib/adapters/headless-adapter.ts`) + +Pattern to follow: `src/lib/adapters/cli-adapter.ts` — same `subscribe()` pattern, same event handlers, but auto-resolving instead of prompting. + +```typescript +import type { AdapterConfig } from './types.js'; +import { writeNDJSON } from '../../utils/ndjson.js'; + +export class HeadlessAdapter { + private emitter: AdapterConfig['emitter']; + private sendEvent: AdapterConfig['sendEvent']; + private handlers = new Map void>(); + private options: HeadlessOptions; + + constructor(config: AdapterConfig & { options: HeadlessOptions }) { + this.emitter = config.emitter; + this.sendEvent = config.sendEvent; + this.options = config.options; + } + + async start(): Promise { + // Subscribe to all events, auto-resolve decisions + this.subscribe('detection:complete', this.handleDetectionComplete); + this.subscribe('detection:none', this.handleDetectionNone); + this.subscribe('git:dirty', this.handleGitDirty); + this.subscribe('credentials:request', this.handleCredentialsRequest); + this.subscribe('credentials:env:prompt', this.handleEnvPrompt); + this.subscribe('branch:prompt', this.handleBranchPrompt); + this.subscribe('postinstall:commit:prompt', this.handleCommitPrompt); + this.subscribe('postinstall:pr:prompt', this.handlePrPrompt); + // ... all other events → writeNDJSON pass-through + } +} +``` + +**Auto-default decisions:** + +| Event | Interactive Behavior | Headless Default | Override Flag | +| --------------------------- | ---------------------------- | ------------------------------------------------------ | -------------------------- | +| `git:dirty` | Prompt to continue | Auto-continue | `--no-git-check` | +| `credentials:request` | Prompt for API key/client ID | Use `--api-key`/`--client-id` flags. Error if missing. | `--api-key`, `--client-id` | +| `credentials:env:prompt` | Ask to scan env files | Auto-scan | (always scans) | +| `branch:prompt` | Ask create/continue/cancel | Auto-create branch | `--no-branch` to skip | +| `postinstall:commit:prompt` | Ask to commit | Auto-commit | `--no-commit` to skip | +| `postinstall:pr:prompt` | Ask to create PR | Skip PR | `--create-pr` to enable | + +**Implementation steps:** + +1. Create `HeadlessOptions` interface with all override flags +2. Implement constructor with `AdapterConfig` + options +3. Implement `start()` with event subscriptions +4. For each decision event: auto-resolve with default, log NDJSON event, send event back to state machine +5. For progress/info events: write NDJSON pass-through +6. For error events: write NDJSON + exit with appropriate code +7. Implement `stop()` cleanup (same pattern as CLIAdapter) + +**Key handler examples:** + +```typescript +private handleGitDirty = ({ files }: InstallerEvents['git:dirty']): void => { + writeNDJSON({ type: 'git:status', dirty: true, files }); + writeNDJSON({ type: 'git:decision', action: 'continue' }); + this.sendEvent({ type: 'GIT_CONFIRMED' }); +}; + +private handleCredentialsRequest = ({ requiresApiKey }: InstallerEvents['credentials:request']): void => { + if (requiresApiKey && !this.options.apiKey) { + writeNDJSON({ type: 'error', code: 'missing_credentials', message: 'API key required. Pass --api-key flag.' }); + exitWithCode(ExitCode.GENERAL_ERROR); + } + this.sendEvent({ + type: 'CREDENTIALS_PROVIDED', + apiKey: this.options.apiKey, + clientId: this.options.clientId, + }); +}; + +private handleBranchPrompt = (): void => { + if (this.options.noBranch) { + writeNDJSON({ type: 'branch:skipped', reason: '--no-branch flag' }); + this.sendEvent({ type: 'BRANCH_CONTINUE_CURRENT' }); + } else { + writeNDJSON({ type: 'branch:creating' }); + this.sendEvent({ type: 'BRANCH_CREATE' }); + } +}; +``` + +**Feedback loop:** + +- Playground: Test suite with mocked emitter +- Experiment: Emit `git:dirty` → adapter auto-confirms and writes NDJSON +- Check: `pnpm test -- --filter headless` + +### Component 3: Adapter Selection (`src/lib/run-with-core.ts`) + +Update adapter selection to include headless: + +```typescript +let adapter: InstallerAdapter; +if (isNonInteractiveEnvironment()) { + adapter = new HeadlessAdapter({ + emitter, + sendEvent, + debug: augmentedOptions.debug, + options: { + apiKey: augmentedOptions.apiKey, + clientId: augmentedOptions.clientId, + noBranch: augmentedOptions.noBranch, + noCommit: augmentedOptions.noCommit, + createPr: augmentedOptions.createPr, + noGitCheck: augmentedOptions.noGitCheck, + }, + }); +} else if (options.dashboard) { + adapter = new DashboardAdapter({ emitter, sendEvent, debug: augmentedOptions.debug }); +} else { + adapter = new CLIAdapter({ emitter, sendEvent, debug: augmentedOptions.debug }); +} +``` + +**Implementation steps:** + +1. Import `HeadlessAdapter` and `isNonInteractiveEnvironment` +2. Add non-TTY check as highest priority (before dashboard check) +3. Pass headless options from CLI args +4. Ensure `HeadlessAdapter` implements same interface as other adapters + +### Component 4: Install Command Update (`src/commands/install.ts`) + +Remove the non-TTY block that currently exits with an error: + +```typescript +// REMOVE THIS: +} else if (isNonInteractiveEnvironment()) { + clack.log.error('This installer requires an interactive terminal...'); + process.exit(1); +} +``` + +Replace with: let the flow continue — `run-with-core.ts` will select the `HeadlessAdapter`. + +**Implementation steps:** + +1. Remove non-TTY error block in `handleInstall()` +2. Ensure CI mode (`--ci`) still works (may need to merge with headless behavior) +3. Make `--api-key` and `--client-id` visible in yargs config (currently `hidden: true`) + +### Component 5: New Install Flags (`src/bin.ts`) + +Expose headless-relevant flags: + +```typescript +const installerOptions = { + // ... existing options + 'api-key': { + type: 'string' as const, + describe: 'WorkOS API key (required in non-interactive mode)', + }, + 'client-id': { + type: 'string' as const, + describe: 'WorkOS client ID (required in non-interactive mode)', + }, + 'no-branch': { + default: false, + type: 'boolean' as const, + describe: 'Skip branch creation (use current branch)', + }, + 'no-commit': { + default: false, + type: 'boolean' as const, + describe: 'Skip auto-commit after installation', + }, + 'create-pr': { + default: false, + type: 'boolean' as const, + describe: 'Auto-create pull request after installation', + }, + 'no-git-check': { + default: false, + type: 'boolean' as const, + describe: 'Skip git dirty check', + }, +}; +``` + +## Testing Requirements + +### Unit Tests + +| Test | Validates | +| ---------------------------------------------------- | ------------------------ | +| `HeadlessAdapter` auto-confirms git dirty | Default behavior | +| `HeadlessAdapter` sends credentials from flags | Flag passthrough | +| `HeadlessAdapter` errors when credentials missing | Required flag validation | +| `HeadlessAdapter` auto-creates branch by default | Default branch behavior | +| `HeadlessAdapter` skips branch with `--no-branch` | Flag override | +| `HeadlessAdapter` auto-commits by default | Default commit behavior | +| `HeadlessAdapter` skips commit with `--no-commit` | Flag override | +| All NDJSON events have `type` and `timestamp` fields | Event schema | +| NDJSON output is parseable line-by-line | NDJSON format | +| `writeNDJSON` outputs exactly one line per call | No multi-line | + +### Integration Tests + +| Test | Validates | +| -------------------------------------------------------------------------------------- | ------------------- | +| `echo '' \| workos install --api-key xxx --client-id yyy --no-validate` streams NDJSON | End-to-end headless | +| Headless install with missing `--api-key` exits with error NDJSON event | Error handling | + +## Error Handling + +- Missing required credentials → NDJSON error event + exit code 1 +- Agent failure → NDJSON error event + exit code 1 +- Auth expired during install → NDJSON auth:required event + exit code 4 +- All errors produce both an NDJSON event AND a structured JSON error on stderr (for agents that read stderr) + +## Validation Commands + +```bash +pnpm typecheck +pnpm test -- --filter headless +pnpm test -- --filter ndjson +pnpm build +# Manual (requires real credentials): +# echo '' | node dist/bin.js install --api-key sk_test_xxx --client-id client_xxx --no-validate --no-commit 2>/dev/null | head -20 +``` + +## Open Items + +- Should agent progress events include the raw agent thinking text, or just summaries? Raw text could be very verbose. Leaning toward summaries with a `--verbose` flag for full output. +- Should NDJSON go to stdout or stderr? Stdout is conventional for data, but if the installer also produces file content to stdout, there'd be a conflict. Since the installer writes files to disk (not stdout), NDJSON on stdout is fine. +- The existing `--ci` flag partially overlaps with headless mode. Should we deprecate `--ci` in favor of auto-detection? Or keep it as an alias? Leaning toward keeping `--ci` as a documented alias for "non-interactive install" to avoid breaking existing CI configs. diff --git a/src/bin.ts b/src/bin.ts index dc633f6..ce1d0b1 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -28,8 +28,14 @@ if (!satisfies(process.version, NODE_VERSION_RANGE)) { } import { isNonInteractiveEnvironment } from './utils/environment.js'; +import { resolveOutputMode, setOutputMode } from './utils/output.js'; import clack from './utils/clack.js'; +// Resolve output mode early from raw argv (before yargs parses) +const rawArgs = hideBin(process.argv); +const hasJsonFlag = rawArgs.includes('--json'); +setOutputMode(resolveOutputMode(hasJsonFlag)); + /** Apply insecure storage flag if set */ async function applyInsecureStorage(insecureStorage?: boolean): Promise { if (insecureStorage) { @@ -145,6 +151,12 @@ await checkForUpdates(); yargs(hideBin(process.argv)) .env('WORKOS_INSTALLER') + .option('json', { + type: 'boolean', + default: false, + describe: 'Output results as JSON (auto-enabled in non-TTY)', + global: true, + }) .command('login', 'Authenticate with WorkOS', insecureStorageOption, async (argv) => { await applyInsecureStorage(argv.insecureStorage); const { runLogin } = await import('./commands/login.js'); diff --git a/src/commands/organization.ts b/src/commands/organization.ts index 2648e3c..a0e6466 100644 --- a/src/commands/organization.ts +++ b/src/commands/organization.ts @@ -2,6 +2,7 @@ import chalk from 'chalk'; import { workosRequest, WorkOSApiError } from '../lib/workos-api.js'; import type { WorkOSListResponse } from '../lib/workos-api.js'; import { formatTable } from '../utils/table.js'; +import { exitWithError } from '../utils/output.js'; interface OrganizationDomain { id: string; @@ -34,19 +35,23 @@ export function parseDomainArgs(args: string[]): DomainData[] { function handleApiError(error: unknown): never { if (error instanceof WorkOSApiError) { - if (error.statusCode === 401) { - console.error(chalk.red('Invalid API key. Check your environment configuration.')); - } else if (error.statusCode === 404) { - console.error(chalk.red(`Organization not found.`)); - } else if (error.statusCode === 422 && error.errors?.length) { - console.error(chalk.red(error.errors.map((e) => e.message).join(', '))); - } else { - console.error(chalk.red(error.message)); - } - } else { - console.error(chalk.red(error instanceof Error ? error.message : 'Unknown error')); + exitWithError({ + code: error.code ?? `http_${error.statusCode}`, + message: + error.statusCode === 401 + ? 'Invalid API key. Check your environment configuration.' + : error.statusCode === 404 + ? 'Organization not found.' + : error.statusCode === 422 && error.errors?.length + ? error.errors.map((e) => e.message).join(', ') + : error.message, + details: error.errors, + }); } - process.exit(1); + exitWithError({ + code: 'unknown_error', + message: error instanceof Error ? error.message : 'Unknown error', + }); } export async function runOrgCreate( diff --git a/src/commands/user.ts b/src/commands/user.ts index c88c8dc..cacb02e 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -2,6 +2,7 @@ import chalk from 'chalk'; import { workosRequest, WorkOSApiError } from '../lib/workos-api.js'; import type { WorkOSListResponse } from '../lib/workos-api.js'; import { formatTable } from '../utils/table.js'; +import { exitWithError } from '../utils/output.js'; interface User { id: string; @@ -15,19 +16,23 @@ interface User { function handleApiError(error: unknown): never { if (error instanceof WorkOSApiError) { - if (error.statusCode === 401) { - console.error(chalk.red('Invalid API key. Check your environment configuration.')); - } else if (error.statusCode === 404) { - console.error(chalk.red('User not found.')); - } else if (error.statusCode === 422 && error.errors?.length) { - console.error(chalk.red(error.errors.map((e) => e.message).join(', '))); - } else { - console.error(chalk.red(error.message)); - } - } else { - console.error(chalk.red(error instanceof Error ? error.message : 'Unknown error')); + exitWithError({ + code: error.code ?? `http_${error.statusCode}`, + message: + error.statusCode === 401 + ? 'Invalid API key. Check your environment configuration.' + : error.statusCode === 404 + ? 'User not found.' + : error.statusCode === 422 && error.errors?.length + ? error.errors.map((e) => e.message).join(', ') + : error.message, + details: error.errors, + }); } - process.exit(1); + exitWithError({ + code: 'unknown_error', + message: error instanceof Error ? error.message : 'Unknown error', + }); } export async function runUserGet(userId: string, apiKey: string, baseUrl?: string): Promise { diff --git a/src/lib/ensure-auth.spec.ts b/src/lib/ensure-auth.spec.ts index 152ab42..f4aa500 100644 --- a/src/lib/ensure-auth.spec.ts +++ b/src/lib/ensure-auth.spec.ts @@ -29,10 +29,36 @@ vi.mock('../utils/debug.js', () => ({ logWarn: vi.fn(), })); -// Mock settings +// Mock settings (getConfig needed by constants.ts via environment.ts import chain) vi.mock('./settings.js', () => ({ getCliAuthClientId: vi.fn(() => 'test_client_id'), getAuthkitDomain: vi.fn(() => 'https://auth.test.com'), + getConfig: vi.fn(() => ({ + nodeVersion: '>=20', + logging: { debugMode: false }, + documentation: { workosDocsUrl: '', dashboardUrl: '', issuesUrl: '' }, + telemetry: { enabled: false, eventName: '' }, + legacy: { oauthPort: 0 }, + })), +})); + +// Mock environment detection +const mockIsNonInteractive = vi.fn(() => false); +vi.mock('../utils/environment.js', () => ({ + isNonInteractiveEnvironment: () => mockIsNonInteractive(), +})); + +// Mock exit codes — must throw to halt execution like the real process.exit() +class AuthRequiredExit extends Error { + constructor() { + super('auth_required_exit'); + } +} +const mockExitWithAuthRequired = vi.fn(() => { + throw new AuthRequiredExit(); +}); +vi.mock('../utils/exit-codes.js', () => ({ + exitWithAuthRequired: (...args: unknown[]) => mockExitWithAuthRequired(...args), })); // Mock runLogin @@ -256,5 +282,56 @@ describe('ensure-auth', () => { expect(mockRefreshAccessToken).toHaveBeenCalledWith('https://auth.test.com', 'test_client_id'); }); + + describe('non-TTY mode', () => { + beforeEach(() => { + mockIsNonInteractive.mockReturnValue(true); + }); + + afterEach(() => { + mockIsNonInteractive.mockReturnValue(false); + }); + + it('exits with auth required when no credentials in non-TTY', async () => { + // No credentials saved, non-TTY mode + await expect(ensureAuthenticated()).rejects.toThrow(AuthRequiredExit); + + expect(mockExitWithAuthRequired).toHaveBeenCalled(); + expect(mockRunLogin).not.toHaveBeenCalled(); + }); + + it('still refreshes tokens silently in non-TTY', async () => { + saveCredentials(expiredAccessCreds); + + mockRefreshAccessToken.mockResolvedValue({ + success: true, + accessToken: 'new_token', + expiresAt: Date.now() + 3600000, + refreshToken: 'new_refresh', + }); + + const result = await ensureAuthenticated(); + + expect(result.tokenRefreshed).toBe(true); + expect(result.authenticated).toBe(true); + expect(mockExitWithAuthRequired).not.toHaveBeenCalled(); + expect(mockRunLogin).not.toHaveBeenCalled(); + }); + + it('exits with auth required when refresh fails in non-TTY', async () => { + saveCredentials(expiredAccessCreds); + + mockRefreshAccessToken.mockResolvedValue({ + success: false, + errorType: 'invalid_grant', + error: 'Refresh token expired', + }); + + await expect(ensureAuthenticated()).rejects.toThrow(AuthRequiredExit); + + expect(mockExitWithAuthRequired).toHaveBeenCalled(); + expect(mockRunLogin).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/src/lib/ensure-auth.ts b/src/lib/ensure-auth.ts index 402a6fd..cb80aab 100644 --- a/src/lib/ensure-auth.ts +++ b/src/lib/ensure-auth.ts @@ -7,6 +7,8 @@ import { refreshAccessToken } from './token-refresh-client.js'; import { getCliAuthClientId, getAuthkitDomain } from './settings.js'; import { runLogin } from '../commands/login.js'; import { logInfo } from '../utils/debug.js'; +import { isNonInteractiveEnvironment } from '../utils/environment.js'; +import { exitWithAuthRequired } from '../utils/exit-codes.js'; export interface EnsureAuthResult { /** Whether auth is now valid */ @@ -36,6 +38,9 @@ export async function ensureAuthenticated(): Promise { // Case 1: No credentials at all if (!hasCredentials()) { + if (isNonInteractiveEnvironment()) { + exitWithAuthRequired(); + } logInfo('[ensure-auth] No credentials found, triggering login'); await runLogin(); result.loginTriggered = true; @@ -46,6 +51,9 @@ export async function ensureAuthenticated(): Promise { const creds = getCredentials(); if (!creds) { // Credentials file exists but is invalid/empty + if (isNonInteractiveEnvironment()) { + exitWithAuthRequired(); + } logInfo('[ensure-auth] Invalid credentials file, triggering login'); await runLogin(); result.loginTriggered = true; @@ -78,6 +86,9 @@ export async function ensureAuthenticated(): Promise { // Refresh failed - check if it's recoverable if (refreshResult.errorType === 'invalid_grant') { + if (isNonInteractiveEnvironment()) { + exitWithAuthRequired('Session expired. Run `workos login` in an interactive terminal to re-authenticate.'); + } logInfo('[ensure-auth] Refresh token expired, triggering login'); await runLogin(); result.loginTriggered = true; @@ -86,6 +97,11 @@ export async function ensureAuthenticated(): Promise { } // Network or server error - try login as fallback + if (isNonInteractiveEnvironment()) { + exitWithAuthRequired( + `Authentication refresh failed (${refreshResult.errorType}). Run \`workos login\` in an interactive terminal.`, + ); + } logInfo(`[ensure-auth] Refresh failed (${refreshResult.errorType}), triggering login`); await runLogin(); result.loginTriggered = true; @@ -95,6 +111,9 @@ export async function ensureAuthenticated(): Promise { } // Case 4: No refresh token available, must login + if (isNonInteractiveEnvironment()) { + exitWithAuthRequired('Session expired. Run `workos login` in an interactive terminal to re-authenticate.'); + } logInfo('[ensure-auth] No refresh token, triggering login'); await runLogin(); result.loginTriggered = true; diff --git a/src/utils/environment.ts b/src/utils/environment.ts index 3008a2c..b701912 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -4,6 +4,16 @@ import fg from 'fast-glob'; import { IS_DEV } from '../lib/constants.js'; export function isNonInteractiveEnvironment(): boolean { + // WORKOS_NO_PROMPT forces non-interactive regardless of TTY + if (process.env.WORKOS_NO_PROMPT === '1' || process.env.WORKOS_NO_PROMPT === 'true') { + return true; + } + + // WORKOS_FORCE_TTY forces interactive regardless of TTY + if (process.env.WORKOS_FORCE_TTY) { + return false; + } + if (IS_DEV) { return false; } diff --git a/src/utils/exit-codes.spec.ts b/src/utils/exit-codes.spec.ts new file mode 100644 index 0000000..1178f1b --- /dev/null +++ b/src/utils/exit-codes.spec.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('./output.js', () => ({ + outputError: vi.fn(), +})); + +const { outputError } = await import('./output.js'); +const { ExitCode, exitWithCode, exitWithAuthRequired } = await import('./exit-codes.js'); + +describe('exit-codes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('ExitCode constants', () => { + it('has correct values', () => { + expect(ExitCode.SUCCESS).toBe(0); + expect(ExitCode.GENERAL_ERROR).toBe(1); + expect(ExitCode.CANCELLED).toBe(2); + expect(ExitCode.AUTH_REQUIRED).toBe(4); + }); + }); + + describe('exitWithCode', () => { + it('exits with the given code', () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + exitWithCode(ExitCode.GENERAL_ERROR); + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + }); + + it('writes error before exiting when error provided', () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + exitWithCode(ExitCode.AUTH_REQUIRED, { code: 'auth_required', message: 'Not logged in' }); + expect(outputError).toHaveBeenCalledWith({ code: 'auth_required', message: 'Not logged in' }); + expect(exitSpy).toHaveBeenCalledWith(4); + exitSpy.mockRestore(); + }); + + it('does not write error when none provided', () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + exitWithCode(ExitCode.SUCCESS); + expect(outputError).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(0); + exitSpy.mockRestore(); + }); + }); + + describe('exitWithAuthRequired', () => { + it('exits with code 4 and auth_required error', () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + exitWithAuthRequired(); + expect(outputError).toHaveBeenCalledWith(expect.objectContaining({ code: 'auth_required' })); + expect(exitSpy).toHaveBeenCalledWith(4); + exitSpy.mockRestore(); + }); + + it('uses custom message when provided', () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + exitWithAuthRequired('Custom auth message'); + expect(outputError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'auth_required', message: 'Custom auth message' }), + ); + exitSpy.mockRestore(); + }); + }); +}); diff --git a/src/utils/exit-codes.ts b/src/utils/exit-codes.ts new file mode 100644 index 0000000..3cfc111 --- /dev/null +++ b/src/utils/exit-codes.ts @@ -0,0 +1,35 @@ +/** + * Standardized exit codes following gh CLI convention. + * + * 0 = Success + * 1 = General error + * 2 = Cancelled (e.g., Ctrl+C, user cancelled prompt) + * 4 = Authentication required + */ + +import { outputError } from './output.js'; + +export const ExitCode = { + SUCCESS: 0, + GENERAL_ERROR: 1, + CANCELLED: 2, + AUTH_REQUIRED: 4, +} as const; + +export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode]; + +/** Exit with a specific code, optionally writing a structured error first. */ +export function exitWithCode(code: ExitCodeValue, error?: { code: string; message: string }): never { + if (error) { + outputError(error); + } + process.exit(code); +} + +/** Convenience: exit with code 4 and auth-required error. */ +export function exitWithAuthRequired(message?: string): never { + exitWithCode(ExitCode.AUTH_REQUIRED, { + code: 'auth_required', + message: message ?? 'Not authenticated. Run `workos login` in an interactive terminal, or set WORKOS_API_KEY.', + }); +} diff --git a/src/utils/output.spec.ts b/src/utils/output.spec.ts new file mode 100644 index 0000000..5173e6c --- /dev/null +++ b/src/utils/output.spec.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const { + resolveOutputMode, + setOutputMode, + getOutputMode, + isJsonMode, + outputJson, + outputError, + outputSuccess, + exitWithError, +} = await import('./output.js'); + +describe('output', () => { + const originalIsTTY = process.stdout.isTTY; + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.WORKOS_FORCE_TTY; + setOutputMode('human'); + }); + + afterEach(() => { + Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, writable: true }); + process.env = originalEnv; + }); + + describe('resolveOutputMode', () => { + it('returns json when --json flag passed', () => { + expect(resolveOutputMode(true)).toBe('json'); + }); + + it('returns human when WORKOS_FORCE_TTY is set even without TTY', () => { + Object.defineProperty(process.stdout, 'isTTY', { value: undefined, writable: true }); + process.env.WORKOS_FORCE_TTY = '1'; + expect(resolveOutputMode()).toBe('human'); + }); + + it('returns json when stdout is not a TTY', () => { + Object.defineProperty(process.stdout, 'isTTY', { value: undefined, writable: true }); + expect(resolveOutputMode()).toBe('json'); + }); + + it('returns human when stdout is a TTY and no flags', () => { + Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); + expect(resolveOutputMode()).toBe('human'); + }); + + it('--json flag overrides WORKOS_FORCE_TTY', () => { + process.env.WORKOS_FORCE_TTY = '1'; + expect(resolveOutputMode(true)).toBe('json'); + }); + }); + + describe('setOutputMode / getOutputMode / isJsonMode', () => { + it('sets and gets output mode', () => { + setOutputMode('json'); + expect(getOutputMode()).toBe('json'); + expect(isJsonMode()).toBe(true); + }); + + it('defaults to human', () => { + setOutputMode('human'); + expect(getOutputMode()).toBe('human'); + expect(isJsonMode()).toBe(false); + }); + }); + + describe('outputJson', () => { + it('writes valid JSON to stdout', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + outputJson({ foo: 'bar', count: 42 }); + expect(spy).toHaveBeenCalledWith('{"foo":"bar","count":42}'); + spy.mockRestore(); + }); + + it('handles arrays', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + outputJson([1, 2, 3]); + expect(spy).toHaveBeenCalledWith('[1,2,3]'); + spy.mockRestore(); + }); + }); + + describe('outputError', () => { + it('writes JSON to stderr in json mode', () => { + setOutputMode('json'); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + outputError({ code: 'test_error', message: 'something failed' }); + const output = spy.mock.calls[0][0]; + expect(JSON.parse(output)).toEqual({ + error: { code: 'test_error', message: 'something failed' }, + }); + spy.mockRestore(); + }); + + it('writes plain text to stderr in human mode', () => { + setOutputMode('human'); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + outputError({ code: 'test_error', message: 'something failed' }); + expect(spy.mock.calls[0][0]).toContain('something failed'); + spy.mockRestore(); + }); + }); + + describe('outputSuccess', () => { + it('writes JSON in json mode', () => { + setOutputMode('json'); + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + outputSuccess('Created', { id: '123' }); + const output = JSON.parse(spy.mock.calls[0][0]); + expect(output).toEqual({ status: 'ok', message: 'Created', id: '123' }); + spy.mockRestore(); + }); + + it('writes chalk-formatted text in human mode', () => { + setOutputMode('human'); + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + outputSuccess('Created'); + expect(spy.mock.calls[0][0]).toContain('Created'); + spy.mockRestore(); + }); + }); + + describe('exitWithError', () => { + it('writes error and exits with code 1', () => { + setOutputMode('json'); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + exitWithError({ code: 'bad', message: 'something broke' }); + + const output = JSON.parse(errorSpy.mock.calls[0][0]); + expect(output.error.code).toBe('bad'); + expect(exitSpy).toHaveBeenCalledWith(1); + + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + }); +}); diff --git a/src/utils/output.ts b/src/utils/output.ts new file mode 100644 index 0000000..bcc5d62 --- /dev/null +++ b/src/utils/output.ts @@ -0,0 +1,98 @@ +/** + * Output mode system for non-TTY / JSON support. + * + * Resolves once at startup, drives all output formatting. + * In JSON mode: structured JSON to stdout, structured errors to stderr. + * In human mode: chalk-formatted output (existing behavior). + */ + +import chalk from 'chalk'; +import { formatTable, type TableColumn } from './table.js'; + +export type OutputMode = 'human' | 'json'; + +let currentMode: OutputMode = 'human'; + +/** + * Resolve the output mode based on flags and environment. + * + * Priority: + * 1. Explicit --json flag + * 2. WORKOS_FORCE_TTY env var → human + * 3. Non-TTY auto-detection → json + * 4. Default → human + */ +export function resolveOutputMode(jsonFlag?: boolean): OutputMode { + if (jsonFlag) return 'json'; + if (process.env.WORKOS_FORCE_TTY) return 'human'; + if (!process.stdout.isTTY) return 'json'; + return 'human'; +} + +export function setOutputMode(mode: OutputMode): void { + currentMode = mode; + if (mode === 'json') { + chalk.level = 0; + } +} + +export function getOutputMode(): OutputMode { + return currentMode; +} + +export function isJsonMode(): boolean { + return currentMode === 'json'; +} + +/** Write structured JSON to stdout (one line, no pretty-print). */ +export function outputJson(data: unknown): void { + console.log(JSON.stringify(data)); +} + +/** Write a success result — chalk in human mode, JSON in json mode. */ +export function outputSuccess(message: string, data?: Record): void { + if (currentMode === 'json') { + console.log(JSON.stringify({ status: 'ok', message, ...data })); + } else { + console.log(chalk.green(message)); + if (data) { + console.log(JSON.stringify(data, null, 2)); + } + } +} + +/** Write a structured error to stderr. */ +export function outputError(error: { code: string; message: string; details?: unknown }): void { + if (currentMode === 'json') { + console.error(JSON.stringify({ error })); + } else { + console.error(chalk.red(error.message)); + } +} + +/** Write tabular data — chalk table in human mode, JSON array in json mode. */ +export function outputTable(columns: TableColumn[], rows: string[][], rawData?: unknown[]): void { + if (currentMode === 'json') { + if (rawData) { + console.log(JSON.stringify(rawData)); + } else { + const headers = columns.map((c) => c.header); + const jsonRows = rows.map((row) => { + const obj: Record = {}; + headers.forEach((h, i) => { + obj[h] = row[i] ?? ''; + }); + return obj; + }); + console.log(JSON.stringify(jsonRows)); + } + } else { + console.log(formatTable(columns, rows)); + } +} + +/** Exit with a structured error. Writes error then exits with code 1. */ +export function exitWithError(error: { code: string; message: string; details?: unknown }): never { + outputError(error); + process.exit(1); +} From c32e5fe6a9c50422619015072543ce42ef5bdd84 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sat, 28 Feb 2026 13:18:05 -0600 Subject: [PATCH 02/11] fix: run format:check in ci --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8c79e9..da23dee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: run: pnpm lint - name: Format - run: pnpm format + run: pnpm format:check - name: Typecheck run: pnpm typecheck From c8d65c107bc04828768cbcd5f9b9ae73e5f4a74a Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sat, 28 Feb 2026 13:22:05 -0600 Subject: [PATCH 03/11] feat: add non-TTY support for management commands, help, and headless installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 — Management commands JSON output: - env/org/user commands produce structured JSON in non-TTY mode - Non-interactive guards for env add (requires args) and env switch (requires name) - Empty list states return valid JSON ({ data: [] }) Phase 3 — Agent-discoverable help: - --help --json outputs machine-readable command tree with types, defaults, and positional schemas - Subcommand scoping (e.g. workos env --help --json) - Improved all command descriptions for agent readability Phase 4 — Headless installer with NDJSON streaming: - HeadlessAdapter auto-resolves all interactive decisions with sensible defaults - Streams progress as NDJSON events to stdout - Flag overrides: --no-branch, --no-commit, --create-pr, --no-git-check - --api-key and --client-id now visible flags - Removed non-TTY error block in install command --- src/bin.ts | 65 ++++- src/commands/env.spec.ts | 67 +++++ src/commands/env.ts | 54 ++-- src/commands/install.ts | 11 - src/commands/organization.spec.ts | 60 +++- src/commands/organization.ts | 17 +- src/commands/user.spec.ts | 60 +++- src/commands/user.ts | 14 +- src/lib/adapters/headless-adapter.spec.ts | 312 ++++++++++++++++++++ src/lib/adapters/headless-adapter.ts | 332 ++++++++++++++++++++++ src/lib/adapters/index.ts | 1 + src/lib/run-with-core.ts | 27 +- src/run.ts | 6 + src/utils/analytics.ts | 2 +- src/utils/help-json.spec.ts | 150 ++++++++++ src/utils/help-json.ts | 285 +++++++++++++++++++ src/utils/ndjson.spec.ts | 72 +++++ src/utils/ndjson.ts | 24 ++ src/utils/telemetry-types.ts | 2 +- src/utils/types.ts | 15 + 20 files changed, 1505 insertions(+), 71 deletions(-) create mode 100644 src/lib/adapters/headless-adapter.spec.ts create mode 100644 src/lib/adapters/headless-adapter.ts create mode 100644 src/utils/help-json.spec.ts create mode 100644 src/utils/help-json.ts create mode 100644 src/utils/ndjson.spec.ts create mode 100644 src/utils/ndjson.ts diff --git a/src/bin.ts b/src/bin.ts index ce1d0b1..e8f23dd 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -28,7 +28,7 @@ if (!satisfies(process.version, NODE_VERSION_RANGE)) { } import { isNonInteractiveEnvironment } from './utils/environment.js'; -import { resolveOutputMode, setOutputMode } from './utils/output.js'; +import { resolveOutputMode, setOutputMode, outputJson, exitWithError } from './utils/output.js'; import clack from './utils/clack.js'; // Resolve output mode early from raw argv (before yargs parses) @@ -36,6 +36,14 @@ const rawArgs = hideBin(process.argv); const hasJsonFlag = rawArgs.includes('--json'); setOutputMode(resolveOutputMode(hasJsonFlag)); +// Intercept --help --json before yargs parses (yargs exits on --help) +if (hasJsonFlag && (rawArgs.includes('--help') || rawArgs.includes('-h'))) { + const { buildCommandTree } = await import('./utils/help-json.js'); + const command = rawArgs.find((a) => !a.startsWith('-')); + outputJson(buildCommandTree(command)); + process.exit(0); +} + /** Apply insecure storage flag if set */ async function applyInsecureStorage(insecureStorage?: boolean): Promise { if (insecureStorage) { @@ -100,11 +108,11 @@ const installerOptions = { }, 'api-key': { type: 'string' as const, - hidden: true, + describe: 'WorkOS API key (required in non-interactive mode)', }, 'client-id': { type: 'string' as const, - hidden: true, + describe: 'WorkOS client ID (required in non-interactive mode)', }, inspect: { default: false, @@ -144,6 +152,26 @@ const installerOptions = { describe: 'Run with visual dashboard mode', type: 'boolean' as const, }, + 'no-branch': { + default: false, + describe: 'Skip branch creation (use current branch)', + type: 'boolean' as const, + }, + 'no-commit': { + default: false, + describe: 'Skip auto-commit after installation', + type: 'boolean' as const, + }, + 'create-pr': { + default: false, + describe: 'Auto-create pull request after installation', + type: 'boolean' as const, + }, + 'no-git-check': { + default: false, + describe: 'Skip git dirty working tree check', + type: 'boolean' as const, + }, }; // Check for updates (blocks up to 500ms) @@ -157,20 +185,20 @@ yargs(hideBin(process.argv)) describe: 'Output results as JSON (auto-enabled in non-TTY)', global: true, }) - .command('login', 'Authenticate with WorkOS', insecureStorageOption, async (argv) => { + .command('login', 'Authenticate with WorkOS via browser-based OAuth', insecureStorageOption, async (argv) => { await applyInsecureStorage(argv.insecureStorage); const { runLogin } = await import('./commands/login.js'); await runLogin(); process.exit(0); }) - .command('logout', 'Remove stored credentials', insecureStorageOption, async (argv) => { + .command('logout', 'Remove stored WorkOS credentials and tokens', insecureStorageOption, async (argv) => { await applyInsecureStorage(argv.insecureStorage); const { runLogout } = await import('./commands/logout.js'); await runLogout(); }) .command( 'install-skill', - 'Install bundled AuthKit skills to coding agents', + 'Install bundled AuthKit skills to coding agents (Claude Code, Codex, Cursor, Goose)', (yargs) => { return yargs .option('list', { @@ -202,7 +230,7 @@ yargs(hideBin(process.argv)) ) .command( 'doctor', - 'Diagnose WorkOS integration issues', + 'Diagnose WorkOS AuthKit integration issues in the current project', (yargs) => yargs.options({ verbose: { @@ -241,7 +269,8 @@ yargs(hideBin(process.argv)) await handleDoctor(argv); }, ) - .command('env', 'Manage environment configurations', (yargs) => + // NOTE: When adding commands here, also update src/utils/help-json.ts + .command('env', 'Manage environment configurations (API keys, endpoints, active environment)', (yargs) => yargs .options(insecureStorageOption) .command( @@ -279,6 +308,12 @@ yargs(hideBin(process.argv)) 'Switch active environment', (yargs) => yargs.positional('name', { type: 'string', describe: 'Environment name' }), async (argv) => { + if (!argv.name && isNonInteractiveEnvironment()) { + exitWithError({ + code: 'missing_args', + message: 'Environment name required. Usage: workos env switch ', + }); + } await applyInsecureStorage(argv.insecureStorage); const { runEnvSwitch } = await import('./commands/env.js'); await runEnvSwitch(argv.name); @@ -297,19 +332,19 @@ yargs(hideBin(process.argv)) .demandCommand(1, 'Please specify an env subcommand') .strict(), ) - .command('organization', 'Manage organizations', (yargs) => + .command('organization', 'Manage WorkOS organizations (create, update, get, list, delete)', (yargs) => yargs .options({ ...insecureStorageOption, - 'api-key': { type: 'string' as const, describe: 'WorkOS API key (overrides environment config)' }, + 'api-key': { type: 'string' as const, describe: 'WorkOS API key (overrides environment config). Format: sk_live_* or sk_test_*' }, }) .command( 'create [domains..]', - 'Create a new organization', + 'Create a new organization with optional verified domains', (yargs) => yargs .positional('name', { type: 'string', demandOption: true, describe: 'Organization name' }) - .positional('domains', { type: 'string', array: true, describe: 'Domains as domain:state' }), + .positional('domains', { type: 'string', array: true, describe: 'Domains in format domain:state (state defaults to verified)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); @@ -385,11 +420,11 @@ yargs(hideBin(process.argv)) .demandCommand(1, 'Please specify an organization subcommand') .strict(), ) - .command('user', 'Manage users', (yargs) => + .command('user', 'Manage WorkOS users (get, list, update, delete)', (yargs) => yargs .options({ ...insecureStorageOption, - 'api-key': { type: 'string' as const, describe: 'WorkOS API key (overrides environment config)' }, + 'api-key': { type: 'string' as const, describe: 'WorkOS API key (overrides environment config). Format: sk_live_* or sk_test_*' }, }) .command( 'get ', @@ -477,7 +512,7 @@ yargs(hideBin(process.argv)) ) .command( 'install', - 'Install WorkOS AuthKit into your project', + 'Install WorkOS AuthKit into your project (interactive framework detection and setup)', (yargs) => yargs.options(installerOptions), withAuth(async (argv) => { const { handleInstall } = await import('./commands/install.js'); diff --git a/src/commands/env.spec.ts b/src/commands/env.spec.ts index b64f138..fcb8f31 100644 --- a/src/commands/env.spec.ts +++ b/src/commands/env.spec.ts @@ -40,6 +40,7 @@ vi.mock('node:os', async (importOriginal) => { const { getConfig, setInsecureConfigStorage, clearConfig } = await import('../lib/config-store.js'); const { runEnvAdd, runEnvRemove, runEnvSwitch, runEnvList } = await import('./env.js'); +const { setOutputMode } = await import('../utils/output.js'); const clack = (await import('../utils/clack.js')).default; // Spy on process.exit @@ -160,4 +161,70 @@ describe('env commands', () => { await expect(runEnvList()).resolves.not.toThrow(); }); }); + + describe('JSON output mode', () => { + let consoleOutput: string[]; + + beforeEach(() => { + setOutputMode('json'); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + setOutputMode('human'); + }); + + it('runEnvAdd outputs JSON success', async () => { + await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' }); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Environment added'); + expect(output.name).toBe('prod'); + expect(output.type).toBe('production'); + expect(output.active).toBe(true); + }); + + it('runEnvRemove outputs JSON success', async () => { + await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' }); + consoleOutput = []; + await runEnvRemove('prod'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Environment removed'); + expect(output.name).toBe('prod'); + }); + + it('runEnvSwitch outputs JSON success', async () => { + await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' }); + await runEnvAdd({ name: 'sandbox', apiKey: 'sk_test_abc' }); + consoleOutput = []; + await runEnvSwitch('sandbox'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Switched environment'); + expect(output.name).toBe('sandbox'); + }); + + it('runEnvList outputs JSON with data array', async () => { + await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' }); + await runEnvAdd({ name: 'sandbox', apiKey: 'sk_test_abc' }); + consoleOutput = []; + await runEnvList(); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toHaveLength(2); + expect(output.data[0].name).toBe('prod'); + expect(output.data[0].active).toBe(true); + expect(output.data[1].name).toBe('sandbox'); + expect(output.data[1].active).toBe(false); + }); + + it('runEnvList outputs empty data array when no environments', async () => { + await runEnvList(); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toEqual([]); + }); + }); }); diff --git a/src/commands/env.ts b/src/commands/env.ts index 85e6d68..fa20df6 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -2,6 +2,8 @@ import chalk from 'chalk'; import clack from '../utils/clack.js'; import { getConfig, saveConfig } from '../lib/config-store.js'; import type { CliConfig } from '../lib/config-store.js'; +import { outputSuccess, outputJson, exitWithError, isJsonMode } from '../utils/output.js'; +import { isNonInteractiveEnvironment } from '../utils/environment.js'; const ENV_NAME_REGEX = /^[a-z0-9\-_]+$/; @@ -29,9 +31,10 @@ export async function runEnvAdd(options: { // Non-interactive mode const nameError = validateEnvName(name); if (nameError) { - clack.log.error(nameError); - process.exit(1); + exitWithError({ code: 'invalid_args', message: nameError }); } + } else if (isNonInteractiveEnvironment()) { + exitWithError({ code: 'missing_args', message: 'Name and API key required in non-interactive mode' }); } else { // Interactive mode const nameResult = await clack.text({ @@ -71,7 +74,6 @@ export async function runEnvAdd(options: { ...(endpoint && { endpoint }), }; - // Auto-set active environment if it's the first one if (isFirst) { config.activeEnvironment = name; } @@ -88,7 +90,6 @@ export async function runEnvAdd(options: { const config = getOrCreateConfig(); const isFirst = Object.keys(config.environments).length === 0; - // Detect type from API key prefix const type: 'production' | 'sandbox' = apiKey.startsWith('sk_test_') ? 'sandbox' : 'production'; config.environments[name!] = { @@ -104,55 +105,47 @@ export async function runEnvAdd(options: { } saveConfig(config); - clack.log.success(`Environment ${chalk.bold(name)} added`); - if (isFirst) { - clack.log.info(`Set as active environment`); - } + outputSuccess('Environment added', { name: name!, type, active: isFirst }); } export async function runEnvRemove(name: string): Promise { const config = getConfig(); if (!config || Object.keys(config.environments).length === 0) { - clack.log.error('No environments configured. Run `workos env add` to get started.'); - process.exit(1); + exitWithError({ code: 'no_environments', message: 'No environments configured. Run `workos env add` to get started.' }); } if (!config.environments[name]) { const available = Object.keys(config.environments).join(', '); - clack.log.error(`Environment "${name}" not found. Available: ${available}`); - process.exit(1); + exitWithError({ code: 'not_found', message: `Environment "${name}" not found. Available: ${available}` }); } delete config.environments[name]; - // Clear active environment if it was the removed one if (config.activeEnvironment === name) { const remaining = Object.keys(config.environments); config.activeEnvironment = remaining.length > 0 ? remaining[0] : undefined; - if (config.activeEnvironment) { + if (config.activeEnvironment && !isJsonMode()) { clack.log.info(`Active environment switched to ${chalk.bold(config.activeEnvironment)}`); } } saveConfig(config); - clack.log.success(`Environment ${chalk.bold(name)} removed`); + outputSuccess('Environment removed', { name, newActive: config.activeEnvironment ?? null }); } export async function runEnvSwitch(name?: string): Promise { const config = getConfig(); if (!config || Object.keys(config.environments).length === 0) { - clack.log.error('No environments configured. Run `workos env add` to get started.'); - process.exit(1); + exitWithError({ code: 'no_environments', message: 'No environments configured. Run `workos env add` to get started.' }); } if (name) { if (!config.environments[name]) { const available = Object.keys(config.environments).join(', '); - clack.log.error(`Environment "${name}" not found. Available: ${available}`); - process.exit(1); + exitWithError({ code: 'not_found', message: `Environment "${name}" not found. Available: ${available}` }); } } else { - // Interactive selection + // Interactive selection (TTY only — non-TTY guard is in bin.ts) const options = Object.entries(config.environments).map(([key, env]) => { let label = key; if (env.type === 'sandbox') label += ` [Sandbox]`; @@ -173,21 +166,32 @@ export async function runEnvSwitch(name?: string): Promise { saveConfig(config); const env = config.environments[name]; - let label = chalk.bold(name); - if (env.type === 'sandbox') label += ` [Sandbox]`; - if (env.endpoint) label += ` [${env.endpoint}]`; - clack.log.success(`Switched to environment ${label}`); + outputSuccess('Switched environment', { name, type: env.type }); } export async function runEnvList(): Promise { const config = getConfig(); if (!config || Object.keys(config.environments).length === 0) { - clack.log.info('No environments configured. Run `workos env add` to get started.'); + if (isJsonMode()) { + outputJson({ data: [] }); + } else { + clack.log.info('No environments configured. Run `workos env add` to get started.'); + } return; } const entries = Object.entries(config.environments); + if (isJsonMode()) { + const data = entries.map(([key, env]) => ({ + ...env, + active: key === config.activeEnvironment, + })); + outputJson({ data }); + return; + } + + // Human-mode table const nameW = Math.max(6, ...entries.map(([k]) => k.length)) + 2; const typeW = 12; diff --git a/src/commands/install.ts b/src/commands/install.ts index 4cd15b2..348dd8d 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -1,6 +1,5 @@ import { runInstaller } from '../run.js'; import type { InstallerArgs } from '../run.js'; -import { isNonInteractiveEnvironment } from '../utils/environment.js'; import clack from '../utils/clack.js'; import chalk from 'chalk'; import type { ArgumentsCamelCase } from 'yargs'; @@ -28,16 +27,6 @@ export async function handleInstall(argv: ArgumentsCamelCase): Pr clack.log.error('CI mode requires --install-dir (directory to install WorkOS AuthKit in)'); process.exit(1); } - } else if (isNonInteractiveEnvironment()) { - clack.intro(chalk.inverse('WorkOS AuthKit Installer')); - clack.log.error( - 'This installer requires an interactive terminal (TTY) to run.\n' + - 'It appears you are running in a non-interactive environment.\n' + - 'Please run the installer in an interactive terminal.\n\n' + - 'For CI/CD environments, use --ci mode:\n' + - ' workos install --ci --api-key sk_xxx --client-id client_xxx', - ); - process.exit(1); } try { diff --git a/src/commands/organization.spec.ts b/src/commands/organization.spec.ts index 858b9e0..803518d 100644 --- a/src/commands/organization.spec.ts +++ b/src/commands/organization.spec.ts @@ -18,6 +18,7 @@ vi.mock('../lib/workos-api.js', () => ({ const { workosRequest } = await import('../lib/workos-api.js'); const mockRequest = vi.mocked(workosRequest); +const { setOutputMode } = await import('../utils/output.js'); const { runOrgCreate, runOrgUpdate, runOrgGet, runOrgList, runOrgDelete, parseDomainArgs } = await import('./organization.js'); @@ -181,7 +182,64 @@ describe('organization commands', () => { expect(mockRequest).toHaveBeenCalledWith( expect.objectContaining({ method: 'DELETE', path: '/organizations/org_123' }), ); - expect(consoleOutput.some((l) => l.includes('Deleted') && l.includes('org_123'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('org_123'))).toBe(true); + }); + }); + + describe('JSON output mode', () => { + beforeEach(() => { + setOutputMode('json'); + }); + + afterEach(() => { + setOutputMode('human'); + }); + + it('runOrgCreate outputs JSON success', async () => { + mockRequest.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); + await runOrgCreate('Test', [], 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Created organization'); + expect(output.id).toBe('org_123'); + }); + + it('runOrgGet outputs raw JSON', async () => { + mockRequest.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); + await runOrgGet('org_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.id).toBe('org_123'); + expect(output.name).toBe('Test'); + expect(output).not.toHaveProperty('status'); + }); + + it('runOrgList outputs JSON with data and list_metadata', async () => { + mockRequest.mockResolvedValue({ + data: [{ id: 'org_123', name: 'FooCorp', domains: [] }], + list_metadata: { before: null, after: 'cursor_a' }, + }); + await runOrgList({}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toHaveLength(1); + expect(output.data[0].id).toBe('org_123'); + expect(output.list_metadata.after).toBe('cursor_a'); + }); + + it('runOrgList outputs empty data array for no results', async () => { + mockRequest.mockResolvedValue({ data: [], list_metadata: { before: null, after: null } }); + await runOrgList({}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toEqual([]); + expect(output.list_metadata).toBeDefined(); + }); + + it('runOrgDelete outputs JSON success', async () => { + mockRequest.mockResolvedValue(null); + await runOrgDelete('org_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.id).toBe('org_123'); }); }); }); diff --git a/src/commands/organization.ts b/src/commands/organization.ts index a0e6466..7f4255a 100644 --- a/src/commands/organization.ts +++ b/src/commands/organization.ts @@ -2,7 +2,7 @@ import chalk from 'chalk'; import { workosRequest, WorkOSApiError } from '../lib/workos-api.js'; import type { WorkOSListResponse } from '../lib/workos-api.js'; import { formatTable } from '../utils/table.js'; -import { exitWithError } from '../utils/output.js'; +import { exitWithError, outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; interface OrganizationDomain { id: string; @@ -74,8 +74,7 @@ export async function runOrgCreate( baseUrl, body, }); - console.log(chalk.green('Created organization')); - console.log(JSON.stringify(org, null, 2)); + outputSuccess('Created organization', org as unknown as Record); } catch (error) { handleApiError(error); } @@ -102,8 +101,7 @@ export async function runOrgUpdate( baseUrl, body, }); - console.log(chalk.green('Updated organization')); - console.log(JSON.stringify(org, null, 2)); + outputSuccess('Updated organization', org as unknown as Record); } catch (error) { handleApiError(error); } @@ -117,7 +115,7 @@ export async function runOrgGet(orgId: string, apiKey: string, baseUrl?: string) apiKey, baseUrl, }); - console.log(JSON.stringify(org, null, 2)); + outputJson(org); } catch (error) { handleApiError(error); } @@ -147,6 +145,11 @@ export async function runOrgList(options: OrgListOptions, apiKey: string, baseUr }, }); + if (isJsonMode()) { + outputJson({ data: result.data, list_metadata: result.list_metadata }); + return; + } + if (result.data.length === 0) { console.log('No organizations found.'); return; @@ -181,7 +184,7 @@ export async function runOrgDelete(orgId: string, apiKey: string, baseUrl?: stri apiKey, baseUrl, }); - console.log(chalk.green(`Deleted organization ${orgId}`)); + outputSuccess('Deleted organization', { id: orgId }); } catch (error) { handleApiError(error); } diff --git a/src/commands/user.spec.ts b/src/commands/user.spec.ts index 24fecb4..9f4d649 100644 --- a/src/commands/user.spec.ts +++ b/src/commands/user.spec.ts @@ -17,6 +17,7 @@ vi.mock('../lib/workos-api.js', () => ({ const { workosRequest } = await import('../lib/workos-api.js'); const mockRequest = vi.mocked(workosRequest); +const { setOutputMode } = await import('../utils/output.js'); const { runUserGet, runUserList, runUserUpdate, runUserDelete } = await import('./user.js'); @@ -111,7 +112,64 @@ describe('user commands', () => { expect(mockRequest).toHaveBeenCalledWith( expect.objectContaining({ method: 'DELETE', path: '/user_management/users/user_123' }), ); - expect(consoleOutput.some((l) => l.includes('Deleted') && l.includes('user_123'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('user_123'))).toBe(true); + }); + }); + + describe('JSON output mode', () => { + beforeEach(() => { + setOutputMode('json'); + }); + + afterEach(() => { + setOutputMode('human'); + }); + + it('runUserGet outputs raw JSON', async () => { + mockRequest.mockResolvedValue({ id: 'user_123', email: 'test@example.com' }); + await runUserGet('user_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.id).toBe('user_123'); + expect(output.email).toBe('test@example.com'); + expect(output).not.toHaveProperty('status'); + }); + + it('runUserList outputs JSON with data and list_metadata', async () => { + mockRequest.mockResolvedValue({ + data: [{ id: 'user_123', email: 'test@example.com', first_name: 'Test', last_name: 'User', email_verified: true }], + list_metadata: { before: null, after: 'cursor_a' }, + }); + await runUserList({}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toHaveLength(1); + expect(output.data[0].email).toBe('test@example.com'); + expect(output.list_metadata.after).toBe('cursor_a'); + }); + + it('runUserList outputs empty data array for no results', async () => { + mockRequest.mockResolvedValue({ data: [], list_metadata: { before: null, after: null } }); + await runUserList({}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toEqual([]); + expect(output.list_metadata).toBeDefined(); + }); + + it('runUserUpdate outputs JSON success', async () => { + mockRequest.mockResolvedValue({ id: 'user_123', email: 'test@example.com' }); + await runUserUpdate('user_123', 'sk_test', { firstName: 'John' }); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Updated user'); + expect(output.id).toBe('user_123'); + }); + + it('runUserDelete outputs JSON success', async () => { + mockRequest.mockResolvedValue(null); + await runUserDelete('user_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.id).toBe('user_123'); }); }); }); diff --git a/src/commands/user.ts b/src/commands/user.ts index cacb02e..86028a5 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -2,7 +2,7 @@ import chalk from 'chalk'; import { workosRequest, WorkOSApiError } from '../lib/workos-api.js'; import type { WorkOSListResponse } from '../lib/workos-api.js'; import { formatTable } from '../utils/table.js'; -import { exitWithError } from '../utils/output.js'; +import { exitWithError, outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; interface User { id: string; @@ -43,7 +43,7 @@ export async function runUserGet(userId: string, apiKey: string, baseUrl?: strin apiKey, baseUrl, }); - console.log(JSON.stringify(user, null, 2)); + outputJson(user); } catch (error) { handleApiError(error); } @@ -75,6 +75,11 @@ export async function runUserList(options: UserListOptions, apiKey: string, base }, }); + if (isJsonMode()) { + outputJson({ data: result.data, list_metadata: result.list_metadata }); + return; + } + if (result.data.length === 0) { console.log('No users found.'); return; @@ -143,8 +148,7 @@ export async function runUserUpdate( baseUrl, body, }); - console.log(chalk.green('Updated user')); - console.log(JSON.stringify(user, null, 2)); + outputSuccess('Updated user', user as unknown as Record); } catch (error) { handleApiError(error); } @@ -158,7 +162,7 @@ export async function runUserDelete(userId: string, apiKey: string, baseUrl?: st apiKey, baseUrl, }); - console.log(chalk.green(`Deleted user ${userId}`)); + outputSuccess('Deleted user', { id: userId }); } catch (error) { handleApiError(error); } diff --git a/src/lib/adapters/headless-adapter.spec.ts b/src/lib/adapters/headless-adapter.spec.ts new file mode 100644 index 0000000..7ff83d1 --- /dev/null +++ b/src/lib/adapters/headless-adapter.spec.ts @@ -0,0 +1,312 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { HeadlessAdapter } from './headless-adapter.js'; +import { createInstallerEventEmitter } from '../events.js'; +import type { InstallerEventEmitter } from '../events.js'; +import type { HeadlessOptions } from './headless-adapter.js'; + +// Mock ndjson writer to capture events +const mockWriteNDJSON = vi.fn(); +vi.mock('../../utils/ndjson.js', () => ({ + writeNDJSON: (...args: unknown[]) => mockWriteNDJSON(...args), +})); + +// Mock process.exit +const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + +describe('HeadlessAdapter', () => { + let emitter: InstallerEventEmitter; + let sendEvent: ReturnType; + + function createAdapter(options: HeadlessOptions = {}) { + return new HeadlessAdapter({ emitter, sendEvent, options }); + } + + beforeEach(() => { + emitter = createInstallerEventEmitter(); + sendEvent = vi.fn(); + mockWriteNDJSON.mockClear(); + mockExit.mockClear(); + }); + + afterEach(async () => { + vi.clearAllMocks(); + }); + + describe('start/stop', () => { + it('is idempotent on start', async () => { + const adapter = createAdapter(); + await adapter.start(); + await adapter.start(); // no-op + + emitter.emit('auth:success', {}); + expect(mockWriteNDJSON).toHaveBeenCalledTimes(1); + await adapter.stop(); + }); + + it('is idempotent on stop', async () => { + const adapter = createAdapter(); + await adapter.start(); + await adapter.stop(); + await adapter.stop(); // no-op — should not throw + }); + + it('unsubscribes from events on stop', async () => { + const adapter = createAdapter(); + await adapter.start(); + await adapter.stop(); + + mockWriteNDJSON.mockClear(); + emitter.emit('auth:success', {}); + expect(mockWriteNDJSON).not.toHaveBeenCalled(); + }); + }); + + describe('auth events', () => { + it('writes NDJSON on auth:success', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('auth:success', {}); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'auth:success' }); + await adapter.stop(); + }); + + it('exits with code 4 on auth:failure', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('auth:failure', { message: 'Token expired' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ + type: 'auth:required', + message: 'Token expired', + }); + expect(mockExit).toHaveBeenCalledWith(4); + await adapter.stop(); + }); + }); + + describe('detection events', () => { + it('writes detection:complete', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('detection:complete', { integration: 'nextjs' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ + type: 'detection:complete', + integration: 'nextjs', + }); + await adapter.stop(); + }); + + it('writes detection:none', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('detection:none', {}); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'detection:none' }); + await adapter.stop(); + }); + }); + + describe('git:dirty auto-resolution', () => { + it('auto-confirms and continues', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('git:dirty', { files: ['package.json'] }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ + type: 'git:status', + dirty: true, + files: ['package.json'], + }); + expect(mockWriteNDJSON).toHaveBeenCalledWith({ + type: 'git:decision', + action: 'continue', + }); + expect(sendEvent).toHaveBeenCalledWith({ type: 'GIT_CONFIRMED' }); + await adapter.stop(); + }); + }); + + describe('credentials auto-resolution', () => { + it('submits credentials from flags', async () => { + const adapter = createAdapter({ apiKey: 'sk_test_123', clientId: 'client_abc' }); + await adapter.start(); + + emitter.emit('credentials:request', { requiresApiKey: true }); + + expect(sendEvent).toHaveBeenCalledWith({ + type: 'CREDENTIALS_SUBMITTED', + apiKey: 'sk_test_123', + clientId: 'client_abc', + }); + await adapter.stop(); + }); + + it('errors when clientId missing', async () => { + const adapter = createAdapter({ apiKey: 'sk_test_123' }); + await adapter.start(); + + emitter.emit('credentials:request', { requiresApiKey: false }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error', code: 'missing_credentials' }), + ); + expect(mockExit).toHaveBeenCalledWith(1); + await adapter.stop(); + }); + + it('errors when apiKey missing but required', async () => { + const adapter = createAdapter({ clientId: 'client_abc' }); + await adapter.start(); + + emitter.emit('credentials:request', { requiresApiKey: true }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error', code: 'missing_credentials' }), + ); + expect(mockExit).toHaveBeenCalledWith(1); + await adapter.stop(); + }); + + it('submits without apiKey when not required', async () => { + const adapter = createAdapter({ clientId: 'client_abc' }); + await adapter.start(); + + emitter.emit('credentials:request', { requiresApiKey: false }); + + expect(sendEvent).toHaveBeenCalledWith({ + type: 'CREDENTIALS_SUBMITTED', + apiKey: '', + clientId: 'client_abc', + }); + await adapter.stop(); + }); + + it('auto-approves env scan', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('credentials:env:prompt', { files: ['.env.local'] }); + + expect(sendEvent).toHaveBeenCalledWith({ type: 'ENV_SCAN_APPROVED' }); + await adapter.stop(); + }); + }); + + describe('branch auto-resolution', () => { + it('auto-creates branch by default', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('branch:prompt', { branch: 'main' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'branch:creating' }); + expect(sendEvent).toHaveBeenCalledWith({ type: 'BRANCH_CREATE' }); + await adapter.stop(); + }); + + it('skips branch with --no-branch flag', async () => { + const adapter = createAdapter({ noBranch: true }); + await adapter.start(); + + emitter.emit('branch:prompt', { branch: 'main' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ + type: 'branch:skipped', + reason: '--no-branch flag', + }); + expect(sendEvent).toHaveBeenCalledWith({ type: 'BRANCH_CONTINUE' }); + await adapter.stop(); + }); + }); + + describe('commit auto-resolution', () => { + it('auto-commits by default', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('postinstall:commit:prompt', {}); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'commit:auto' }); + expect(sendEvent).toHaveBeenCalledWith({ type: 'COMMIT_APPROVED' }); + await adapter.stop(); + }); + + it('skips commit with --no-commit flag', async () => { + const adapter = createAdapter({ noCommit: true }); + await adapter.start(); + + emitter.emit('postinstall:commit:prompt', {}); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ + type: 'commit:skipped', + reason: '--no-commit flag', + }); + expect(sendEvent).toHaveBeenCalledWith({ type: 'COMMIT_DECLINED' }); + await adapter.stop(); + }); + }); + + describe('PR auto-resolution', () => { + it('skips PR by default', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('postinstall:pr:prompt', {}); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ + type: 'pr:skipped', + reason: '--create-pr not set', + }); + expect(sendEvent).toHaveBeenCalledWith({ type: 'PR_DECLINED' }); + await adapter.stop(); + }); + + it('creates PR with --create-pr flag', async () => { + const adapter = createAdapter({ createPr: true }); + await adapter.start(); + + emitter.emit('postinstall:pr:prompt', {}); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'pr:creating' }); + expect(sendEvent).toHaveBeenCalledWith({ type: 'PR_APPROVED' }); + await adapter.stop(); + }); + }); + + describe('terminal events', () => { + it('writes complete event', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('complete', { success: true, summary: 'All done' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ + type: 'complete', + success: true, + summary: 'All done', + }); + await adapter.stop(); + }); + + it('writes error event', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('error', { message: 'Something broke', stack: 'stack trace' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ + type: 'error', + code: 'installer_error', + message: 'Something broke', + }); + await adapter.stop(); + }); + }); +}); diff --git a/src/lib/adapters/headless-adapter.ts b/src/lib/adapters/headless-adapter.ts new file mode 100644 index 0000000..bf10e07 --- /dev/null +++ b/src/lib/adapters/headless-adapter.ts @@ -0,0 +1,332 @@ +import type { InstallerAdapter, AdapterConfig } from './types.js'; +import type { InstallerEventEmitter, InstallerEvents } from '../events.js'; +import { writeNDJSON } from '../../utils/ndjson.js'; +import { ExitCode } from '../../utils/exit-codes.js'; + +/** + * Options controlling headless adapter behavior. + * Corresponds to CLI flags passed in non-interactive mode. + */ +export interface HeadlessOptions { + apiKey?: string; + clientId?: string; + noBranch?: boolean; + noCommit?: boolean; + createPr?: boolean; + noGitCheck?: boolean; +} + +/** + * Non-interactive adapter for CI/CD and agent consumption. + * + * Subscribes to the same installer events as CLIAdapter but never prompts. + * All decisions are auto-resolved with sensible defaults (overridable via flags). + * Progress is streamed as NDJSON to stdout. + */ +export class HeadlessAdapter implements InstallerAdapter { + readonly emitter: InstallerEventEmitter; + private sendEvent: AdapterConfig['sendEvent']; + private debug: boolean; + private options: HeadlessOptions; + private isStarted = false; + private handlers = new Map void>(); + + constructor(config: AdapterConfig & { options: HeadlessOptions }) { + this.emitter = config.emitter; + this.sendEvent = config.sendEvent; + this.debug = config.debug ?? false; + this.options = config.options; + } + + async start(): Promise { + if (this.isStarted) return; + this.isStarted = true; + + // Auth events + this.subscribe('auth:success', this.handleAuthSuccess); + this.subscribe('auth:failure', this.handleAuthFailure); + + // Detection events + this.subscribe('detection:complete', this.handleDetectionComplete); + this.subscribe('detection:none', this.handleDetectionNone); + + // Git events — auto-resolve + this.subscribe('git:dirty', this.handleGitDirty); + + // Credential events — auto-resolve + this.subscribe('credentials:found', this.handleCredentialsFound); + this.subscribe('credentials:request', this.handleCredentialsRequest); + this.subscribe('credentials:env:prompt', this.handleEnvScanPrompt); + this.subscribe('credentials:env:found', this.handleEnvCredentialsFound); + + // Device auth (should not happen in headless, but log if it does) + this.subscribe('device:started', this.handleDeviceStarted); + + // Staging + this.subscribe('staging:fetching', this.handleStagingFetching); + this.subscribe('staging:success', this.handleStagingSuccess); + + // Config + this.subscribe('config:complete', this.handleConfigComplete); + + // Agent progress + this.subscribe('agent:start', this.handleAgentStart); + this.subscribe('agent:progress', this.handleAgentProgress); + + // Validation + this.subscribe('validation:start', this.handleValidationStart); + this.subscribe('validation:issues', this.handleValidationIssues); + this.subscribe('validation:complete', this.handleValidationComplete); + + // Branch — auto-resolve + this.subscribe('branch:prompt', this.handleBranchPrompt); + this.subscribe('branch:created', this.handleBranchCreated); + + // Post-install — auto-resolve + this.subscribe('postinstall:changes', this.handlePostInstallChanges); + this.subscribe('postinstall:commit:prompt', this.handleCommitPrompt); + this.subscribe('postinstall:commit:success', this.handleCommitSuccess); + this.subscribe('postinstall:commit:failed', this.handleCommitFailed); + this.subscribe('postinstall:pr:prompt', this.handlePrPrompt); + this.subscribe('postinstall:pr:success', this.handlePrSuccess); + this.subscribe('postinstall:pr:failed', this.handlePrFailed); + this.subscribe('postinstall:push:failed', this.handlePushFailed); + this.subscribe('postinstall:manual', this.handleManualInstructions); + + // Terminal events + this.subscribe('complete', this.handleComplete); + this.subscribe('error', this.handleError); + } + + async stop(): Promise { + if (!this.isStarted) return; + + for (const [event, handler] of this.handlers) { + this.emitter.off(event as keyof InstallerEvents, handler as never); + } + this.handlers.clear(); + this.isStarted = false; + } + + // ===== Helpers ===== + + private subscribe( + event: K, + handler: (payload: InstallerEvents[K]) => void | Promise, + ): void { + const boundHandler = handler.bind(this); + this.handlers.set(event, boundHandler as (...args: unknown[]) => void); + this.emitter.on(event, boundHandler); + } + + private debugLog(message: string): void { + if (this.debug) { + writeNDJSON({ type: 'debug', message }); + } + } + + // ===== Auth Handlers ===== + + private handleAuthSuccess = (): void => { + writeNDJSON({ type: 'auth:success' }); + }; + + private handleAuthFailure = ({ message }: InstallerEvents['auth:failure']): void => { + writeNDJSON({ type: 'auth:required', message }); + process.exit(ExitCode.AUTH_REQUIRED); + }; + + // ===== Detection Handlers ===== + + private handleDetectionComplete = ({ integration }: InstallerEvents['detection:complete']): void => { + writeNDJSON({ type: 'detection:complete', integration }); + }; + + private handleDetectionNone = (): void => { + writeNDJSON({ type: 'detection:none' }); + }; + + // ===== Git Handlers (auto-resolve) ===== + + private handleGitDirty = ({ files }: InstallerEvents['git:dirty']): void => { + writeNDJSON({ type: 'git:status', dirty: true, files }); + writeNDJSON({ type: 'git:decision', action: 'continue' }); + this.sendEvent({ type: 'GIT_CONFIRMED' }); + }; + + // ===== Credential Handlers (auto-resolve) ===== + + private handleCredentialsFound = (): void => { + writeNDJSON({ type: 'credentials:found', source: 'env' }); + }; + + private handleCredentialsRequest = ({ requiresApiKey }: InstallerEvents['credentials:request']): void => { + if (!this.options.clientId) { + writeNDJSON({ + type: 'error', + code: 'missing_credentials', + message: 'Client ID required in non-interactive mode. Pass --client-id flag.', + }); + process.exit(ExitCode.GENERAL_ERROR); + } + + if (requiresApiKey && !this.options.apiKey) { + writeNDJSON({ + type: 'error', + code: 'missing_credentials', + message: 'API key required for this framework. Pass --api-key flag.', + }); + process.exit(ExitCode.GENERAL_ERROR); + } + + writeNDJSON({ type: 'credentials:provided', source: 'flag' }); + this.sendEvent({ + type: 'CREDENTIALS_SUBMITTED', + apiKey: this.options.apiKey ?? '', + clientId: this.options.clientId, + }); + }; + + private handleEnvScanPrompt = (): void => { + writeNDJSON({ type: 'credentials:env:scanning' }); + this.sendEvent({ type: 'ENV_SCAN_APPROVED' }); + }; + + private handleEnvCredentialsFound = ({ sourcePath }: InstallerEvents['credentials:env:found']): void => { + writeNDJSON({ type: 'credentials:found', source: 'env', sourcePath }); + }; + + // ===== Device Auth (should not occur in headless) ===== + + private handleDeviceStarted = ({ + verificationUri, + userCode, + }: InstallerEvents['device:started']): void => { + writeNDJSON({ + type: 'auth:device_required', + verificationUri, + userCode, + message: 'Device auth cannot proceed in non-interactive mode', + }); + }; + + // ===== Staging ===== + + private handleStagingFetching = (): void => { + writeNDJSON({ type: 'staging:fetching' }); + }; + + private handleStagingSuccess = (): void => { + writeNDJSON({ type: 'staging:success' }); + }; + + // ===== Config ===== + + private handleConfigComplete = (): void => { + writeNDJSON({ type: 'config:complete' }); + }; + + // ===== Agent Progress ===== + + private handleAgentStart = (): void => { + writeNDJSON({ type: 'agent:start' }); + }; + + private handleAgentProgress = ({ step, detail }: InstallerEvents['agent:progress']): void => { + const message = detail ? `${step}: ${detail}` : step; + writeNDJSON({ type: 'agent:progress', message }); + }; + + // ===== Validation ===== + + private handleValidationStart = ({ framework }: InstallerEvents['validation:start']): void => { + writeNDJSON({ type: 'validation:start', framework }); + }; + + private handleValidationIssues = ({ issues }: InstallerEvents['validation:issues']): void => { + for (const issue of issues) { + writeNDJSON({ type: 'validation:issue', severity: issue.severity, message: issue.message }); + } + }; + + private handleValidationComplete = ({ passed, issueCount }: InstallerEvents['validation:complete']): void => { + writeNDJSON({ type: 'validation:complete', passed, issues: issueCount }); + }; + + // ===== Branch (auto-resolve) ===== + + private handleBranchPrompt = (): void => { + if (this.options.noBranch) { + writeNDJSON({ type: 'branch:skipped', reason: '--no-branch flag' }); + this.sendEvent({ type: 'BRANCH_CONTINUE' }); + } else { + writeNDJSON({ type: 'branch:creating' }); + this.sendEvent({ type: 'BRANCH_CREATE' }); + } + }; + + private handleBranchCreated = ({ branch }: InstallerEvents['branch:created']): void => { + writeNDJSON({ type: 'branch:created', name: branch }); + }; + + // ===== Post-install (auto-resolve) ===== + + private handlePostInstallChanges = ({ files }: InstallerEvents['postinstall:changes']): void => { + writeNDJSON({ type: 'postinstall:changes', files, count: files.length }); + }; + + private handleCommitPrompt = (): void => { + if (this.options.noCommit) { + writeNDJSON({ type: 'commit:skipped', reason: '--no-commit flag' }); + this.sendEvent({ type: 'COMMIT_DECLINED' }); + } else { + writeNDJSON({ type: 'commit:auto' }); + this.sendEvent({ type: 'COMMIT_APPROVED' }); + } + }; + + private handleCommitSuccess = ({ message }: InstallerEvents['postinstall:commit:success']): void => { + writeNDJSON({ type: 'commit:created', message }); + }; + + private handleCommitFailed = ({ error }: InstallerEvents['postinstall:commit:failed']): void => { + writeNDJSON({ type: 'commit:failed', error }); + }; + + private handlePrPrompt = (): void => { + if (this.options.createPr) { + writeNDJSON({ type: 'pr:creating' }); + this.sendEvent({ type: 'PR_APPROVED' }); + } else { + writeNDJSON({ type: 'pr:skipped', reason: '--create-pr not set' }); + this.sendEvent({ type: 'PR_DECLINED' }); + } + }; + + private handlePrSuccess = ({ url }: InstallerEvents['postinstall:pr:success']): void => { + writeNDJSON({ type: 'pr:created', url }); + }; + + private handlePrFailed = ({ error }: InstallerEvents['postinstall:pr:failed']): void => { + writeNDJSON({ type: 'pr:failed', error }); + }; + + private handlePushFailed = ({ error }: InstallerEvents['postinstall:push:failed']): void => { + writeNDJSON({ type: 'push:failed', error }); + }; + + private handleManualInstructions = ({ instructions }: InstallerEvents['postinstall:manual']): void => { + writeNDJSON({ type: 'postinstall:manual', instructions }); + }; + + // ===== Terminal Events ===== + + private handleComplete = ({ success, summary }: InstallerEvents['complete']): void => { + writeNDJSON({ type: 'complete', success, summary }); + }; + + private handleError = ({ message, stack }: InstallerEvents['error']): void => { + writeNDJSON({ type: 'error', code: 'installer_error', message }); + this.debugLog(stack ?? ''); + }; +} diff --git a/src/lib/adapters/index.ts b/src/lib/adapters/index.ts index 3b4621a..e88217d 100644 --- a/src/lib/adapters/index.ts +++ b/src/lib/adapters/index.ts @@ -1,3 +1,4 @@ export { CLIAdapter } from './cli-adapter.js'; export { DashboardAdapter } from './dashboard-adapter.js'; +export { HeadlessAdapter } from './headless-adapter.js'; export type { InstallerAdapter, AdapterConfig } from './types.js'; diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 057ebe2..c0cf0ce 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -6,8 +6,10 @@ import { installerMachine } from './installer-core.js'; import { createInstallerEventEmitter } from './events.js'; import { CLIAdapter } from './adapters/cli-adapter.js'; import { DashboardAdapter } from './adapters/dashboard-adapter.js'; +import { HeadlessAdapter } from './adapters/headless-adapter.js'; import type { InstallerAdapter } from './adapters/types.js'; import type { InstallerOptions } from '../utils/types.js'; +import { isNonInteractiveEnvironment } from '../utils/environment.js'; import type { InstallerMachineContext, DetectionOutput, @@ -193,9 +195,26 @@ export async function runWithCore(options: InstallerOptions): Promise { } }; - const adapter: InstallerAdapter = options.dashboard - ? new DashboardAdapter({ emitter, sendEvent, debug: augmentedOptions.debug }) - : new CLIAdapter({ emitter, sendEvent, debug: augmentedOptions.debug }); + let adapter: InstallerAdapter; + if (isNonInteractiveEnvironment()) { + adapter = new HeadlessAdapter({ + emitter, + sendEvent, + debug: augmentedOptions.debug, + options: { + apiKey: augmentedOptions.apiKey, + clientId: augmentedOptions.clientId, + noBranch: augmentedOptions.noBranch, + noCommit: augmentedOptions.noCommit, + createPr: augmentedOptions.createPr, + noGitCheck: augmentedOptions.noGitCheck, + }, + }); + } else if (options.dashboard) { + adapter = new DashboardAdapter({ emitter, sendEvent, debug: augmentedOptions.debug }); + } else { + adapter = new CLIAdapter({ emitter, sendEvent, debug: augmentedOptions.debug }); + } const machineWithActors = installerMachine.provide({ actors: { @@ -467,7 +486,7 @@ export async function runWithCore(options: InstallerOptions): Promise { await adapter.start(); // Start telemetry session - const mode = augmentedOptions.dashboard ? 'tui' : 'cli'; + const mode = isNonInteractiveEnvironment() ? 'headless' : augmentedOptions.dashboard ? 'tui' : 'cli'; analytics.sessionStart(mode, getVersion()); let installerStatus: 'success' | 'error' | 'cancelled' = 'success'; diff --git a/src/run.ts b/src/run.ts index 4d5cebd..cd8487e 100644 --- a/src/run.ts +++ b/src/run.ts @@ -25,6 +25,9 @@ export type InstallerArgs = { inspect?: boolean; noValidate?: boolean; noCommit?: boolean; + noBranch?: boolean; + createPr?: boolean; + noGitCheck?: boolean; direct?: boolean; }; @@ -62,6 +65,9 @@ function buildOptions(argv: InstallerArgs): InstallerOptions { inspect: merged.inspect ?? false, noValidate: merged.noValidate ?? false, noCommit: merged.noCommit ?? false, + noBranch: merged.noBranch ?? false, + createPr: merged.createPr ?? false, + noGitCheck: merged.noGitCheck ?? false, direct: merged.direct ?? false, emitter: createInstallerEventEmitter(), // Will be replaced in runWithCore }; diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index 875d9c5..c0f348e 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -71,7 +71,7 @@ export class Analytics { return undefined; } - sessionStart(mode: 'cli' | 'tui', version: string) { + sessionStart(mode: 'cli' | 'tui' | 'headless', version: string) { if (!WORKOS_TELEMETRY_ENABLED) return; const event: SessionStartEvent = { diff --git a/src/utils/help-json.spec.ts b/src/utils/help-json.spec.ts new file mode 100644 index 0000000..24d22b1 --- /dev/null +++ b/src/utils/help-json.spec.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('../lib/settings.js', () => ({ + getVersion: vi.fn(() => '0.7.3'), +})); + +const { buildCommandTree } = await import('./help-json.js'); + +describe('help-json', () => { + describe('buildCommandTree() — full tree', () => { + it('returns root with name "workos"', () => { + const tree = buildCommandTree(); + expect(tree).toHaveProperty('name', 'workos'); + }); + + it('includes version string', () => { + const tree = buildCommandTree(); + expect(tree).toHaveProperty('version', '0.7.3'); + }); + + it('includes top-level description', () => { + const tree = buildCommandTree(); + expect(tree).toHaveProperty('description'); + expect((tree as { description: string }).description.length).toBeGreaterThan(0); + }); + + it('includes all public commands', () => { + const tree = buildCommandTree(); + const names = (tree as { commands: { name: string }[] }).commands.map((c) => c.name); + expect(names).toEqual( + expect.arrayContaining(['login', 'logout', 'install-skill', 'doctor', 'env', 'organization', 'user', 'install']), + ); + }); + + it('does not include hidden dashboard command', () => { + const tree = buildCommandTree(); + const names = (tree as { commands: { name: string }[] }).commands.map((c) => c.name); + expect(names).not.toContain('dashboard'); + }); + + it('includes global options with types and defaults', () => { + const tree = buildCommandTree(); + const opts = (tree as { options: { name: string; type: string; default?: unknown }[] }).options; + const jsonOpt = opts.find((o) => o.name === 'json'); + expect(jsonOpt).toBeDefined(); + expect(jsonOpt!.type).toBe('boolean'); + expect(jsonOpt!.default).toBe(false); + }); + + it('output is valid JSON-serializable', () => { + const tree = buildCommandTree(); + const json = JSON.stringify(tree); + expect(() => JSON.parse(json)).not.toThrow(); + }); + }); + + describe('buildCommandTree() — subcommand subtrees', () => { + it('returns env subtree with subcommands', () => { + const tree = buildCommandTree('env'); + expect(tree.name).toBe('env'); + const subNames = tree.commands!.map((c) => c.name); + expect(subNames).toEqual(expect.arrayContaining(['add', 'remove', 'switch', 'list'])); + }); + + it('returns organization subtree with CRUD subcommands', () => { + const tree = buildCommandTree('organization'); + expect(tree.name).toBe('organization'); + const subNames = tree.commands!.map((c) => c.name); + expect(subNames).toEqual(expect.arrayContaining(['create', 'update', 'get', 'list', 'delete'])); + }); + + it('returns user subtree with subcommands', () => { + const tree = buildCommandTree('user'); + expect(tree.name).toBe('user'); + const subNames = tree.commands!.map((c) => c.name); + expect(subNames).toEqual(expect.arrayContaining(['get', 'list', 'update', 'delete'])); + }); + + it('returns full tree for unknown subcommand', () => { + const tree = buildCommandTree('nonexistent'); + expect(tree).toHaveProperty('name', 'workos'); + expect(tree).toHaveProperty('version'); + }); + }); + + describe('positional schemas', () => { + it('env add has optional positionals', () => { + const env = buildCommandTree('env'); + const add = env.commands!.find((c) => c.name === 'add'); + expect(add).toBeDefined(); + expect(add!.positionals).toBeDefined(); + const namePos = add!.positionals!.find((p) => p.name === 'name'); + expect(namePos).toBeDefined(); + expect(namePos!.required).toBe(false); + }); + + it('env remove has required positional', () => { + const env = buildCommandTree('env'); + const remove = env.commands!.find((c) => c.name === 'remove'); + expect(remove!.positionals![0].required).toBe(true); + }); + + it('organization create has required name positional', () => { + const org = buildCommandTree('organization'); + const create = org.commands!.find((c) => c.name === 'create'); + const namePos = create!.positionals!.find((p) => p.name === 'name'); + expect(namePos!.required).toBe(true); + }); + + it('organization delete has required orgId positional', () => { + const org = buildCommandTree('organization'); + const del = org.commands!.find((c) => c.name === 'delete'); + const orgId = del!.positionals!.find((p) => p.name === 'orgId'); + expect(orgId).toBeDefined(); + expect(orgId!.required).toBe(true); + }); + + it('user update has required userId positional', () => { + const user = buildCommandTree('user'); + const update = user.commands!.find((c) => c.name === 'update'); + const userId = update!.positionals!.find((p) => p.name === 'userId'); + expect(userId!.required).toBe(true); + }); + }); + + describe('option schemas', () => { + it('install command has direct option with alias', () => { + const install = buildCommandTree('install'); + const direct = install.options!.find((o) => o.name === 'direct'); + expect(direct).toBeDefined(); + expect(direct!.alias).toBe('D'); + expect(direct!.type).toBe('boolean'); + expect(direct!.default).toBe(false); + }); + + it('organization list has pagination options', () => { + const org = buildCommandTree('organization'); + const list = org.commands!.find((c) => c.name === 'list'); + const optNames = list!.options!.map((o) => o.name); + expect(optNames).toEqual(expect.arrayContaining(['limit', 'before', 'after', 'order'])); + }); + + it('user list has email and organization filters', () => { + const user = buildCommandTree('user'); + const list = user.commands!.find((c) => c.name === 'list'); + const optNames = list!.options!.map((o) => o.name); + expect(optNames).toEqual(expect.arrayContaining(['email', 'organization'])); + }); + }); +}); diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts new file mode 100644 index 0000000..ad2f354 --- /dev/null +++ b/src/utils/help-json.ts @@ -0,0 +1,285 @@ +/** + * Agent-discoverable help: machine-readable command tree for --help --json. + * + * Static command registry mirroring bin.ts yargs definitions. + * yargs v18 doesn't expose public APIs for command introspection, + * so we maintain a parallel typed registry. + */ + +import { getVersion } from '../lib/settings.js'; + +export interface OptionSchema { + name: string; + type: 'string' | 'boolean' | 'number' | 'array'; + description: string; + required: boolean; + default?: unknown; + alias?: string; + choices?: string[]; + hidden: boolean; +} + +export interface PositionalSchema { + name: string; + type: string; + description: string; + required: boolean; +} + +export interface CommandSchema { + name: string; + description: string; + commands?: CommandSchema[]; + options?: OptionSchema[]; + positionals?: PositionalSchema[]; + examples?: string[]; +} + +export interface HelpOutput { + name: string; + version: string; + description: string; + commands: CommandSchema[]; + options: OptionSchema[]; +} + +// --------------------------------------------------------------------------- +// Shared option fragments (mirrors bin.ts shared option objects) +// --------------------------------------------------------------------------- + +const insecureStorageOpt: OptionSchema = { + name: 'insecure-storage', + type: 'boolean', + description: 'Store credentials in plaintext file instead of system keyring', + required: false, + default: false, + hidden: false, +}; + +const apiKeyOpt: OptionSchema = { + name: 'api-key', + type: 'string', + description: 'WorkOS API key (overrides environment config). Format: sk_live_* or sk_test_*', + required: false, + hidden: false, +}; + +const paginationOpts: OptionSchema[] = [ + { name: 'limit', type: 'number', description: 'Maximum number of results to return', required: false, hidden: false }, + { name: 'before', type: 'string', description: 'Pagination cursor for results before a specific item', required: false, hidden: false }, + { name: 'after', type: 'string', description: 'Pagination cursor for results after a specific item', required: false, hidden: false }, + { name: 'order', type: 'string', description: 'Sort order (asc or desc)', required: false, choices: ['asc', 'desc'], hidden: false }, +]; + +// --------------------------------------------------------------------------- +// Command registry +// --------------------------------------------------------------------------- + +const commands: CommandSchema[] = [ + { + name: 'login', + description: 'Authenticate with WorkOS via browser-based OAuth', + options: [insecureStorageOpt], + }, + { + name: 'logout', + description: 'Remove stored WorkOS credentials and tokens', + options: [insecureStorageOpt], + }, + { + name: 'install-skill', + description: 'Install bundled AuthKit skills to coding agents (Claude Code, Codex, Cursor, Goose)', + options: [ + { name: 'list', type: 'boolean', description: 'List available skills without installing', required: false, alias: 'l', hidden: false }, + { name: 'skill', type: 'array', description: 'Install specific skill(s) by name', required: false, alias: 's', hidden: false }, + { name: 'agent', type: 'array', description: 'Target specific agent(s): claude-code, codex, cursor, goose', required: false, alias: 'a', hidden: false }, + ], + }, + { + name: 'doctor', + description: 'Diagnose WorkOS AuthKit integration issues in the current project', + options: [ + { name: 'verbose', type: 'boolean', description: 'Include additional diagnostic information', required: false, default: false, hidden: false }, + { name: 'skip-api', type: 'boolean', description: 'Skip API calls (offline mode)', required: false, default: false, hidden: false }, + { name: 'skip-ai', type: 'boolean', description: 'Skip AI-powered analysis', required: false, default: false, hidden: false }, + { name: 'install-dir', type: 'string', description: 'Project directory to analyze (defaults to cwd)', required: false, hidden: false }, + { name: 'json', type: 'boolean', description: 'Output diagnostic report as JSON', required: false, default: false, hidden: false }, + { name: 'copy', type: 'boolean', description: 'Copy diagnostic report to clipboard', required: false, default: false, hidden: false }, + ], + }, + { + name: 'env', + description: 'Manage environment configurations (API keys, endpoints, active environment)', + options: [insecureStorageOpt], + commands: [ + { + name: 'add', + description: 'Add a new environment configuration with API key and optional settings', + positionals: [ + { name: 'name', type: 'string', description: 'Environment name (lowercase, hyphens, underscores)', required: false }, + { name: 'apiKey', type: 'string', description: 'WorkOS API key (sk_live_* or sk_test_*)', required: false }, + ], + options: [ + { name: 'client-id', type: 'string', description: 'WorkOS client ID for this environment', required: false, hidden: false }, + { name: 'endpoint', type: 'string', description: 'Custom API endpoint URL', required: false, hidden: false }, + ], + }, + { + name: 'remove', + description: 'Remove an environment configuration', + positionals: [ + { name: 'name', type: 'string', description: 'Environment name to remove', required: true }, + ], + }, + { + name: 'switch', + description: 'Switch the active environment (determines which API key is used)', + positionals: [ + { name: 'name', type: 'string', description: 'Environment name to activate', required: false }, + ], + }, + { + name: 'list', + description: 'List all configured environments and show which is active', + }, + ], + }, + { + name: 'organization', + description: 'Manage WorkOS organizations (create, update, get, list, delete)', + options: [insecureStorageOpt, apiKeyOpt], + commands: [ + { + name: 'create', + description: 'Create a new organization with optional verified domains', + positionals: [ + { name: 'name', type: 'string', description: 'Organization name', required: true }, + { name: 'domains', type: 'string', description: 'Domains in format domain:state (state defaults to verified)', required: false }, + ], + }, + { + name: 'update', + description: 'Update an existing organization name or domain', + positionals: [ + { name: 'orgId', type: 'string', description: 'Organization ID (org_*)', required: true }, + { name: 'name', type: 'string', description: 'New organization name', required: true }, + { name: 'domain', type: 'string', description: 'Domain to add or update', required: false }, + { name: 'state', type: 'string', description: 'Domain state (verified or pending)', required: false }, + ], + }, + { + name: 'get', + description: 'Get an organization by its ID', + positionals: [ + { name: 'orgId', type: 'string', description: 'Organization ID (org_*)', required: true }, + ], + }, + { + name: 'list', + description: 'List organizations with optional filters and pagination', + options: [ + { name: 'domain', type: 'string', description: 'Filter organizations by domain', required: false, hidden: false }, + ...paginationOpts, + ], + }, + { + name: 'delete', + description: 'Delete an organization by its ID', + positionals: [ + { name: 'orgId', type: 'string', description: 'Organization ID (org_*)', required: true }, + ], + }, + ], + }, + { + name: 'user', + description: 'Manage WorkOS user management users (get, list, update, delete)', + options: [insecureStorageOpt, apiKeyOpt], + commands: [ + { + name: 'get', + description: 'Get a user by their ID', + positionals: [ + { name: 'userId', type: 'string', description: 'User ID (user_*)', required: true }, + ], + }, + { + name: 'list', + description: 'List users with optional filters and pagination', + options: [ + { name: 'email', type: 'string', description: 'Filter users by email address', required: false, hidden: false }, + { name: 'organization', type: 'string', description: 'Filter users by organization ID', required: false, hidden: false }, + ...paginationOpts, + ], + }, + { + name: 'update', + description: 'Update user properties (name, email verification, password, external ID)', + positionals: [ + { name: 'userId', type: 'string', description: 'User ID (user_*)', required: true }, + ], + options: [ + { name: 'first-name', type: 'string', description: 'First name', required: false, hidden: false }, + { name: 'last-name', type: 'string', description: 'Last name', required: false, hidden: false }, + { name: 'email-verified', type: 'boolean', description: 'Email verification status', required: false, hidden: false }, + { name: 'password', type: 'string', description: 'New password', required: false, hidden: false }, + { name: 'external-id', type: 'string', description: 'External ID for cross-system mapping', required: false, hidden: false }, + ], + }, + { + name: 'delete', + description: 'Delete a user by their ID', + positionals: [ + { name: 'userId', type: 'string', description: 'User ID (user_*)', required: true }, + ], + }, + ], + }, + { + name: 'install', + description: 'Install WorkOS AuthKit into your project (interactive framework detection and setup)', + options: [ + { name: 'direct', type: 'boolean', description: 'Use your own Anthropic API key (bypass llm-gateway)', required: false, default: false, alias: 'D', hidden: false }, + { name: 'debug', type: 'boolean', description: 'Enable verbose logging', required: false, default: false, hidden: false }, + insecureStorageOpt, + { name: 'homepage-url', type: 'string', description: 'App homepage URL for WorkOS (defaults to http://localhost:{port})', required: false, hidden: false }, + { name: 'redirect-uri', type: 'string', description: 'Redirect URI for WorkOS callback (defaults to framework convention)', required: false, hidden: false }, + { name: 'no-validate', type: 'boolean', description: 'Skip post-installation validation (includes build check)', required: false, default: false, hidden: false }, + { name: 'install-dir', type: 'string', description: 'Directory to install WorkOS AuthKit in (defaults to cwd)', required: false, hidden: false }, + { name: 'integration', type: 'string', description: 'Framework integration to set up (auto-detected if omitted)', required: false, hidden: false }, + { name: 'force-install', type: 'boolean', description: 'Force install packages even if peer dependency checks fail', required: false, default: false, hidden: false }, + { name: 'dashboard', type: 'boolean', description: 'Run with visual dashboard mode', required: false, default: false, alias: 'd', hidden: false }, + ], + }, +]; + +const globalOptions: OptionSchema[] = [ + { name: 'json', type: 'boolean', description: 'Output results as JSON (auto-enabled in non-TTY environments)', required: false, default: false, hidden: false }, + { name: 'help', type: 'boolean', description: 'Show help', required: false, alias: 'h', hidden: false }, + { name: 'version', type: 'boolean', description: 'Show version number', required: false, alias: 'v', hidden: false }, +]; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Build a machine-readable command tree for --help --json output. + * + * @param subcommand - Optional command name to return a subtree for (e.g. "env"). + * Returns full tree if omitted or if command not found. + */ +export function buildCommandTree(subcommand?: string): HelpOutput | CommandSchema { + if (subcommand) { + const match = commands.find((c) => c.name === subcommand); + if (match) return match; + } + + return { + name: 'workos', + version: getVersion(), + description: 'WorkOS CLI for AuthKit integration and resource management', + commands, + options: globalOptions, + }; +} diff --git a/src/utils/ndjson.spec.ts b/src/utils/ndjson.spec.ts new file mode 100644 index 0000000..9c9594a --- /dev/null +++ b/src/utils/ndjson.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { writeNDJSON } from './ndjson.js'; + +describe('writeNDJSON', () => { + let writeSpy: ReturnType; + + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-15T12:00:00.000Z')); + }); + + afterEach(() => { + writeSpy.mockRestore(); + vi.useRealTimers(); + }); + + it('writes valid JSON followed by newline', () => { + writeNDJSON({ type: 'test:event' }); + + expect(writeSpy).toHaveBeenCalledTimes(1); + const output = writeSpy.mock.calls[0][0] as string; + expect(output.endsWith('\n')).toBe(true); + + const parsed = JSON.parse(output.trim()); + expect(parsed.type).toBe('test:event'); + }); + + it('includes ISO timestamp', () => { + writeNDJSON({ type: 'test:event' }); + + const output = writeSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output.trim()); + expect(parsed.timestamp).toBe('2026-01-15T12:00:00.000Z'); + }); + + it('passes through additional payload fields', () => { + writeNDJSON({ type: 'detection:complete', integration: 'nextjs' }); + + const output = writeSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output.trim()); + expect(parsed.integration).toBe('nextjs'); + }); + + it('outputs exactly one line per call', () => { + writeNDJSON({ type: 'event1' }); + writeNDJSON({ type: 'event2' }); + + expect(writeSpy).toHaveBeenCalledTimes(2); + for (const call of writeSpy.mock.calls) { + const output = call[0] as string; + const lines = output.split('\n').filter(Boolean); + expect(lines).toHaveLength(1); + } + }); + + it('produces parseable NDJSON stream', () => { + writeNDJSON({ type: 'start' }); + writeNDJSON({ type: 'progress', step: 'installing' }); + writeNDJSON({ type: 'complete', success: true }); + + const allOutput = writeSpy.mock.calls.map((c) => c[0] as string).join(''); + const lines = allOutput.trim().split('\n'); + expect(lines).toHaveLength(3); + + for (const line of lines) { + const parsed = JSON.parse(line); + expect(parsed).toHaveProperty('type'); + expect(parsed).toHaveProperty('timestamp'); + } + }); +}); diff --git a/src/utils/ndjson.ts b/src/utils/ndjson.ts new file mode 100644 index 0000000..feed2fd --- /dev/null +++ b/src/utils/ndjson.ts @@ -0,0 +1,24 @@ +/** + * NDJSON (Newline-Delimited JSON) writer for headless mode. + * + * Each line is a self-contained JSON object with a `type` discriminator + * and an ISO-8601 `timestamp`. Consumers can parse line-by-line. + */ + +export interface NDJSONEvent { + type: string; + timestamp: string; + [key: string]: unknown; +} + +/** + * Write a single NDJSON event to stdout. + * Automatically adds an ISO timestamp. + */ +export function writeNDJSON(event: Omit): void { + const line = { + ...event, + timestamp: new Date().toISOString(), + }; + process.stdout.write(JSON.stringify(line) + '\n'); +} diff --git a/src/utils/telemetry-types.ts b/src/utils/telemetry-types.ts index a8bd57f..b9a6679 100644 --- a/src/utils/telemetry-types.ts +++ b/src/utils/telemetry-types.ts @@ -14,7 +14,7 @@ export interface SessionStartEvent extends TelemetryEvent { type: 'session.start'; attributes: { 'installer.version': string; - 'installer.mode': 'cli' | 'tui'; + 'installer.mode': 'cli' | 'tui' | 'headless'; 'workos.user_id'?: string; 'workos.org_id'?: string; }; diff --git a/src/utils/types.ts b/src/utils/types.ts index 901a05c..fbc29ad 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -86,6 +86,21 @@ export type InstallerOptions = { */ noCommit?: boolean; + /** + * Skip branch creation (continue on current branch) + */ + noBranch?: boolean; + + /** + * Auto-create pull request after installation + */ + createPr?: boolean; + + /** + * Skip git dirty working tree check + */ + noGitCheck?: boolean; + /** * Direct mode - bypass llm-gateway and use user's own Anthropic API key. * Requires ANTHROPIC_API_KEY environment variable. From 1a0d3f278482ddc1aae90206713578c40ee6892c Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sat, 28 Feb 2026 13:24:14 -0600 Subject: [PATCH 04/11] chore: remove docs/ideation from commits --- docs/ideation/non-tty/contract.md | 68 ------ docs/ideation/non-tty/spec-phase-1.md | 283 ---------------------- docs/ideation/non-tty/spec-phase-2.md | 192 --------------- docs/ideation/non-tty/spec-phase-3.md | 214 ----------------- docs/ideation/non-tty/spec-phase-4.md | 332 -------------------------- 5 files changed, 1089 deletions(-) delete mode 100644 docs/ideation/non-tty/contract.md delete mode 100644 docs/ideation/non-tty/spec-phase-1.md delete mode 100644 docs/ideation/non-tty/spec-phase-2.md delete mode 100644 docs/ideation/non-tty/spec-phase-3.md delete mode 100644 docs/ideation/non-tty/spec-phase-4.md diff --git a/docs/ideation/non-tty/contract.md b/docs/ideation/non-tty/contract.md deleted file mode 100644 index 184c3c3..0000000 --- a/docs/ideation/non-tty/contract.md +++ /dev/null @@ -1,68 +0,0 @@ -# Non-TTY Mode for WorkOS CLI - -**Created**: 2026-02-28 -**Confidence Score**: 95/100 -**Status**: Draft - -## Problem Statement - -The WorkOS CLI currently assumes a human at a terminal. 26+ interactive prompts (via clack), browser-based OAuth, and chalk-formatted tables make it unusable by coding agents like Claude Code, Codex, or Cursor. Agents must either avoid the CLI entirely or rely on users to copy-paste credentials and command output — defeating the purpose of automation. - -This is a growing problem. AI coding agents are becoming primary consumers of developer tools, and CLIs that can't operate headlessly lose relevance. The `gh` CLI solved this well: it works identically for humans and agents, with automatic non-TTY detection, structured output, and env-var auth. The WorkOS CLI should follow this proven model. - -The constraint is clear: non-TTY support must not degrade the human experience. Interactive prompts, colored output, spinners, and the TUI dashboard remain the default for humans. Agents get a parallel path that's equally capable but designed for machine consumption. - -## Goals - -1. **All management commands work non-interactively** — `env`, `organization`, `user` commands accept all required inputs via flags and produce structured output without prompts. -2. **Structured JSON output everywhere** — Auto-detect non-TTY and switch to JSON. Provide `--json` flag for explicit control. Errors go to stderr as structured JSON. -3. **Consistent exit codes** — Follow gh convention: 0=success, 1=general error, 2=cancelled, 4=auth required. Agents can branch on exit codes without parsing output. -4. **Auth works without a browser** — Agents use pre-existing credentials (from prior `workos login`) or `WORKOS_API_KEY` env var. No TTY + no credentials = exit code 4 with clear message. -5. **Headless installer mode** — The installer runs non-interactively with flags for overrides (`--api-key`, `--client-id`) and sensible auto-defaults (create branch, auto-commit, skip confirmations). -6. **Zero breaking changes for humans** — All existing interactive behavior is preserved. Non-TTY detection is automatic. Humans never see JSON unless they ask for it. - -## Success Criteria - -- [ ] Running any management command piped (`workos org list | jq .`) produces valid JSON with no ANSI escape codes -- [ ] Running `workos org list --json` in a TTY produces JSON instead of a table -- [ ] Running `workos env add prod sk_live_xxx` in non-TTY succeeds silently (exit 0) with JSON confirmation to stdout -- [ ] Running `workos install` in non-TTY with `--api-key` and `--client-id` flags completes without prompts, using auto-defaults for branch/commit -- [ ] Running any authenticated command without credentials in non-TTY exits with code 4 and a JSON error to stderr -- [ ] Running `workos install` in a TTY with no flags behaves identically to today (interactive clack prompts, colored output) -- [ ] All commands in non-TTY produce structured errors to stderr as `{ "error": { "code": "...", "message": "..." } }` -- [ ] `WORKOS_API_KEY` env var is respected as auth for commands that need it, bypassing OAuth entirely -- [ ] A `GH_PROMPT_DISABLED`-equivalent env var (`WORKOS_NO_PROMPT`) explicitly prevents all interactive prompts -- [ ] Installer in non-TTY streams NDJSON progress events to stdout (one JSON object per line) so agents can monitor real-time status -- [ ] `workos --help --json` outputs a machine-readable command tree (commands, flags, types, descriptions) -- [ ] Every subcommand's `--help` output includes complete flag documentation with types and defaults - -## Scope - -### In Scope - -- **Non-TTY auto-detection** — Enhance `isNonInteractiveEnvironment()` to drive behavior throughout the CLI -- **JSON output mode** — Global `--json` flag + auto-detect for non-TTY. Strip ANSI, use JSON for all output -- **Structured errors** — JSON error objects to stderr with error codes -- **Exit code standardization** — Consistent exit codes across all commands (0, 1, 2, 4) -- **Management command non-interactive paths** — All `env`, `org`, `user` subcommands work with flags-only, no prompts -- **Auth in non-TTY** — Require pre-existing credentials or `WORKOS_API_KEY`. Exit 4 if neither available -- **Headless installer adapter** — New adapter (alongside CLI and Dashboard) for non-interactive installs with flag overrides and auto-defaults -- **`WORKOS_NO_PROMPT` env var** — Explicit prompt suppression (like `GH_PROMPT_DISABLED`) -- **`WORKOS_FORCE_TTY` env var** — Force TTY behavior when piped (like `GH_FORCE_TTY`) -- **NDJSON streaming for installer** — Headless installer outputs progress as newline-delimited JSON events to stdout (detection, auth, file changes, agent thinking, completion). Agents consume in real-time. -- **Agent-discoverable help** — `--help --json` outputs machine-readable command schema. All subcommand help is thorough with complete flag docs, types, defaults, and examples. Agents can introspect the CLI without guessing. - -### Out of Scope - -- **New TUI features** — Dashboard stays as-is. No changes to Ink components. -- **Service account / machine tokens** — Future consideration. Pre-existing OAuth tokens and API keys are sufficient for now. -- **`--jq` / `--template` flags** — Nice-to-have but not needed for initial non-TTY support. Agents can pipe to `jq` themselves. -- **Interactive NDJSON consumer** — No TUI for consuming NDJSON events. Agents read stdout directly. A future "agent dashboard" could visualize the stream. -- **CI-specific mode** — The existing `--ci` flag on install is subsumed by the broader non-TTY support. May deprecate later. - -### Future Considerations - -- `--jq` and `--template` flags for inline output transformation -- Service account tokens for long-lived automation -- MCP server mode (expose CLI commands as MCP tools directly) -- `workos status` command for agents to check auth/config state diff --git a/docs/ideation/non-tty/spec-phase-1.md b/docs/ideation/non-tty/spec-phase-1.md deleted file mode 100644 index d35e9c0..0000000 --- a/docs/ideation/non-tty/spec-phase-1.md +++ /dev/null @@ -1,283 +0,0 @@ -# Spec: Core Infrastructure + Auth (Phase 1) - -**Effort**: L -**Blocked by**: None - -## Technical Approach - -Build the foundational layer that all other phases depend on: output mode detection, JSON formatting, structured errors, exit codes, and non-TTY auth behavior. This phase touches shared utilities and the CLI entry point but does NOT modify individual command implementations — that's Phase 2. - -The key design principle: **detect once, flow everywhere**. A single `OutputMode` resolved at startup drives all output and error formatting decisions through shared utilities that commands call. - -Pattern to follow: The existing `isNonInteractiveEnvironment()` in `src/utils/environment.ts` already detects TTY. We extend this into a richer `OutputMode` system. - -## Feedback Strategy - -- **Inner-loop command**: `pnpm test -- --filter output` -- **Playground**: Unit tests for output utilities + manual `echo | workos --help` pipe tests -- **Rationale**: Core utilities need thorough unit tests since every command depends on them - -## File Changes - -### New Files - -| File | Purpose | -| ------------------------------ | -------------------------------------------------------------- | -| `src/utils/output.ts` | Output mode detection, JSON formatter, structured error writer | -| `src/utils/exit-codes.ts` | Exit code constants and typed exit helper | -| `src/utils/output.spec.ts` | Tests for output utilities | -| `src/utils/exit-codes.spec.ts` | Tests for exit code helpers | - -### Modified Files - -| File | Change | -| ------------------------------ | ---------------------------------------------------------------------------------------------------- | -| `src/utils/environment.ts` | Add `WORKOS_NO_PROMPT`, `WORKOS_FORCE_TTY` env var support to `isNonInteractiveEnvironment()` | -| `src/bin.ts` | Add global `--json` flag, resolve `OutputMode` early, pass to commands. Add `--help --json` handler. | -| `src/lib/ensure-auth.ts` | In non-TTY mode, don't trigger `runLogin()` — exit with code 4 and structured error instead | -| `src/lib/api-key.ts` | Update error to use structured error format and exit code 4 | -| `src/lib/workos-api.ts` | Update `WorkOSApiError` to support structured JSON error output | -| `src/commands/organization.ts` | Update `handleApiError` to use structured error output (shared utility) | -| `src/commands/user.ts` | Update `handleApiError` to use structured error output (shared utility) | - -## Implementation Details - -### Component 1: Output Mode System (`src/utils/output.ts`) - -Pattern to follow: `src/utils/environment.ts` for env var reading pattern. - -```typescript -export type OutputMode = 'human' | 'json'; - -export function resolveOutputMode(jsonFlag?: boolean): OutputMode { - // Explicit --json flag always wins - if (jsonFlag) return 'json'; - // WORKOS_FORCE_TTY overrides auto-detection - if (process.env.WORKOS_FORCE_TTY) return 'human'; - // Auto-detect: non-TTY → JSON - if (!process.stdout.isTTY) return 'json'; - return 'human'; -} -``` - -**Key decisions:** - -- `OutputMode` is resolved once at startup in `bin.ts` and threaded through -- `outputJson(data)` writes `JSON.stringify(data)` to stdout (no pretty-print — agents parse it) -- `outputError(error)` writes structured JSON to stderr: `{ "error": { "code": string, "message": string, "details"?: unknown } }` -- `outputSuccess(message, data?)` writes either chalk-formatted success or JSON with `{ "status": "ok", "message": string, ...data }` -- When `OutputMode === 'json'`, all chalk calls are suppressed (strip ANSI) - -**Implementation steps:** - -1. Create `OutputMode` type and `resolveOutputMode()` function -2. Create `outputJson()`, `outputError()`, `outputSuccess()` helpers -3. Create `outputTable(columns, rows)` that delegates to `formatTable()` for human mode and JSON array for json mode -4. Add `stripAnsi()` utility (or use existing `chalk.level = 0` approach) - -**Feedback loop:** - -- Playground: Test suite -- Experiment: `resolveOutputMode({ jsonFlag: true })` returns `'json'`, `resolveOutputMode()` with mocked TTY returns `'human'` -- Check: `pnpm test -- --filter output` - -### Component 2: Exit Codes (`src/utils/exit-codes.ts`) - -```typescript -export const ExitCode = { - SUCCESS: 0, - GENERAL_ERROR: 1, - CANCELLED: 2, - AUTH_REQUIRED: 4, -} as const; - -export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode]; - -export function exitWithCode(code: ExitCodeValue, error?: { code: string; message: string }): never { - if (error) { - outputError(error); - } - process.exit(code); -} -``` - -**Implementation steps:** - -1. Define exit code constants -2. Create `exitWithCode()` helper that writes structured error then exits -3. Create `exitWithAuthRequired()` convenience for the common auth case - -**Feedback loop:** - -- Playground: Test suite -- Experiment: `exitWithCode(ExitCode.AUTH_REQUIRED, { code: 'auth_required', message: '...' })` exits 4 with JSON on stderr -- Check: `pnpm test -- --filter exit-codes` - -### Component 3: Environment Variable Support (`src/utils/environment.ts`) - -Update `isNonInteractiveEnvironment()`: - -```typescript -export function isNonInteractiveEnvironment(): boolean { - // WORKOS_NO_PROMPT forces non-interactive regardless of TTY - if (process.env.WORKOS_NO_PROMPT === '1' || process.env.WORKOS_NO_PROMPT === 'true') { - return true; - } - // WORKOS_FORCE_TTY forces interactive regardless of TTY - if (process.env.WORKOS_FORCE_TTY) { - return false; - } - if (IS_DEV) { - return false; - } - if (!process.stdout.isTTY || !process.stderr.isTTY) { - return true; - } - return false; -} -``` - -**Implementation steps:** - -1. Add `WORKOS_NO_PROMPT` check (highest priority — always non-interactive) -2. Add `WORKOS_FORCE_TTY` check (overrides TTY detection → interactive) -3. Preserve existing `IS_DEV` bypass - -### Component 4: Non-TTY Auth Guard (`src/lib/ensure-auth.ts`) - -In non-TTY mode, `ensureAuthenticated()` must never trigger `runLogin()` (which opens a browser). Instead, it should exit with code 4. - -```typescript -export async function ensureAuthenticated(): Promise { - const result: EnsureAuthResult = { authenticated: false, loginTriggered: false, tokenRefreshed: false }; - - if (!hasCredentials()) { - if (isNonInteractiveEnvironment()) { - exitWithCode(ExitCode.AUTH_REQUIRED, { - code: 'auth_required', - message: 'Not authenticated. Run `workos login` in an interactive terminal, or set WORKOS_API_KEY.', - }); - } - // ... existing interactive login flow - } - // ... rest of existing logic, with same pattern for expired tokens -} -``` - -**Implementation steps:** - -1. Import `isNonInteractiveEnvironment`, `exitWithCode`, `ExitCode` -2. Add non-TTY guard before every `runLogin()` call (4 locations) -3. Each guard uses `exitWithCode(ExitCode.AUTH_REQUIRED, ...)` with a helpful message -4. Token refresh still works silently (no user interaction needed) - -### Component 5: Global `--json` Flag and Help (`src/bin.ts`) - -Add `--json` as a global yargs option and resolve `OutputMode` at the top level. - -```typescript -// Global options -.option('json', { - type: 'boolean', - default: false, - describe: 'Output results as JSON (auto-enabled in non-TTY)', - global: true, -}) -``` - -For `--help --json`, intercept yargs help output and return a structured command tree: - -```typescript -// After yargs config, before .argv -.middleware((argv) => { - if (argv.help && argv.json) { - const commandTree = buildCommandTree(yargs); // Extract from yargs internal config - console.log(JSON.stringify(commandTree, null, 2)); - process.exit(0); - } -}) -``` - -**Implementation steps:** - -1. Add `--json` global option to yargs -2. Resolve `OutputMode` early using `resolveOutputMode(argv.json)` -3. Thread `OutputMode` to commands via yargs middleware or a shared singleton -4. Add `--help --json` interceptor that outputs machine-readable command schema -5. Update default command (`$0`) to output JSON help in non-TTY - -### Component 6: Structured Error Output for API Commands - -Update both `handleApiError` functions in `organization.ts` and `user.ts` to use the shared utility: - -```typescript -function handleApiError(error: unknown): never { - if (error instanceof WorkOSApiError) { - exitWithError({ - code: error.code || `http_${error.statusCode}`, - message: error.message, - details: error.errors, - }); - } - exitWithError({ - code: 'unknown_error', - message: error instanceof Error ? error.message : 'Unknown error', - }); -} -``` - -Where `exitWithError` uses `outputError()` + `process.exit(1)`, and in JSON mode outputs to stderr as JSON instead of `chalk.red()`. - -**Implementation steps:** - -1. Create shared `exitWithError()` in `src/utils/output.ts` -2. Update `handleApiError` in `organization.ts` to use it -3. Update `handleApiError` in `user.ts` to use it -4. Both still show chalk-formatted errors in human mode - -## Testing Requirements - -### Unit Tests - -| Test | Validates | -| ------------------------------------------------------------------------ | ------------------------- | -| `resolveOutputMode()` returns `'json'` when `--json` passed | Flag override works | -| `resolveOutputMode()` returns `'json'` when stdout not TTY | Auto-detection works | -| `resolveOutputMode()` returns `'human'` when `WORKOS_FORCE_TTY=1` | Force override works | -| `isNonInteractiveEnvironment()` returns `true` when `WORKOS_NO_PROMPT=1` | Env var suppression works | -| `outputJson()` writes valid JSON to stdout | JSON formatting | -| `outputError()` writes JSON to stderr in json mode | Structured errors | -| `outputError()` writes chalk.red to stderr in human mode | Human errors preserved | -| `exitWithCode(4)` exits with code 4 | Exit code propagation | -| `ensureAuthenticated()` exits 4 in non-TTY without credentials | Auth guard | -| `ensureAuthenticated()` still refreshes tokens silently in non-TTY | Token refresh unaffected | - -### Integration Tests - -| Test | Validates | -| -------------------------------------------------------------------------------- | ------------------------ | -| `echo '' \| workos org list --api-key invalid` exits 1 with JSON error on stderr | End-to-end non-TTY error | -| `workos org list --json --api-key valid` outputs JSON array | End-to-end JSON output | -| `WORKOS_NO_PROMPT=1 workos` exits 0 with JSON help | Prompt suppression | - -## Error Handling - -- Non-TTY + no auth → exit code 4, JSON error to stderr -- Non-TTY + API error → exit code 1, JSON error to stderr with error code -- Non-TTY + cancelled (Ctrl+C) → exit code 2 -- All errors include `code` field for machine parsing - -## Validation Commands - -```bash -pnpm typecheck -pnpm test -- --filter output -pnpm test -- --filter exit-codes -pnpm build -# Manual: echo '' | node dist/bin.js org list --api-key fake 2>&1 | jq . -``` - -## Open Items - -- Should `OutputMode` be a module-level singleton (like `IS_DEV`) or threaded via function args? Singleton is simpler but harder to test. Leaning toward singleton with `setOutputMode()` for tests. -- Exact schema for `--help --json` output — should it match a standard (e.g., JSON Schema for CLI args) or be custom? diff --git a/docs/ideation/non-tty/spec-phase-2.md b/docs/ideation/non-tty/spec-phase-2.md deleted file mode 100644 index d15268f..0000000 --- a/docs/ideation/non-tty/spec-phase-2.md +++ /dev/null @@ -1,192 +0,0 @@ -# Spec: Management Commands Non-Interactive (Phase 2) - -**Effort**: M -**Blocked by**: Phase 1 (Core Infrastructure) - -## Technical Approach - -Migrate all management commands (`env`, `organization`, `user`) to use the Phase 1 output utilities. Each command already has partial non-interactive support (they accept flags), but output is always human-formatted (chalk tables, colored success messages). This phase makes every command produce clean JSON in non-TTY mode while preserving the current human output. - -The pattern is consistent across all commands: - -1. Replace `console.log(chalk.green(...))` with `outputSuccess()` -2. Replace `formatTable()` calls with `outputTable()` -3. Replace `handleApiError()` with shared structured error output -4. Add non-interactive paths where interactive prompts exist (env add, env switch) - -Pattern to follow: `src/commands/env.ts` already has a non-interactive path for `env add` (lines 28-34). Extend this pattern to all commands. - -## Feedback Strategy - -- **Inner-loop command**: `pnpm test -- --filter commands` -- **Playground**: Test suite + manual pipe tests (`workos org list --api-key xxx | jq .`) -- **Rationale**: Each command is independent — test one, apply pattern to rest - -## File Changes - -### Modified Files - -| File | Change | -| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| `src/commands/env.ts` | Use output utilities for all subcommands. Add JSON output for `env list`. Non-interactive `env switch` requires name arg. | -| `src/commands/organization.ts` | Use output utilities. Remove `handleApiError` in favor of shared. JSON output for `org list`. | -| `src/commands/user.ts` | Use output utilities. Remove `handleApiError` in favor of shared. JSON output for `user list`. | -| `src/commands/env.spec.ts` | Add tests for JSON output mode | -| `src/commands/organization.spec.ts` | Add tests for JSON output mode | -| `src/commands/user.spec.ts` | Add tests for JSON output mode | -| `src/utils/table.ts` | No changes needed — `outputTable()` from Phase 1 delegates to this in human mode | -| `src/bin.ts` | Ensure non-TTY `env switch` without name arg exits with error instead of prompting | - -## Implementation Details - -### Component 1: Env Commands (`src/commands/env.ts`) - -Pattern to follow: Existing non-interactive path in `runEnvAdd()` (lines 28-34). - -**`runEnvAdd()` changes:** - -- Non-interactive path: replace `clack.log.success()` with `outputSuccess('Environment added', { name, type, active: isFirst })` -- Interactive path: no changes (human output preserved) -- In non-TTY without required args → `exitWithError({ code: 'missing_args', message: 'Name and API key required in non-interactive mode' })` - -**`runEnvRemove()` changes:** - -- Replace `clack.log.error/success` with `outputError/outputSuccess` -- Error case: `exitWithError({ code: 'not_found', message: '...' })` - -**`runEnvSwitch()` changes:** - -- Non-interactive (name provided): replace `clack.log.success` with `outputSuccess` -- Non-interactive (no name, non-TTY): `exitWithError({ code: 'missing_args', message: 'Environment name required in non-interactive mode' })` -- Interactive (no name, TTY): unchanged - -**`runEnvList()` changes:** - -- JSON mode: `outputJson(Object.values(config.environments).map(env => ({ ...env, active: env.name === config.activeEnvironment })))` -- Human mode: existing chalk table (unchanged) - -**Implementation steps:** - -1. Import output utilities at top of file -2. Update each function to check output mode for formatting -3. Add non-TTY guards for interactive-only code paths -4. Add tests for JSON output of each subcommand - -**Feedback loop:** - -- Playground: Test suite -- Experiment: `runEnvList()` with json output mode → valid JSON array -- Check: `pnpm test -- --filter env` - -### Component 2: Organization Commands (`src/commands/organization.ts`) - -**`handleApiError()` → remove**, replaced by shared `exitWithApiError()` from Phase 1. - -**`runOrgCreate()` changes:** - -- Replace `console.log(chalk.green('Created organization'))` + `console.log(JSON.stringify(org, null, 2))` with `outputSuccess('Created organization', org)` -- In JSON mode, outputs: `{ "status": "ok", "message": "Created organization", "data": { ...org } }` - -**`runOrgGet()` changes:** - -- Replace `console.log(JSON.stringify(org, null, 2))` with `outputJson(org)` -- Both modes get JSON for single-resource responses (already JSON, just standardize) - -**`runOrgList()` changes:** - -- JSON mode: `outputJson({ data: result.data, list_metadata: result.list_metadata })` -- Human mode: existing `formatTable()` (unchanged) -- Empty state: JSON mode → `outputJson({ data: [], list_metadata: result.list_metadata })` (no "No organizations found." string) - -**`runOrgUpdate()` / `runOrgDelete()` changes:** - -- Same pattern as `runOrgCreate()` - -**Implementation steps:** - -1. Remove local `handleApiError()` — use shared utility -2. Update each function to use `outputSuccess/outputJson/outputTable` -3. Ensure empty list states produce valid JSON (not strings like "No organizations found.") -4. Add tests - -**Feedback loop:** - -- Playground: Test suite with mocked API -- Experiment: `runOrgList()` in JSON mode with mock data → valid JSON with `data` array -- Check: `pnpm test -- --filter organization` - -### Component 3: User Commands (`src/commands/user.ts`) - -Identical pattern to organization commands. Same changes: - -1. Remove local `handleApiError()` -2. Replace output calls with shared utilities -3. Ensure empty states are valid JSON -4. Add tests - -**Implementation steps:** Same as Component 2, applied to user commands. - -**Feedback loop:** - -- Playground: Test suite -- Experiment: `runUserList()` in JSON mode → valid JSON -- Check: `pnpm test -- --filter user` - -### Component 4: Non-TTY Guards in `bin.ts` - -Update command handlers in `bin.ts` to prevent interactive prompts in non-TTY: - -```typescript -// env switch without name in non-TTY -.command('switch [name]', 'Switch active environment', (yargs) => ..., async (argv) => { - if (!argv.name && isNonInteractiveEnvironment()) { - exitWithError({ code: 'missing_args', message: 'Environment name required. Usage: workos env switch ' }); - } - // ... existing handler -}) -``` - -**Implementation steps:** - -1. Add non-TTY guards for `env switch` (requires name) -2. Add non-TTY guard for default command (`$0`) — already shows help, but switch to JSON help in non-TTY -3. Ensure `env add` in non-TTY without args exits with structured error - -## Testing Requirements - -### Unit Tests - -| Test | Validates | -| --------------------------------------------------------------------- | --------------------- | -| `runEnvList()` in JSON mode outputs valid JSON array | JSON env list | -| `runEnvAdd()` in non-TTY without args exits with error | Non-interactive guard | -| `runEnvSwitch()` in non-TTY without name exits with error | Non-interactive guard | -| `runOrgList()` in JSON mode outputs `{ data: [...] }` | JSON org list | -| `runOrgCreate()` in JSON mode outputs `{ status: "ok", data: {...} }` | JSON success | -| `runUserList()` in JSON mode outputs `{ data: [...] }` | JSON user list | -| `handleApiError` uses structured JSON in json mode | Structured errors | -| Empty list in JSON mode outputs `{ data: [] }`, not string | Empty state | - -### Integration Tests - -| Test | Validates | -| ----------------------------------------------- | --------------- | -| `echo '' \| workos env list` outputs valid JSON | End-to-end pipe | -| `workos org list --json` outputs JSON in TTY | Explicit flag | - -## Validation Commands - -```bash -pnpm typecheck -pnpm test -- --filter env -pnpm test -- --filter organization -pnpm test -- --filter user -pnpm build -# Manual: echo '' | node dist/bin.js env list 2>&1 | jq . -# Manual: echo '' | node dist/bin.js org list --api-key sk_test_xxx 2>&1 | jq . -``` - -## Open Items - -- Pagination metadata: Should JSON output for list commands always include `list_metadata` (cursor info) even when there's only one page? Leaning yes — agents need to know if more pages exist. -- Should `org get` and `user get` return raw API JSON or wrap in `{ "data": ... }` for consistency with list commands? Leaning raw — simpler for agents to consume single resources. diff --git a/docs/ideation/non-tty/spec-phase-3.md b/docs/ideation/non-tty/spec-phase-3.md deleted file mode 100644 index 2e7eb38..0000000 --- a/docs/ideation/non-tty/spec-phase-3.md +++ /dev/null @@ -1,214 +0,0 @@ -# Spec: Agent-Discoverable Help (Phase 3) - -**Effort**: S -**Blocked by**: Phase 1 (Core Infrastructure) - -## Technical Approach - -Make the CLI self-documenting for agents. Two parts: (1) `--help --json` outputs a machine-readable command tree, and (2) all subcommand help text is thorough enough for an agent to use any command without external docs. - -Yargs already knows the full command tree internally — we extract it and serialize to JSON. For help text quality, we audit every command's `describe`, positional descriptions, and option descriptions. - -Pattern to follow: `gh` CLI doesn't have `--help --json`, but tools like `kubectl` and `terraform` have rich help. Our approach is simpler: JSON schema of commands when both `--help` and `--json` are passed. - -## Feedback Strategy - -- **Inner-loop command**: `pnpm test -- --filter help` -- **Playground**: Manual testing — `workos --help --json | jq .commands` -- **Rationale**: Help output is best verified manually + snapshot tests - -## File Changes - -### New Files - -| File | Purpose | -| ----------------------------- | ------------------------------------------------ | -| `src/utils/help-json.ts` | Extracts yargs command tree into structured JSON | -| `src/utils/help-json.spec.ts` | Tests for help JSON output | - -### Modified Files - -| File | Change | -| ------------ | ------------------------------------------------------------------------------------ | -| `src/bin.ts` | Add middleware to intercept `--help --json`, improve all command/option descriptions | - -## Implementation Details - -### Component 1: JSON Help Extractor (`src/utils/help-json.ts`) - -Build a function that takes a yargs instance and returns a structured command tree: - -```typescript -export interface CommandSchema { - name: string; - description: string; - commands?: CommandSchema[]; - options?: OptionSchema[]; - positionals?: PositionalSchema[]; - examples?: string[]; -} - -export interface OptionSchema { - name: string; - type: string; - description: string; - required: boolean; - default?: unknown; - alias?: string; - choices?: string[]; - hidden: boolean; -} - -export interface PositionalSchema { - name: string; - type: string; - description: string; - required: boolean; -} - -export function buildCommandTree(yargsInstance: yargs.Argv): CommandSchema { - // Extract from yargs internal command registry - // yargs.getCommandInstance().getCommands() gives command names - // yargs.getOptions() gives options for each command -} -``` - -**Output format:** - -```json -{ - "name": "workos", - "version": "0.7.3", - "description": "WorkOS CLI for AuthKit integration and resource management", - "commands": [ - { - "name": "login", - "description": "Authenticate with WorkOS via browser-based OAuth", - "options": [ - { "name": "insecure-storage", "type": "boolean", "description": "...", "required": false, "default": false, "hidden": false } - ] - }, - { - "name": "env", - "description": "Manage environment configurations (API keys, endpoints)", - "commands": [ - { - "name": "add", - "description": "Add an environment configuration", - "positionals": [ - { "name": "name", "type": "string", "description": "Environment name (lowercase, hyphens, underscores)", "required": false }, - { "name": "apiKey", "type": "string", "description": "WorkOS API key (sk_live_* or sk_test_*)", "required": false } - ], - "options": [ - { "name": "client-id", "type": "string", "description": "WorkOS client ID for this environment", "required": false, "hidden": false }, - { "name": "endpoint", "type": "string", "description": "Custom API endpoint URL", "required": false, "hidden": false } - ] - } - ] - }, - { - "name": "organization", - "description": "Manage WorkOS organizations (CRUD operations)", - "commands": [...] - } - ] -} -``` - -**Implementation steps:** - -1. Define `CommandSchema`, `OptionSchema`, `PositionalSchema` interfaces -2. Implement `buildCommandTree()` using yargs internal APIs (`getCommandInstance()`, `getOptions()`) -3. Handle nested commands (env → add/remove/switch/list) -4. Include hidden flag on options (agents may want to use hidden flags) -5. Add version field from `getVersion()` - -**Feedback loop:** - -- Playground: Manual pipe test -- Experiment: `workos --help --json | jq '.commands | length'` returns correct count -- Check: `pnpm test -- --filter help` - -### Component 2: Help Interception (`src/bin.ts`) - -Add yargs middleware to intercept `--help --json`: - -```typescript -.middleware((argv) => { - // Note: --help causes yargs to show help and exit before middleware normally. - // We need to intercept earlier or use a custom check. -}, /* applyBeforeValidation */ true) -``` - -Actually, yargs `--help` exits before middleware runs. Better approach: check for `--help` and `--json` in argv before yargs parses: - -```typescript -const rawArgs = hideBin(process.argv); -if (rawArgs.includes('--help') && rawArgs.includes('--json')) { - // Build yargs config but don't parse - const cli = buildYargsConfig(); // Extract yargs setup into function - const tree = buildCommandTree(cli); - console.log(JSON.stringify(tree, null, 2)); - process.exit(0); -} -``` - -**Implementation steps:** - -1. Extract yargs configuration into a `buildYargsConfig()` function (refactor from inline in bin.ts) -2. Add early `--help --json` check before `.argv` -3. Call `buildCommandTree()` and output JSON - -### Component 3: Help Text Quality Audit - -Audit and improve all command/option descriptions in `bin.ts`: - -**Current gaps found:** - -- `env` command: `'Manage environment configurations'` → `'Manage environment configurations (API keys, endpoints, active environment)'` -- `organization`: `'Manage organizations'` → `'Manage WorkOS organizations (create, update, get, list, delete)'` -- `user`: `'Manage users'` → `'Manage WorkOS user management users (get, list, update, delete)'` -- `install`: Missing description of what it does beyond "Install WorkOS AuthKit" -- Options like `--api-key`: `'WorkOS API key (overrides environment config)'` → add `'Format: sk_live_* or sk_test_*'` -- Positionals like `domains..`: `'Domains as domain:state'` → `'Domains in format domain.com:verified (state is optional, defaults to verified)'` - -**Implementation steps:** - -1. Review every `.describe()` and `.positional()` description -2. Ensure each describes: what it does, format/type expectations, default behavior -3. Add examples where helpful (yargs `.example()`) -4. Keep descriptions concise but complete — agents need enough to use the command correctly - -## Testing Requirements - -### Unit Tests - -| Test | Validates | -| ------------------------------------------------------------- | ----------------- | -| `buildCommandTree()` returns all top-level commands | Command discovery | -| `buildCommandTree()` includes nested subcommands | Nested commands | -| `buildCommandTree()` includes options with types and defaults | Option schema | -| `buildCommandTree()` includes positionals with required flag | Positional schema | -| Output is valid JSON parseable by `JSON.parse()` | JSON validity | - -### Snapshot Tests - -| Test | Validates | -| --------------------------------------- | --------------------------------------- | -| `--help --json` output matches snapshot | No accidental regression in help schema | - -## Validation Commands - -```bash -pnpm typecheck -pnpm test -- --filter help -pnpm build -# Manual: node dist/bin.js --help --json | jq . -# Manual: node dist/bin.js env --help --json | jq '.commands' -# Manual: node dist/bin.js organization --help --json | jq '.commands[0].positionals' -``` - -## Open Items - -- Should `--help --json` work for subcommands too? e.g., `workos env --help --json` → only the env subtree. Leaning yes — more useful for agents exploring one command group. -- Yargs internal APIs are not stable. Need to check if `getCommandInstance()` is reliable or if we need to build the tree from our own registry. May need a parallel command registry. diff --git a/docs/ideation/non-tty/spec-phase-4.md b/docs/ideation/non-tty/spec-phase-4.md deleted file mode 100644 index 1feaaa3..0000000 --- a/docs/ideation/non-tty/spec-phase-4.md +++ /dev/null @@ -1,332 +0,0 @@ -# Spec: Headless Installer + NDJSON Streaming (Phase 4) - -**Effort**: L -**Blocked by**: Phase 1 (Core Infrastructure) - -## Technical Approach - -Create a third adapter — `HeadlessAdapter` — alongside the existing `CLIAdapter` and `DashboardAdapter`. This adapter handles all state machine events non-interactively: auto-resolving decisions with sensible defaults, accepting overrides via CLI flags, and streaming progress as NDJSON to stdout. - -The adapter pattern is already well-established. The state machine (`installer-core.ts`) emits typed events; adapters subscribe and respond. The `HeadlessAdapter` subscribes to the same events but never prompts — it auto-responds with defaults or flag-provided values. - -Pattern to follow: `src/lib/adapters/cli-adapter.ts` for event subscription pattern and handler structure. - -## Feedback Strategy - -- **Inner-loop command**: `pnpm test -- --filter headless` -- **Playground**: Pipe test — `echo '' | workos install --api-key xxx --client-id yyy 2>&1 | head` -- **Rationale**: Adapter must be tested against the real event flow, but unit tests can mock the emitter - -## File Changes - -### New Files - -| File | Purpose | -| ------------------------------------------- | --------------------------------------------- | -| `src/lib/adapters/headless-adapter.ts` | Non-interactive adapter with NDJSON streaming | -| `src/lib/adapters/headless-adapter.spec.ts` | Tests for headless adapter | -| `src/utils/ndjson.ts` | NDJSON writer utility | -| `src/utils/ndjson.spec.ts` | Tests for NDJSON utility | - -### Modified Files - -| File | Change | -| -------------------------- | ------------------------------------------------------------------------------- | -| `src/lib/run-with-core.ts` | Add headless adapter selection when non-TTY detected | -| `src/commands/install.ts` | Remove non-TTY block — route to headless adapter instead of erroring | -| `src/bin.ts` | Ensure `--api-key` and `--client-id` are visible (not hidden) flags for install | - -## Implementation Details - -### Component 1: NDJSON Writer (`src/utils/ndjson.ts`) - -Simple utility for writing newline-delimited JSON events to stdout: - -```typescript -export interface NDJSONEvent { - type: string; - timestamp: string; - [key: string]: unknown; -} - -export function writeNDJSON(event: Omit): void { - const line: NDJSONEvent = { - ...event, - timestamp: new Date().toISOString(), - }; - process.stdout.write(JSON.stringify(line) + '\n'); -} -``` - -**Event types emitted:** - -| Event Type | Payload | When | -| --------------------- | ----------------------------------------- | -------------------------- | -| `detection:start` | `{}` | Framework detection begins | -| `detection:complete` | `{ integration: string }` | Framework detected | -| `detection:none` | `{}` | No framework detected | -| `auth:checking` | `{}` | Checking credentials | -| `auth:success` | `{}` | Authenticated | -| `auth:required` | `{ message: string }` | Auth failed (also exits 4) | -| `git:status` | `{ dirty: boolean, files?: string[] }` | Git status check | -| `git:decision` | `{ action: 'continue' \| 'cancel' }` | Auto-resolved git decision | -| `credentials:found` | `{ source: 'flag' \| 'env' \| 'stored' }` | Credentials resolved | -| `branch:created` | `{ name: string }` | Branch created | -| `branch:skipped` | `{ reason: string }` | Branch creation skipped | -| `agent:start` | `{}` | Agent execution begins | -| `agent:progress` | `{ message: string }` | Agent thinking/progress | -| `agent:tool` | `{ tool: string, input?: unknown }` | Agent using a tool | -| `agent:success` | `{}` | Agent completed | -| `agent:failure` | `{ error: string }` | Agent failed | -| `validation:start` | `{}` | Post-install validation | -| `validation:issue` | `{ severity: string, message: string }` | Validation finding | -| `validation:complete` | `{ issues: number }` | Validation done | -| `commit:created` | `{ sha: string, message: string }` | Auto-committed | -| `complete` | `{ success: boolean }` | Install finished | -| `error` | `{ code: string, message: string }` | Fatal error | - -**Implementation steps:** - -1. Define `NDJSONEvent` interface -2. Create `writeNDJSON()` that serializes + writes to stdout -3. Ensure no other stdout writes happen during headless mode (all human output suppressed) - -**Feedback loop:** - -- Playground: Test suite -- Experiment: `writeNDJSON({ type: 'detection:complete', integration: 'nextjs' })` outputs valid JSON line -- Check: `pnpm test -- --filter ndjson` - -### Component 2: Headless Adapter (`src/lib/adapters/headless-adapter.ts`) - -Pattern to follow: `src/lib/adapters/cli-adapter.ts` — same `subscribe()` pattern, same event handlers, but auto-resolving instead of prompting. - -```typescript -import type { AdapterConfig } from './types.js'; -import { writeNDJSON } from '../../utils/ndjson.js'; - -export class HeadlessAdapter { - private emitter: AdapterConfig['emitter']; - private sendEvent: AdapterConfig['sendEvent']; - private handlers = new Map void>(); - private options: HeadlessOptions; - - constructor(config: AdapterConfig & { options: HeadlessOptions }) { - this.emitter = config.emitter; - this.sendEvent = config.sendEvent; - this.options = config.options; - } - - async start(): Promise { - // Subscribe to all events, auto-resolve decisions - this.subscribe('detection:complete', this.handleDetectionComplete); - this.subscribe('detection:none', this.handleDetectionNone); - this.subscribe('git:dirty', this.handleGitDirty); - this.subscribe('credentials:request', this.handleCredentialsRequest); - this.subscribe('credentials:env:prompt', this.handleEnvPrompt); - this.subscribe('branch:prompt', this.handleBranchPrompt); - this.subscribe('postinstall:commit:prompt', this.handleCommitPrompt); - this.subscribe('postinstall:pr:prompt', this.handlePrPrompt); - // ... all other events → writeNDJSON pass-through - } -} -``` - -**Auto-default decisions:** - -| Event | Interactive Behavior | Headless Default | Override Flag | -| --------------------------- | ---------------------------- | ------------------------------------------------------ | -------------------------- | -| `git:dirty` | Prompt to continue | Auto-continue | `--no-git-check` | -| `credentials:request` | Prompt for API key/client ID | Use `--api-key`/`--client-id` flags. Error if missing. | `--api-key`, `--client-id` | -| `credentials:env:prompt` | Ask to scan env files | Auto-scan | (always scans) | -| `branch:prompt` | Ask create/continue/cancel | Auto-create branch | `--no-branch` to skip | -| `postinstall:commit:prompt` | Ask to commit | Auto-commit | `--no-commit` to skip | -| `postinstall:pr:prompt` | Ask to create PR | Skip PR | `--create-pr` to enable | - -**Implementation steps:** - -1. Create `HeadlessOptions` interface with all override flags -2. Implement constructor with `AdapterConfig` + options -3. Implement `start()` with event subscriptions -4. For each decision event: auto-resolve with default, log NDJSON event, send event back to state machine -5. For progress/info events: write NDJSON pass-through -6. For error events: write NDJSON + exit with appropriate code -7. Implement `stop()` cleanup (same pattern as CLIAdapter) - -**Key handler examples:** - -```typescript -private handleGitDirty = ({ files }: InstallerEvents['git:dirty']): void => { - writeNDJSON({ type: 'git:status', dirty: true, files }); - writeNDJSON({ type: 'git:decision', action: 'continue' }); - this.sendEvent({ type: 'GIT_CONFIRMED' }); -}; - -private handleCredentialsRequest = ({ requiresApiKey }: InstallerEvents['credentials:request']): void => { - if (requiresApiKey && !this.options.apiKey) { - writeNDJSON({ type: 'error', code: 'missing_credentials', message: 'API key required. Pass --api-key flag.' }); - exitWithCode(ExitCode.GENERAL_ERROR); - } - this.sendEvent({ - type: 'CREDENTIALS_PROVIDED', - apiKey: this.options.apiKey, - clientId: this.options.clientId, - }); -}; - -private handleBranchPrompt = (): void => { - if (this.options.noBranch) { - writeNDJSON({ type: 'branch:skipped', reason: '--no-branch flag' }); - this.sendEvent({ type: 'BRANCH_CONTINUE_CURRENT' }); - } else { - writeNDJSON({ type: 'branch:creating' }); - this.sendEvent({ type: 'BRANCH_CREATE' }); - } -}; -``` - -**Feedback loop:** - -- Playground: Test suite with mocked emitter -- Experiment: Emit `git:dirty` → adapter auto-confirms and writes NDJSON -- Check: `pnpm test -- --filter headless` - -### Component 3: Adapter Selection (`src/lib/run-with-core.ts`) - -Update adapter selection to include headless: - -```typescript -let adapter: InstallerAdapter; -if (isNonInteractiveEnvironment()) { - adapter = new HeadlessAdapter({ - emitter, - sendEvent, - debug: augmentedOptions.debug, - options: { - apiKey: augmentedOptions.apiKey, - clientId: augmentedOptions.clientId, - noBranch: augmentedOptions.noBranch, - noCommit: augmentedOptions.noCommit, - createPr: augmentedOptions.createPr, - noGitCheck: augmentedOptions.noGitCheck, - }, - }); -} else if (options.dashboard) { - adapter = new DashboardAdapter({ emitter, sendEvent, debug: augmentedOptions.debug }); -} else { - adapter = new CLIAdapter({ emitter, sendEvent, debug: augmentedOptions.debug }); -} -``` - -**Implementation steps:** - -1. Import `HeadlessAdapter` and `isNonInteractiveEnvironment` -2. Add non-TTY check as highest priority (before dashboard check) -3. Pass headless options from CLI args -4. Ensure `HeadlessAdapter` implements same interface as other adapters - -### Component 4: Install Command Update (`src/commands/install.ts`) - -Remove the non-TTY block that currently exits with an error: - -```typescript -// REMOVE THIS: -} else if (isNonInteractiveEnvironment()) { - clack.log.error('This installer requires an interactive terminal...'); - process.exit(1); -} -``` - -Replace with: let the flow continue — `run-with-core.ts` will select the `HeadlessAdapter`. - -**Implementation steps:** - -1. Remove non-TTY error block in `handleInstall()` -2. Ensure CI mode (`--ci`) still works (may need to merge with headless behavior) -3. Make `--api-key` and `--client-id` visible in yargs config (currently `hidden: true`) - -### Component 5: New Install Flags (`src/bin.ts`) - -Expose headless-relevant flags: - -```typescript -const installerOptions = { - // ... existing options - 'api-key': { - type: 'string' as const, - describe: 'WorkOS API key (required in non-interactive mode)', - }, - 'client-id': { - type: 'string' as const, - describe: 'WorkOS client ID (required in non-interactive mode)', - }, - 'no-branch': { - default: false, - type: 'boolean' as const, - describe: 'Skip branch creation (use current branch)', - }, - 'no-commit': { - default: false, - type: 'boolean' as const, - describe: 'Skip auto-commit after installation', - }, - 'create-pr': { - default: false, - type: 'boolean' as const, - describe: 'Auto-create pull request after installation', - }, - 'no-git-check': { - default: false, - type: 'boolean' as const, - describe: 'Skip git dirty check', - }, -}; -``` - -## Testing Requirements - -### Unit Tests - -| Test | Validates | -| ---------------------------------------------------- | ------------------------ | -| `HeadlessAdapter` auto-confirms git dirty | Default behavior | -| `HeadlessAdapter` sends credentials from flags | Flag passthrough | -| `HeadlessAdapter` errors when credentials missing | Required flag validation | -| `HeadlessAdapter` auto-creates branch by default | Default branch behavior | -| `HeadlessAdapter` skips branch with `--no-branch` | Flag override | -| `HeadlessAdapter` auto-commits by default | Default commit behavior | -| `HeadlessAdapter` skips commit with `--no-commit` | Flag override | -| All NDJSON events have `type` and `timestamp` fields | Event schema | -| NDJSON output is parseable line-by-line | NDJSON format | -| `writeNDJSON` outputs exactly one line per call | No multi-line | - -### Integration Tests - -| Test | Validates | -| -------------------------------------------------------------------------------------- | ------------------- | -| `echo '' \| workos install --api-key xxx --client-id yyy --no-validate` streams NDJSON | End-to-end headless | -| Headless install with missing `--api-key` exits with error NDJSON event | Error handling | - -## Error Handling - -- Missing required credentials → NDJSON error event + exit code 1 -- Agent failure → NDJSON error event + exit code 1 -- Auth expired during install → NDJSON auth:required event + exit code 4 -- All errors produce both an NDJSON event AND a structured JSON error on stderr (for agents that read stderr) - -## Validation Commands - -```bash -pnpm typecheck -pnpm test -- --filter headless -pnpm test -- --filter ndjson -pnpm build -# Manual (requires real credentials): -# echo '' | node dist/bin.js install --api-key sk_test_xxx --client-id client_xxx --no-validate --no-commit 2>/dev/null | head -20 -``` - -## Open Items - -- Should agent progress events include the raw agent thinking text, or just summaries? Raw text could be very verbose. Leaning toward summaries with a `--verbose` flag for full output. -- Should NDJSON go to stdout or stderr? Stdout is conventional for data, but if the installer also produces file content to stdout, there'd be a conflict. Since the installer writes files to disk (not stdout), NDJSON on stdout is fine. -- The existing `--ci` flag partially overlaps with headless mode. Should we deprecate `--ci` in favor of auto-detection? Or keep it as an alias? Leaning toward keeping `--ci` as a documented alias for "non-interactive install" to avoid breaking existing CI configs. From 78e4b8468d5144188cfe8f53ec0f7cc34e5cd873 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sat, 28 Feb 2026 13:24:29 -0600 Subject: [PATCH 05/11] fix: add format:check script: --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 3286362..5636200 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,8 @@ "build": "pnpm tsc", "postbuild": "chmod +x ./dist/bin.js && cp -r scripts/** dist", "lint": "oxlint", - "format": "oxfmt --check .", + "format": "oxfmt .", + "format:check": "oxfmt --check .", "try": "tsx dev.ts", "dev": "pnpm build && pnpm link --global && pnpm build:watch", "test": "vitest run", From e49cce370f5ba9a838abe398f5d137c51bb69caf Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sat, 28 Feb 2026 13:53:33 -0600 Subject: [PATCH 06/11] fix: various fixes from manual testing --- src/bin.ts | 42 ++-- src/commands/env.ts | 10 +- src/commands/user.spec.ts | 4 +- src/lib/adapters/headless-adapter.ts | 5 +- src/lib/api-key.spec.ts | 26 ++- src/lib/api-key.ts | 6 +- src/run.ts | 12 +- src/utils/help-json.spec.ts | 11 +- src/utils/help-json.ts | 283 ++++++++++++++++++++++----- src/utils/output.ts | 1 + 10 files changed, 316 insertions(+), 84 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index e8f23dd..942ec18 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -128,9 +128,9 @@ const installerOptions = { describe: 'Redirect URI for WorkOS callback (defaults to framework convention)', type: 'string' as const, }, - 'no-validate': { - default: false, - describe: 'Skip post-installation validation (includes build check)', + validate: { + default: true, + describe: 'Run post-installation validation (use --no-validate to skip)', type: 'boolean' as const, }, 'install-dir': { @@ -152,14 +152,14 @@ const installerOptions = { describe: 'Run with visual dashboard mode', type: 'boolean' as const, }, - 'no-branch': { - default: false, - describe: 'Skip branch creation (use current branch)', + branch: { + default: true, + describe: 'Create a new branch for changes (use --no-branch to skip)', type: 'boolean' as const, }, - 'no-commit': { - default: false, - describe: 'Skip auto-commit after installation', + commit: { + default: true, + describe: 'Auto-commit after installation (use --no-commit to skip)', type: 'boolean' as const, }, 'create-pr': { @@ -167,9 +167,9 @@ const installerOptions = { describe: 'Auto-create pull request after installation', type: 'boolean' as const, }, - 'no-git-check': { - default: false, - describe: 'Skip git dirty working tree check', + 'git-check': { + default: true, + describe: 'Check for dirty working tree (use --no-git-check to skip)', type: 'boolean' as const, }, }; @@ -332,11 +332,14 @@ yargs(hideBin(process.argv)) .demandCommand(1, 'Please specify an env subcommand') .strict(), ) - .command('organization', 'Manage WorkOS organizations (create, update, get, list, delete)', (yargs) => + .command(['organization', 'org'], 'Manage WorkOS organizations (create, update, get, list, delete)', (yargs) => yargs .options({ ...insecureStorageOption, - 'api-key': { type: 'string' as const, describe: 'WorkOS API key (overrides environment config). Format: sk_live_* or sk_test_*' }, + 'api-key': { + type: 'string' as const, + describe: 'WorkOS API key (overrides environment config). Format: sk_live_* or sk_test_*', + }, }) .command( 'create [domains..]', @@ -344,7 +347,11 @@ yargs(hideBin(process.argv)) (yargs) => yargs .positional('name', { type: 'string', demandOption: true, describe: 'Organization name' }) - .positional('domains', { type: 'string', array: true, describe: 'Domains in format domain:state (state defaults to verified)' }), + .positional('domains', { + type: 'string', + array: true, + describe: 'Domains in format domain:state (state defaults to verified)', + }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); @@ -424,7 +431,10 @@ yargs(hideBin(process.argv)) yargs .options({ ...insecureStorageOption, - 'api-key': { type: 'string' as const, describe: 'WorkOS API key (overrides environment config). Format: sk_live_* or sk_test_*' }, + 'api-key': { + type: 'string' as const, + describe: 'WorkOS API key (overrides environment config). Format: sk_live_* or sk_test_*', + }, }) .command( 'get ', diff --git a/src/commands/env.ts b/src/commands/env.ts index fa20df6..8d6eaea 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -111,7 +111,10 @@ export async function runEnvAdd(options: { export async function runEnvRemove(name: string): Promise { const config = getConfig(); if (!config || Object.keys(config.environments).length === 0) { - exitWithError({ code: 'no_environments', message: 'No environments configured. Run `workos env add` to get started.' }); + exitWithError({ + code: 'no_environments', + message: 'No environments configured. Run `workos env add` to get started.', + }); } if (!config.environments[name]) { @@ -136,7 +139,10 @@ export async function runEnvRemove(name: string): Promise { export async function runEnvSwitch(name?: string): Promise { const config = getConfig(); if (!config || Object.keys(config.environments).length === 0) { - exitWithError({ code: 'no_environments', message: 'No environments configured. Run `workos env add` to get started.' }); + exitWithError({ + code: 'no_environments', + message: 'No environments configured. Run `workos env add` to get started.', + }); } if (name) { diff --git a/src/commands/user.spec.ts b/src/commands/user.spec.ts index 9f4d649..4df065d 100644 --- a/src/commands/user.spec.ts +++ b/src/commands/user.spec.ts @@ -137,7 +137,9 @@ describe('user commands', () => { it('runUserList outputs JSON with data and list_metadata', async () => { mockRequest.mockResolvedValue({ - data: [{ id: 'user_123', email: 'test@example.com', first_name: 'Test', last_name: 'User', email_verified: true }], + data: [ + { id: 'user_123', email: 'test@example.com', first_name: 'Test', last_name: 'User', email_verified: true }, + ], list_metadata: { before: null, after: 'cursor_a' }, }); await runUserList({}, 'sk_test'); diff --git a/src/lib/adapters/headless-adapter.ts b/src/lib/adapters/headless-adapter.ts index bf10e07..2d5b7fb 100644 --- a/src/lib/adapters/headless-adapter.ts +++ b/src/lib/adapters/headless-adapter.ts @@ -198,10 +198,7 @@ export class HeadlessAdapter implements InstallerAdapter { // ===== Device Auth (should not occur in headless) ===== - private handleDeviceStarted = ({ - verificationUri, - userCode, - }: InstallerEvents['device:started']): void => { + private handleDeviceStarted = ({ verificationUri, userCode }: InstallerEvents['device:started']): void => { writeNDJSON({ type: 'auth:device_required', verificationUri, diff --git a/src/lib/api-key.spec.ts b/src/lib/api-key.spec.ts index 71df759..1ae097d 100644 --- a/src/lib/api-key.spec.ts +++ b/src/lib/api-key.spec.ts @@ -8,6 +8,21 @@ vi.mock('../utils/debug.js', () => ({ logWarn: vi.fn(), })); +// Mock exitWithError — must throw to halt execution like process.exit +class ExitError extends Error { + code: string; + constructor(error: { code: string; message: string }) { + super(error.message); + this.code = error.code; + } +} +const mockExitWithError = vi.fn((error: { code: string; message: string }) => { + throw new ExitError(error); +}); +vi.mock('../utils/output.js', () => ({ + exitWithError: (...args: unknown[]) => mockExitWithError(...(args as [{ code: string; message: string }])), +})); + let testDir: string; // Mock os.homedir for config-store @@ -73,13 +88,16 @@ describe('api-key', () => { expect(resolveApiKey()).toBe('sk_stored'); }); - it('throws when no API key available', () => { - expect(() => resolveApiKey()).toThrow(/No API key/); + it('exits with error when no API key available', () => { + expect(() => resolveApiKey()).toThrow(ExitError); + expect(mockExitWithError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'no_api_key' }), + ); }); - it('throws when config exists but no active environment', () => { + it('exits with error when config exists but no active environment', () => { saveConfig({ environments: {} }); - expect(() => resolveApiKey()).toThrow(/No API key/); + expect(() => resolveApiKey()).toThrow(ExitError); }); it('ignores empty string env var', () => { diff --git a/src/lib/api-key.ts b/src/lib/api-key.ts index 13c2c8a..309aff6 100644 --- a/src/lib/api-key.ts +++ b/src/lib/api-key.ts @@ -8,6 +8,7 @@ */ import { getActiveEnvironment } from './config-store.js'; +import { exitWithError } from '../utils/output.js'; const DEFAULT_BASE_URL = 'https://api.workos.com'; @@ -24,7 +25,10 @@ export function resolveApiKey(options?: ApiKeyOptions): string { const activeEnv = getActiveEnvironment(); if (activeEnv?.apiKey) return activeEnv.apiKey; - throw new Error('No API key configured. Run `workos env add` to configure an environment, or set WORKOS_API_KEY.'); + exitWithError({ + code: 'no_api_key', + message: 'No API key configured. Run `workos env add` to configure an environment, or set WORKOS_API_KEY.', + }); } export function resolveApiBaseUrl(): string { diff --git a/src/run.ts b/src/run.ts index cd8487e..8252738 100644 --- a/src/run.ts +++ b/src/run.ts @@ -24,10 +24,14 @@ export type InstallerArgs = { dashboard?: boolean; inspect?: boolean; noValidate?: boolean; + validate?: boolean; noCommit?: boolean; + commit?: boolean; noBranch?: boolean; + branch?: boolean; createPr?: boolean; noGitCheck?: boolean; + gitCheck?: boolean; direct?: boolean; }; @@ -63,11 +67,11 @@ function buildOptions(argv: InstallerArgs): InstallerOptions { dashboard: merged.dashboard ?? false, integration: merged.integration, inspect: merged.inspect ?? false, - noValidate: merged.noValidate ?? false, - noCommit: merged.noCommit ?? false, - noBranch: merged.noBranch ?? false, + noValidate: merged.noValidate ?? (merged.validate === false ? true : false), + noCommit: merged.noCommit ?? (merged.commit === false ? true : false), + noBranch: merged.noBranch ?? (merged.branch === false ? true : false), createPr: merged.createPr ?? false, - noGitCheck: merged.noGitCheck ?? false, + noGitCheck: merged.noGitCheck ?? (merged.gitCheck === false ? true : false), direct: merged.direct ?? false, emitter: createInstallerEventEmitter(), // Will be replaced in runWithCore }; diff --git a/src/utils/help-json.spec.ts b/src/utils/help-json.spec.ts index 24d22b1..08a9bf2 100644 --- a/src/utils/help-json.spec.ts +++ b/src/utils/help-json.spec.ts @@ -28,7 +28,16 @@ describe('help-json', () => { const tree = buildCommandTree(); const names = (tree as { commands: { name: string }[] }).commands.map((c) => c.name); expect(names).toEqual( - expect.arrayContaining(['login', 'logout', 'install-skill', 'doctor', 'env', 'organization', 'user', 'install']), + expect.arrayContaining([ + 'login', + 'logout', + 'install-skill', + 'doctor', + 'env', + 'organization', + 'user', + 'install', + ]), ); }); diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index ad2f354..ebfa602 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -66,9 +66,28 @@ const apiKeyOpt: OptionSchema = { const paginationOpts: OptionSchema[] = [ { name: 'limit', type: 'number', description: 'Maximum number of results to return', required: false, hidden: false }, - { name: 'before', type: 'string', description: 'Pagination cursor for results before a specific item', required: false, hidden: false }, - { name: 'after', type: 'string', description: 'Pagination cursor for results after a specific item', required: false, hidden: false }, - { name: 'order', type: 'string', description: 'Sort order (asc or desc)', required: false, choices: ['asc', 'desc'], hidden: false }, + { + name: 'before', + type: 'string', + description: 'Pagination cursor for results before a specific item', + required: false, + hidden: false, + }, + { + name: 'after', + type: 'string', + description: 'Pagination cursor for results after a specific item', + required: false, + hidden: false, + }, + { + name: 'order', + type: 'string', + description: 'Sort order (asc or desc)', + required: false, + choices: ['asc', 'desc'], + hidden: false, + }, ]; // --------------------------------------------------------------------------- @@ -90,21 +109,83 @@ const commands: CommandSchema[] = [ name: 'install-skill', description: 'Install bundled AuthKit skills to coding agents (Claude Code, Codex, Cursor, Goose)', options: [ - { name: 'list', type: 'boolean', description: 'List available skills without installing', required: false, alias: 'l', hidden: false }, - { name: 'skill', type: 'array', description: 'Install specific skill(s) by name', required: false, alias: 's', hidden: false }, - { name: 'agent', type: 'array', description: 'Target specific agent(s): claude-code, codex, cursor, goose', required: false, alias: 'a', hidden: false }, + { + name: 'list', + type: 'boolean', + description: 'List available skills without installing', + required: false, + alias: 'l', + hidden: false, + }, + { + name: 'skill', + type: 'array', + description: 'Install specific skill(s) by name', + required: false, + alias: 's', + hidden: false, + }, + { + name: 'agent', + type: 'array', + description: 'Target specific agent(s): claude-code, codex, cursor, goose', + required: false, + alias: 'a', + hidden: false, + }, ], }, { name: 'doctor', description: 'Diagnose WorkOS AuthKit integration issues in the current project', options: [ - { name: 'verbose', type: 'boolean', description: 'Include additional diagnostic information', required: false, default: false, hidden: false }, - { name: 'skip-api', type: 'boolean', description: 'Skip API calls (offline mode)', required: false, default: false, hidden: false }, - { name: 'skip-ai', type: 'boolean', description: 'Skip AI-powered analysis', required: false, default: false, hidden: false }, - { name: 'install-dir', type: 'string', description: 'Project directory to analyze (defaults to cwd)', required: false, hidden: false }, - { name: 'json', type: 'boolean', description: 'Output diagnostic report as JSON', required: false, default: false, hidden: false }, - { name: 'copy', type: 'boolean', description: 'Copy diagnostic report to clipboard', required: false, default: false, hidden: false }, + { + name: 'verbose', + type: 'boolean', + description: 'Include additional diagnostic information', + required: false, + default: false, + hidden: false, + }, + { + name: 'skip-api', + type: 'boolean', + description: 'Skip API calls (offline mode)', + required: false, + default: false, + hidden: false, + }, + { + name: 'skip-ai', + type: 'boolean', + description: 'Skip AI-powered analysis', + required: false, + default: false, + hidden: false, + }, + { + name: 'install-dir', + type: 'string', + description: 'Project directory to analyze (defaults to cwd)', + required: false, + hidden: false, + }, + { + name: 'json', + type: 'boolean', + description: 'Output diagnostic report as JSON', + required: false, + default: false, + hidden: false, + }, + { + name: 'copy', + type: 'boolean', + description: 'Copy diagnostic report to clipboard', + required: false, + default: false, + hidden: false, + }, ], }, { @@ -116,27 +197,34 @@ const commands: CommandSchema[] = [ name: 'add', description: 'Add a new environment configuration with API key and optional settings', positionals: [ - { name: 'name', type: 'string', description: 'Environment name (lowercase, hyphens, underscores)', required: false }, + { + name: 'name', + type: 'string', + description: 'Environment name (lowercase, hyphens, underscores)', + required: false, + }, { name: 'apiKey', type: 'string', description: 'WorkOS API key (sk_live_* or sk_test_*)', required: false }, ], options: [ - { name: 'client-id', type: 'string', description: 'WorkOS client ID for this environment', required: false, hidden: false }, + { + name: 'client-id', + type: 'string', + description: 'WorkOS client ID for this environment', + required: false, + hidden: false, + }, { name: 'endpoint', type: 'string', description: 'Custom API endpoint URL', required: false, hidden: false }, ], }, { name: 'remove', description: 'Remove an environment configuration', - positionals: [ - { name: 'name', type: 'string', description: 'Environment name to remove', required: true }, - ], + positionals: [{ name: 'name', type: 'string', description: 'Environment name to remove', required: true }], }, { name: 'switch', description: 'Switch the active environment (determines which API key is used)', - positionals: [ - { name: 'name', type: 'string', description: 'Environment name to activate', required: false }, - ], + positionals: [{ name: 'name', type: 'string', description: 'Environment name to activate', required: false }], }, { name: 'list', @@ -154,7 +242,12 @@ const commands: CommandSchema[] = [ description: 'Create a new organization with optional verified domains', positionals: [ { name: 'name', type: 'string', description: 'Organization name', required: true }, - { name: 'domains', type: 'string', description: 'Domains in format domain:state (state defaults to verified)', required: false }, + { + name: 'domains', + type: 'string', + description: 'Domains in format domain:state (state defaults to verified)', + required: false, + }, ], }, { @@ -170,24 +263,26 @@ const commands: CommandSchema[] = [ { name: 'get', description: 'Get an organization by its ID', - positionals: [ - { name: 'orgId', type: 'string', description: 'Organization ID (org_*)', required: true }, - ], + positionals: [{ name: 'orgId', type: 'string', description: 'Organization ID (org_*)', required: true }], }, { name: 'list', description: 'List organizations with optional filters and pagination', options: [ - { name: 'domain', type: 'string', description: 'Filter organizations by domain', required: false, hidden: false }, + { + name: 'domain', + type: 'string', + description: 'Filter organizations by domain', + required: false, + hidden: false, + }, ...paginationOpts, ], }, { name: 'delete', description: 'Delete an organization by its ID', - positionals: [ - { name: 'orgId', type: 'string', description: 'Organization ID (org_*)', required: true }, - ], + positionals: [{ name: 'orgId', type: 'string', description: 'Organization ID (org_*)', required: true }], }, ], }, @@ -199,39 +294,57 @@ const commands: CommandSchema[] = [ { name: 'get', description: 'Get a user by their ID', - positionals: [ - { name: 'userId', type: 'string', description: 'User ID (user_*)', required: true }, - ], + positionals: [{ name: 'userId', type: 'string', description: 'User ID (user_*)', required: true }], }, { name: 'list', description: 'List users with optional filters and pagination', options: [ - { name: 'email', type: 'string', description: 'Filter users by email address', required: false, hidden: false }, - { name: 'organization', type: 'string', description: 'Filter users by organization ID', required: false, hidden: false }, + { + name: 'email', + type: 'string', + description: 'Filter users by email address', + required: false, + hidden: false, + }, + { + name: 'organization', + type: 'string', + description: 'Filter users by organization ID', + required: false, + hidden: false, + }, ...paginationOpts, ], }, { name: 'update', description: 'Update user properties (name, email verification, password, external ID)', - positionals: [ - { name: 'userId', type: 'string', description: 'User ID (user_*)', required: true }, - ], + positionals: [{ name: 'userId', type: 'string', description: 'User ID (user_*)', required: true }], options: [ { name: 'first-name', type: 'string', description: 'First name', required: false, hidden: false }, { name: 'last-name', type: 'string', description: 'Last name', required: false, hidden: false }, - { name: 'email-verified', type: 'boolean', description: 'Email verification status', required: false, hidden: false }, + { + name: 'email-verified', + type: 'boolean', + description: 'Email verification status', + required: false, + hidden: false, + }, { name: 'password', type: 'string', description: 'New password', required: false, hidden: false }, - { name: 'external-id', type: 'string', description: 'External ID for cross-system mapping', required: false, hidden: false }, + { + name: 'external-id', + type: 'string', + description: 'External ID for cross-system mapping', + required: false, + hidden: false, + }, ], }, { name: 'delete', description: 'Delete a user by their ID', - positionals: [ - { name: 'userId', type: 'string', description: 'User ID (user_*)', required: true }, - ], + positionals: [{ name: 'userId', type: 'string', description: 'User ID (user_*)', required: true }], }, ], }, @@ -239,22 +352,90 @@ const commands: CommandSchema[] = [ name: 'install', description: 'Install WorkOS AuthKit into your project (interactive framework detection and setup)', options: [ - { name: 'direct', type: 'boolean', description: 'Use your own Anthropic API key (bypass llm-gateway)', required: false, default: false, alias: 'D', hidden: false }, - { name: 'debug', type: 'boolean', description: 'Enable verbose logging', required: false, default: false, hidden: false }, + { + name: 'direct', + type: 'boolean', + description: 'Use your own Anthropic API key (bypass llm-gateway)', + required: false, + default: false, + alias: 'D', + hidden: false, + }, + { + name: 'debug', + type: 'boolean', + description: 'Enable verbose logging', + required: false, + default: false, + hidden: false, + }, insecureStorageOpt, - { name: 'homepage-url', type: 'string', description: 'App homepage URL for WorkOS (defaults to http://localhost:{port})', required: false, hidden: false }, - { name: 'redirect-uri', type: 'string', description: 'Redirect URI for WorkOS callback (defaults to framework convention)', required: false, hidden: false }, - { name: 'no-validate', type: 'boolean', description: 'Skip post-installation validation (includes build check)', required: false, default: false, hidden: false }, - { name: 'install-dir', type: 'string', description: 'Directory to install WorkOS AuthKit in (defaults to cwd)', required: false, hidden: false }, - { name: 'integration', type: 'string', description: 'Framework integration to set up (auto-detected if omitted)', required: false, hidden: false }, - { name: 'force-install', type: 'boolean', description: 'Force install packages even if peer dependency checks fail', required: false, default: false, hidden: false }, - { name: 'dashboard', type: 'boolean', description: 'Run with visual dashboard mode', required: false, default: false, alias: 'd', hidden: false }, + { + name: 'homepage-url', + type: 'string', + description: 'App homepage URL for WorkOS (defaults to http://localhost:{port})', + required: false, + hidden: false, + }, + { + name: 'redirect-uri', + type: 'string', + description: 'Redirect URI for WorkOS callback (defaults to framework convention)', + required: false, + hidden: false, + }, + { + name: 'no-validate', + type: 'boolean', + description: 'Skip post-installation validation (includes build check)', + required: false, + default: false, + hidden: false, + }, + { + name: 'install-dir', + type: 'string', + description: 'Directory to install WorkOS AuthKit in (defaults to cwd)', + required: false, + hidden: false, + }, + { + name: 'integration', + type: 'string', + description: 'Framework integration to set up (auto-detected if omitted)', + required: false, + hidden: false, + }, + { + name: 'force-install', + type: 'boolean', + description: 'Force install packages even if peer dependency checks fail', + required: false, + default: false, + hidden: false, + }, + { + name: 'dashboard', + type: 'boolean', + description: 'Run with visual dashboard mode', + required: false, + default: false, + alias: 'd', + hidden: false, + }, ], }, ]; const globalOptions: OptionSchema[] = [ - { name: 'json', type: 'boolean', description: 'Output results as JSON (auto-enabled in non-TTY environments)', required: false, default: false, hidden: false }, + { + name: 'json', + type: 'boolean', + description: 'Output results as JSON (auto-enabled in non-TTY environments)', + required: false, + default: false, + hidden: false, + }, { name: 'help', type: 'boolean', description: 'Show help', required: false, alias: 'h', hidden: false }, { name: 'version', type: 'boolean', description: 'Show version number', required: false, alias: 'v', hidden: false }, ]; diff --git a/src/utils/output.ts b/src/utils/output.ts index bcc5d62..7f90b3c 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -25,6 +25,7 @@ let currentMode: OutputMode = 'human'; export function resolveOutputMode(jsonFlag?: boolean): OutputMode { if (jsonFlag) return 'json'; if (process.env.WORKOS_FORCE_TTY) return 'human'; + if (process.env.WORKOS_NO_PROMPT === '1' || process.env.WORKOS_NO_PROMPT === 'true') return 'json'; if (!process.stdout.isTTY) return 'json'; return 'human'; } From 4c714500da25d5643e3665ec8d917b7a7718d32c Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sat, 28 Feb 2026 13:57:36 -0600 Subject: [PATCH 07/11] fix: clear stale credentials on refresh token failure When the refresh token is expired or invalidated (e.g., after rotation), the old credentials remained in the keyring. This caused a loop where every CLI invocation found dead creds, tried to refresh, failed, and opened the browser for login. Now clearCredentials() is called before triggering login on all failure paths: invalid_grant, network/server errors, no refresh token, and invalid credentials file. The next run sees "no credentials" and prompts a clean login instead of retrying with stale tokens. --- src/lib/ensure-auth.spec.ts | 70 ++++++++++++++++++++++++++++++++++++- src/lib/ensure-auth.ts | 12 ++++--- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/src/lib/ensure-auth.spec.ts b/src/lib/ensure-auth.spec.ts index f4aa500..76a5da2 100644 --- a/src/lib/ensure-auth.spec.ts +++ b/src/lib/ensure-auth.spec.ts @@ -74,7 +74,7 @@ vi.mock('./token-refresh-client.js', () => ({ })); // Import after mocks are set up -const { saveCredentials, getCredentials, setInsecureStorage } = await import('./credentials.js'); +const { saveCredentials, getCredentials, setInsecureStorage, hasCredentials } = await import('./credentials.js'); const { ensureAuthenticated } = await import('./ensure-auth.js'); describe('ensure-auth', () => { @@ -283,6 +283,74 @@ describe('ensure-auth', () => { expect(mockRefreshAccessToken).toHaveBeenCalledWith('https://auth.test.com', 'test_client_id'); }); + describe('credential clearing on failure', () => { + it('clears stale credentials when refresh fails with invalid_grant', async () => { + saveCredentials(expiredAccessCreds); + expect(hasCredentials()).toBe(true); + + mockRefreshAccessToken.mockResolvedValue({ + success: false, + errorType: 'invalid_grant', + error: 'Refresh token expired', + }); + + mockRunLogin.mockImplementation(() => { + saveCredentials(validCreds); + }); + + await ensureAuthenticated(); + + // Credentials were cleared before runLogin, then runLogin saved new ones + expect(mockRunLogin).toHaveBeenCalledOnce(); + }); + + it('clears credentials when refresh fails with network error', async () => { + saveCredentials(expiredAccessCreds); + + mockRefreshAccessToken.mockResolvedValue({ + success: false, + errorType: 'network', + error: 'Network error', + }); + + // Don't save new creds in login — verify old ones were cleared + mockRunLogin.mockImplementation(() => {}); + + await ensureAuthenticated(); + + // Old stale credentials should be gone + expect(hasCredentials()).toBe(false); + }); + + it('clears credentials when no refresh token available', async () => { + saveCredentials(expiredCredsNoRefresh); + + mockRunLogin.mockImplementation(() => {}); + + await ensureAuthenticated(); + + expect(hasCredentials()).toBe(false); + }); + + it('does NOT clear credentials on successful refresh', async () => { + saveCredentials(expiredAccessCreds); + + mockRefreshAccessToken.mockResolvedValue({ + success: true, + accessToken: 'new_access_token', + expiresAt: Date.now() + 3600000, + refreshToken: 'new_refresh_token', + }); + + const result = await ensureAuthenticated(); + + expect(result.authenticated).toBe(true); + expect(hasCredentials()).toBe(true); + const creds = getCredentials(); + expect(creds?.accessToken).toBe('new_access_token'); + }); + }); + describe('non-TTY mode', () => { beforeEach(() => { mockIsNonInteractive.mockReturnValue(true); diff --git a/src/lib/ensure-auth.ts b/src/lib/ensure-auth.ts index cb80aab..f54ced4 100644 --- a/src/lib/ensure-auth.ts +++ b/src/lib/ensure-auth.ts @@ -2,7 +2,7 @@ * Startup auth guard - ensures valid authentication before command execution. */ -import { getCredentials, updateTokens, hasCredentials, isTokenExpired } from './credentials.js'; +import { getCredentials, updateTokens, hasCredentials, isTokenExpired, clearCredentials } from './credentials.js'; import { refreshAccessToken } from './token-refresh-client.js'; import { getCliAuthClientId, getAuthkitDomain } from './settings.js'; import { runLogin } from '../commands/login.js'; @@ -50,7 +50,8 @@ export async function ensureAuthenticated(): Promise { const creds = getCredentials(); if (!creds) { - // Credentials file exists but is invalid/empty + // Credentials file exists but is invalid/empty — clear stale data + clearCredentials(); if (isNonInteractiveEnvironment()) { exitWithAuthRequired(); } @@ -86,6 +87,7 @@ export async function ensureAuthenticated(): Promise { // Refresh failed - check if it's recoverable if (refreshResult.errorType === 'invalid_grant') { + clearCredentials(); if (isNonInteractiveEnvironment()) { exitWithAuthRequired('Session expired. Run `workos login` in an interactive terminal to re-authenticate.'); } @@ -96,7 +98,8 @@ export async function ensureAuthenticated(): Promise { return result; } - // Network or server error - try login as fallback + // Network or server error - clear stale creds and try login as fallback + clearCredentials(); if (isNonInteractiveEnvironment()) { exitWithAuthRequired( `Authentication refresh failed (${refreshResult.errorType}). Run \`workos login\` in an interactive terminal.`, @@ -110,7 +113,8 @@ export async function ensureAuthenticated(): Promise { } } - // Case 4: No refresh token available, must login + // Case 4: No refresh token available — clear stale creds, must login + clearCredentials(); if (isNonInteractiveEnvironment()) { exitWithAuthRequired('Session expired. Run `workos login` in an interactive terminal to re-authenticate.'); } From 84e104a95b5f9502945e73f68542d38d3e70d247 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sat, 28 Feb 2026 14:11:16 -0600 Subject: [PATCH 08/11] fix: redact API keys in env list JSON and strict WORKOS_FORCE_TTY check - env list --json no longer leaks full API keys; outputs hasApiKey and hasClientId booleans instead - WORKOS_FORCE_TTY now requires explicit '1' or 'true' value, matching WORKOS_NO_PROMPT behavior (setting WORKOS_FORCE_TTY=false no longer incorrectly forces TTY mode) --- src/commands/env.ts | 6 +++++- src/utils/environment.ts | 2 +- src/utils/output.ts | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/commands/env.ts b/src/commands/env.ts index 8d6eaea..a7ea781 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -190,8 +190,12 @@ export async function runEnvList(): Promise { if (isJsonMode()) { const data = entries.map(([key, env]) => ({ - ...env, + name: key, + type: env.type, active: key === config.activeEnvironment, + endpoint: env.endpoint ?? null, + hasApiKey: !!env.apiKey, + hasClientId: !!env.clientId, })); outputJson({ data }); return; diff --git a/src/utils/environment.ts b/src/utils/environment.ts index b701912..bab2488 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -10,7 +10,7 @@ export function isNonInteractiveEnvironment(): boolean { } // WORKOS_FORCE_TTY forces interactive regardless of TTY - if (process.env.WORKOS_FORCE_TTY) { + if (process.env.WORKOS_FORCE_TTY === '1' || process.env.WORKOS_FORCE_TTY === 'true') { return false; } diff --git a/src/utils/output.ts b/src/utils/output.ts index 7f90b3c..82999e8 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -24,7 +24,7 @@ let currentMode: OutputMode = 'human'; */ export function resolveOutputMode(jsonFlag?: boolean): OutputMode { if (jsonFlag) return 'json'; - if (process.env.WORKOS_FORCE_TTY) return 'human'; + if (process.env.WORKOS_FORCE_TTY === '1' || process.env.WORKOS_FORCE_TTY === 'true') return 'human'; if (process.env.WORKOS_NO_PROMPT === '1' || process.env.WORKOS_NO_PROMPT === 'true') return 'json'; if (!process.stdout.isTTY) return 'json'; return 'human'; From ab9ca79b4a73eb2fe2f5161bd5b397595bbad260 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sat, 28 Feb 2026 14:13:01 -0600 Subject: [PATCH 09/11] chore: formatting --- src/lib/api-key.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/api-key.spec.ts b/src/lib/api-key.spec.ts index 1ae097d..0215a61 100644 --- a/src/lib/api-key.spec.ts +++ b/src/lib/api-key.spec.ts @@ -90,9 +90,7 @@ describe('api-key', () => { it('exits with error when no API key available', () => { expect(() => resolveApiKey()).toThrow(ExitError); - expect(mockExitWithError).toHaveBeenCalledWith( - expect.objectContaining({ code: 'no_api_key' }), - ); + expect(mockExitWithError).toHaveBeenCalledWith(expect.objectContaining({ code: 'no_api_key' })); }); it('exits with error when config exists but no active environment', () => { From 12e2e3638e8f40f6a023b9b59665a68738b0d5a6 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sat, 28 Feb 2026 14:21:03 -0600 Subject: [PATCH 10/11] refactor: remove type cast slop from output utilities - Change outputSuccess data param from Record to object, eliminating as unknown as Record double casts in org/user commands - Remove redundant section divider comment in headless-adapter --- src/commands/organization.ts | 4 ++-- src/commands/user.ts | 2 +- src/lib/adapters/headless-adapter.ts | 2 -- src/utils/output.ts | 2 +- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/commands/organization.ts b/src/commands/organization.ts index 7f4255a..9434795 100644 --- a/src/commands/organization.ts +++ b/src/commands/organization.ts @@ -74,7 +74,7 @@ export async function runOrgCreate( baseUrl, body, }); - outputSuccess('Created organization', org as unknown as Record); + outputSuccess('Created organization', org); } catch (error) { handleApiError(error); } @@ -101,7 +101,7 @@ export async function runOrgUpdate( baseUrl, body, }); - outputSuccess('Updated organization', org as unknown as Record); + outputSuccess('Updated organization', org); } catch (error) { handleApiError(error); } diff --git a/src/commands/user.ts b/src/commands/user.ts index 86028a5..051f046 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -148,7 +148,7 @@ export async function runUserUpdate( baseUrl, body, }); - outputSuccess('Updated user', user as unknown as Record); + outputSuccess('Updated user', user); } catch (error) { handleApiError(error); } diff --git a/src/lib/adapters/headless-adapter.ts b/src/lib/adapters/headless-adapter.ts index 2d5b7fb..cea532e 100644 --- a/src/lib/adapters/headless-adapter.ts +++ b/src/lib/adapters/headless-adapter.ts @@ -108,8 +108,6 @@ export class HeadlessAdapter implements InstallerAdapter { this.isStarted = false; } - // ===== Helpers ===== - private subscribe( event: K, handler: (payload: InstallerEvents[K]) => void | Promise, diff --git a/src/utils/output.ts b/src/utils/output.ts index 82999e8..1bf6dcc 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -51,7 +51,7 @@ export function outputJson(data: unknown): void { } /** Write a success result — chalk in human mode, JSON in json mode. */ -export function outputSuccess(message: string, data?: Record): void { +export function outputSuccess(message: string, data?: object): void { if (currentMode === 'json') { console.log(JSON.stringify({ status: 'ok', message, ...data })); } else { From c7cf08e086d756159061b96c7657f2524a550b18 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sat, 28 Feb 2026 14:27:10 -0600 Subject: [PATCH 11/11] refactor: extract shared API error handler and simplify - Extract duplicated handleApiError from organization.ts and user.ts into shared createApiErrorHandler() factory in lib/api-error-handler.ts - Cache hideBin(process.argv) result to avoid double-parsing in bin.ts - Simplify flag inversion mapping in run.ts (remove verbose ternaries) - Lazy-import HeadlessAdapter in run-with-core.ts to avoid loading it in interactive mode --- src/bin.ts | 4 ++-- src/commands/organization.ts | 26 ++++---------------------- src/commands/user.ts | 26 ++++---------------------- src/lib/api-error-handler.ts | 29 +++++++++++++++++++++++++++++ src/lib/run-with-core.ts | 2 +- src/run.ts | 8 ++++---- 6 files changed, 44 insertions(+), 51 deletions(-) create mode 100644 src/lib/api-error-handler.ts diff --git a/src/bin.ts b/src/bin.ts index 942ec18..f2fa849 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -177,7 +177,7 @@ const installerOptions = { // Check for updates (blocks up to 500ms) await checkForUpdates(); -yargs(hideBin(process.argv)) +yargs(rawArgs) .env('WORKOS_INSTALLER') .option('json', { type: 'boolean', @@ -545,7 +545,7 @@ yargs(hideBin(process.argv)) async (argv) => { // Non-TTY: show help if (isNonInteractiveEnvironment()) { - yargs(hideBin(process.argv)).showHelp(); + yargs(rawArgs).showHelp(); return; } diff --git a/src/commands/organization.ts b/src/commands/organization.ts index 9434795..672f7ed 100644 --- a/src/commands/organization.ts +++ b/src/commands/organization.ts @@ -1,8 +1,9 @@ import chalk from 'chalk'; -import { workosRequest, WorkOSApiError } from '../lib/workos-api.js'; +import { workosRequest } from '../lib/workos-api.js'; import type { WorkOSListResponse } from '../lib/workos-api.js'; import { formatTable } from '../utils/table.js'; -import { exitWithError, outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; +import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; interface OrganizationDomain { id: string; @@ -33,26 +34,7 @@ export function parseDomainArgs(args: string[]): DomainData[] { }); } -function handleApiError(error: unknown): never { - if (error instanceof WorkOSApiError) { - exitWithError({ - code: error.code ?? `http_${error.statusCode}`, - message: - error.statusCode === 401 - ? 'Invalid API key. Check your environment configuration.' - : error.statusCode === 404 - ? 'Organization not found.' - : error.statusCode === 422 && error.errors?.length - ? error.errors.map((e) => e.message).join(', ') - : error.message, - details: error.errors, - }); - } - exitWithError({ - code: 'unknown_error', - message: error instanceof Error ? error.message : 'Unknown error', - }); -} +const handleApiError = createApiErrorHandler('Organization'); export async function runOrgCreate( name: string, diff --git a/src/commands/user.ts b/src/commands/user.ts index 051f046..262891c 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -1,8 +1,9 @@ import chalk from 'chalk'; -import { workosRequest, WorkOSApiError } from '../lib/workos-api.js'; +import { workosRequest } from '../lib/workos-api.js'; import type { WorkOSListResponse } from '../lib/workos-api.js'; import { formatTable } from '../utils/table.js'; -import { exitWithError, outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; +import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; interface User { id: string; @@ -14,26 +15,7 @@ interface User { updated_at: string; } -function handleApiError(error: unknown): never { - if (error instanceof WorkOSApiError) { - exitWithError({ - code: error.code ?? `http_${error.statusCode}`, - message: - error.statusCode === 401 - ? 'Invalid API key. Check your environment configuration.' - : error.statusCode === 404 - ? 'User not found.' - : error.statusCode === 422 && error.errors?.length - ? error.errors.map((e) => e.message).join(', ') - : error.message, - details: error.errors, - }); - } - exitWithError({ - code: 'unknown_error', - message: error instanceof Error ? error.message : 'Unknown error', - }); -} +const handleApiError = createApiErrorHandler('User'); export async function runUserGet(userId: string, apiKey: string, baseUrl?: string): Promise { try { diff --git a/src/lib/api-error-handler.ts b/src/lib/api-error-handler.ts new file mode 100644 index 0000000..bff4c26 --- /dev/null +++ b/src/lib/api-error-handler.ts @@ -0,0 +1,29 @@ +import { WorkOSApiError } from './workos-api.js'; +import { exitWithError } from '../utils/output.js'; + +/** + * Create a resource-specific API error handler. + * Returns a `never` function that writes structured errors and exits. + */ +export function createApiErrorHandler(resourceName: string) { + return (error: unknown): never => { + if (error instanceof WorkOSApiError) { + exitWithError({ + code: error.code ?? `http_${error.statusCode}`, + message: + error.statusCode === 401 + ? 'Invalid API key. Check your environment configuration.' + : error.statusCode === 404 + ? `${resourceName} not found.` + : error.statusCode === 422 && error.errors?.length + ? error.errors.map((e) => e.message).join(', ') + : error.message, + details: error.errors, + }); + } + exitWithError({ + code: 'unknown_error', + message: error instanceof Error ? error.message : 'Unknown error', + }); + }; +} diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index c0cf0ce..57bcbc8 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -6,7 +6,6 @@ import { installerMachine } from './installer-core.js'; import { createInstallerEventEmitter } from './events.js'; import { CLIAdapter } from './adapters/cli-adapter.js'; import { DashboardAdapter } from './adapters/dashboard-adapter.js'; -import { HeadlessAdapter } from './adapters/headless-adapter.js'; import type { InstallerAdapter } from './adapters/types.js'; import type { InstallerOptions } from '../utils/types.js'; import { isNonInteractiveEnvironment } from '../utils/environment.js'; @@ -197,6 +196,7 @@ export async function runWithCore(options: InstallerOptions): Promise { let adapter: InstallerAdapter; if (isNonInteractiveEnvironment()) { + const { HeadlessAdapter } = await import('./adapters/headless-adapter.js'); adapter = new HeadlessAdapter({ emitter, sendEvent, diff --git a/src/run.ts b/src/run.ts index 8252738..fcb0dc4 100644 --- a/src/run.ts +++ b/src/run.ts @@ -67,11 +67,11 @@ function buildOptions(argv: InstallerArgs): InstallerOptions { dashboard: merged.dashboard ?? false, integration: merged.integration, inspect: merged.inspect ?? false, - noValidate: merged.noValidate ?? (merged.validate === false ? true : false), - noCommit: merged.noCommit ?? (merged.commit === false ? true : false), - noBranch: merged.noBranch ?? (merged.branch === false ? true : false), + noValidate: merged.noValidate ?? merged.validate === false, + noCommit: merged.noCommit ?? merged.commit === false, + noBranch: merged.noBranch ?? merged.branch === false, createPr: merged.createPr ?? false, - noGitCheck: merged.noGitCheck ?? (merged.gitCheck === false ? true : false), + noGitCheck: merged.noGitCheck ?? merged.gitCheck === false, direct: merged.direct ?? false, emitter: createInstallerEventEmitter(), // Will be replaced in runWithCore };