From 36c47e9d256cdf8b29fbd065b89de57e68e5f8a5 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 7 Jul 2026 16:04:46 -0500 Subject: [PATCH] fix: unblock agent/CI non-interactive CLI paths Removes the two hard dead-ends an AI agent or CI pipeline hits first when driving the CLI non-interactively: - Bare `workos` in a non-TTY shell now emits the machine-readable command tree (JSON) or the fully-configured help, instead of a degenerate two-line block on stderr with empty stdout. Agents and CI can now discover `install`. - Non-interactive installs no longer hang on interactive prompts. `abortIfCancelled` fails fast with a structured `non_interactive_prompt` error (Layer 1), and Next.js, React Router, and upload-env apply graceful per-site defaults so installs succeed (Layer 2). - `--router app|pages` deterministically selects the Next.js router, winning over detection. WORKOS_MODE=ci now enforces the same required-arg validation as the hidden --ci flag and bridges to the downstream `options.ci` paths (git checks auto-continue, package-manager auto-select). Behavior change: `WORKOS_MODE=ci workos install` without --api-key/--client-id/--install-dir now errors with a structured missing_args message instead of falling through to auto-provisioning. --- src/bin-default-command.integration.spec.ts | 115 +++++++++++++++++ src/bin.ts | 15 ++- src/commands/install.spec.ts | 48 ++++++- src/commands/install.ts | 5 +- src/integrations/nextjs/utils.spec.ts | 118 ++++++++++++++++++ src/integrations/nextjs/utils.ts | 24 +++- src/integrations/react-router/utils.spec.ts | 64 ++++++++++ src/integrations/react-router/utils.ts | 22 ++++ src/run.ts | 7 +- .../index.spec.ts | 61 +++++++++ .../upload-environment-variables/index.ts | 13 ++ src/utils/clack-utils.spec.ts | 80 ++++++++++++ src/utils/clack-utils.ts | 24 ++++ src/utils/help-json.ts | 7 ++ src/utils/types.ts | 3 + 15 files changed, 598 insertions(+), 8 deletions(-) create mode 100644 src/bin-default-command.integration.spec.ts create mode 100644 src/integrations/nextjs/utils.spec.ts create mode 100644 src/integrations/react-router/utils.spec.ts create mode 100644 src/steps/upload-environment-variables/index.spec.ts create mode 100644 src/utils/clack-utils.spec.ts diff --git a/src/bin-default-command.integration.spec.ts b/src/bin-default-command.integration.spec.ts new file mode 100644 index 00000000..42b4b22c --- /dev/null +++ b/src/bin-default-command.integration.spec.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Integration test for the fixed `$0` default-command output. + * + * bin.ts runs runCli() at import and exposes no seams, so the only honest way + * to prove the friction fix is to drive the real CLI as a subprocess and read + * its streams. Pre-fix, a bare non-TTY invocation ran `yargs(rawArgs).showHelp()` + * on a FRESH yargs instance (zero commands registered), emitting a degenerate + * two-line `--help`/`--version` block to stderr and leaving stdout empty — a + * piped agent/CI consumer got nothing and never discovered `install`. + * + * We use a LOCAL spawn helper (not the shared one in + * bin-command-telemetry.integration.spec.ts) because that one hardcodes + * WORKOS_MODE=agent, which the CI and human-force-TTY cases must omit. The env + * is built clean (spawnSync replaces, not merges) so host agent/CI markers are + * absent unless a case adds them. + */ +const binPath = fileURLToPath(new URL('./bin.ts', import.meta.url)); +const forceInsecureStorageImport = new URL('./test/force-insecure-storage.ts', import.meta.url).href; +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); + +let sandboxTmp: string; + +beforeEach(() => { + sandboxTmp = mkdtempSync(join(tmpdir(), 'wos-cli-default-it-')); +}); + +afterEach(() => { + rmSync(sandboxTmp, { recursive: true, force: true }); +}); + +function runCli(args: string[], envOverrides: NodeJS.ProcessEnv = {}) { + const env: NodeJS.ProcessEnv = { + PATH: process.env.PATH, + HOME: sandboxTmp, + USERPROFILE: sandboxTmp, + TMPDIR: sandboxTmp, + TMP: sandboxTmp, + TEMP: sandboxTmp, + // Keep machine streams clean: no telemetry, no update check network calls. + WORKOS_TELEMETRY: 'false', + // Unroutable API base (defense-in-depth; the $0 path never hits the network). + WORKOS_API_URL: 'http://127.0.0.1:59999', + // Per-case env last. Agent/CI markers are absent unless a case adds one. + ...envOverrides, + }; + + return spawnSync(process.execPath, ['--import', 'tsx', '--import', forceInsecureStorageImport, binPath, ...args], { + cwd: repoRoot, + encoding: 'utf-8', + env, + }); +} + +describe('bin $0 default command (non-TTY)', () => { + it('agent mode emits the full machine-readable command tree on stdout', () => { + const result = runCli([], { WORKOS_MODE: 'agent' }); + + expect(result.status).toBe(0); + + const tree = JSON.parse(result.stdout.trim()); + expect(tree.name).toBe('workos'); + expect(tree.version).toBeTruthy(); + + const names = tree.commands.map((c: { name: string }) => c.name); + expect(names).toEqual(expect.arrayContaining(['install', 'auth login', 'organization', 'doctor'])); + // The installer must be discoverable — this is the friction being fixed. + expect(tree.commands.some((c: { name: string }) => c.name === 'install')).toBe(true); + + // Regression guard: never emit the degenerate fresh-yargs help block. + expect(result.stderr).not.toContain('Show version number'); + }, 20_000); + + it('CI mode (CI=1, no WORKOS_MODE) emits the command tree on stdout', () => { + const result = runCli([], { CI: '1' }); + + expect(result.status).toBe(0); + + const tree = JSON.parse(result.stdout.trim()); + expect(tree.name).toBe('workos'); + expect(tree.version).toBeTruthy(); + + const names = tree.commands.map((c: { name: string }) => c.name); + expect(names).toEqual(expect.arrayContaining(['install', 'auth login', 'organization', 'doctor'])); + expect(result.stderr).not.toContain('Show version number'); + }, 20_000); + + it('human non-TTY edge (WORKOS_FORCE_TTY=1) shows the configured help, not JSON', () => { + // WORKOS_MODE omitted entirely (never set to '' — that throws InvalidInteractionModeError). + const result = runCli([], { WORKOS_FORCE_TTY: '1' }); + + expect(result.status).toBe(0); + + const combined = `${result.stdout}\n${result.stderr}`; + // parser.showHelp() (not the fresh-yargs block) lists the full command set. + expect(combined).toMatch(/install/); + expect(combined).toMatch(/organization/); + expect(combined).toMatch(/auth/); + }, 20_000); + + it('--help --json still returns the command tree (regression for the intercept)', () => { + const result = runCli(['--help', '--json'], { WORKOS_MODE: 'agent' }); + + expect(result.status).toBe(0); + const parsed = JSON.parse(result.stdout.trim()); + expect(Array.isArray(parsed.commands)).toBe(true); + expect(parsed.commands.length).toBeGreaterThan(0); + }, 20_000); +}); diff --git a/src/bin.ts b/src/bin.ts index 2312ba09..eb6b88be 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -241,6 +241,11 @@ const installerOptions = { choices: ['npm', 'pnpm', 'yarn', 'bun'] as const, type: 'string' as const, }, + router: { + choices: ['app', 'pages'] as const, + describe: 'Next.js router to target when detection is ambiguous (app or pages)', + type: 'string' as const, + }, }; // Check for updates (blocks up to 500ms, skip in JSON/non-human modes to keep machine streams clean) @@ -2677,9 +2682,15 @@ async function runCli(): Promise { 'WorkOS AuthKit CLI', (yargs) => yargs.options(insecureStorageOption), async (argv) => { - // Non-human modes: show help instead of prompting + // Non-human modes: emit machine-readable command tree (JSON) or the + // fully-configured parser help (human non-TTY edge) instead of prompting. if (!isPromptAllowed()) { - yargs(rawArgs).showHelp(); + if (isJsonMode()) { + const { buildCommandTree } = await import('./utils/help-json.js'); + outputJson(buildCommandTree()); + } else { + parser.showHelp(); + } return; } diff --git a/src/commands/install.spec.ts b/src/commands/install.spec.ts index df19b169..b686bd90 100644 --- a/src/commands/install.spec.ts +++ b/src/commands/install.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; vi.mock('../run.js', () => ({ runInstaller: vi.fn(), @@ -31,8 +31,9 @@ const { runInstaller } = await import('../run.js'); const { autoInstallSkills } = await import('./install-skill.js'); const { maybeOfferMcpInstall } = await import('../lib/mcp-notice.js'); const clack = (await import('../utils/clack.js')).default; -const { isJsonMode } = await import('../utils/output.js'); +const { isJsonMode, exitWithError } = await import('../utils/output.js'); const { CliExit } = await import('../utils/cli-exit.js'); +const { setInteractionMode, resetInteractionModeForTests } = await import('../utils/interaction-mode.js'); const { handleInstall } = await import('./install.js'); @@ -41,6 +42,10 @@ describe('handleInstall', () => { vi.clearAllMocks(); }); + afterEach(() => { + resetInteractionModeForTests(); + }); + it('calls autoInstallSkills after successful install', async () => { vi.mocked(runInstaller).mockResolvedValue(undefined as any); vi.mocked(autoInstallSkills).mockResolvedValue(null); @@ -126,4 +131,43 @@ describe('handleInstall', () => { expect(runInstaller).toHaveBeenCalledOnce(); expect(autoInstallSkills).toHaveBeenCalledOnce(); }); + + describe('CI-mode required-arg validation', () => { + it('WORKOS_MODE=ci requires --api-key (validation triggered without the --ci flag)', async () => { + vi.mocked(runInstaller).mockResolvedValue(undefined as any); + vi.mocked(autoInstallSkills).mockResolvedValue(null); + setInteractionMode({ mode: 'ci', source: 'env' }); + + await handleInstall({ _: ['install'], $0: 'workos' } as any); + + expect(exitWithError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'missing_args', message: expect.stringContaining('--api-key') }), + ); + }); + + it('WORKOS_MODE=ci with all required args does not error', async () => { + vi.mocked(runInstaller).mockResolvedValue(undefined as any); + vi.mocked(autoInstallSkills).mockResolvedValue(null); + setInteractionMode({ mode: 'ci', source: 'env' }); + + await handleInstall({ + _: ['install'], + $0: 'workos', + apiKey: 'sk_test', + clientId: 'client_x', + installDir: '/tmp/x', + } as any); + + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it('default (human) mode does not trigger CI validation', async () => { + vi.mocked(runInstaller).mockResolvedValue(undefined as any); + vi.mocked(autoInstallSkills).mockResolvedValue(null); + + await handleInstall({ _: ['install'], $0: 'workos' } as any); + + expect(exitWithError).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/commands/install.ts b/src/commands/install.ts index cc538be6..8e4eb75f 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -3,6 +3,7 @@ import type { InstallerArgs } from '../run.js'; import clack from '../utils/clack.js'; import { exitWithError, isJsonMode } from '../utils/output.js'; import { ExitCode, exitWithCode } from '../utils/exit-codes.js'; +import { isCiMode } from '../utils/interaction-mode.js'; import type { ArgumentsCamelCase } from 'yargs'; import { autoInstallSkills } from './install-skill.js'; import { maybeOfferMcpInstall } from '../lib/mcp-notice.js'; @@ -13,8 +14,8 @@ import { maybeOfferMcpInstall } from '../lib/mcp-notice.js'; export async function handleInstall(argv: ArgumentsCamelCase): Promise { const options = { ...argv }; - // CI mode validation - if (options.ci) { + // CI mode validation — trigger for the hidden --ci flag or WORKOS_MODE=ci. + if (options.ci || isCiMode()) { if (!options.apiKey) { exitWithError({ code: 'missing_args', message: 'CI mode requires --api-key (WorkOS API key sk_xxx)' }); } diff --git a/src/integrations/nextjs/utils.spec.ts b/src/integrations/nextjs/utils.spec.ts new file mode 100644 index 00000000..13832f1d --- /dev/null +++ b/src/integrations/nextjs/utils.spec.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { InteractionMode } from '../../utils/interaction-mode.js'; + +vi.mock('fast-glob', () => ({ default: vi.fn() })); + +vi.mock('../../utils/clack.js', () => ({ + default: { + select: vi.fn(), + isCancel: vi.fn(() => false), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn(), step: vi.fn(), message: vi.fn() }, + }, +})); + +// Passthrough — the guard itself is covered by clack-utils.spec.ts; here we only +// need clack.select's resolved value to flow through in the human path. +vi.mock('../../utils/clack-utils.js', () => ({ + abortIfCancelled: vi.fn(async (p) => await p), +})); + +const fg = (await import('fast-glob')).default; +const clack = (await import('../../utils/clack.js')).default; +const { getNextJsRouter, NextJsRouter } = await import('./utils.js'); +const { setInteractionMode, resetInteractionModeForTests } = await import('../../utils/interaction-mode.js'); + +/** Configure fast-glob to report presence of pages/ and/or app/ dirs. */ +function mockDetection({ pages, app }: { pages: boolean; app: boolean }): void { + vi.mocked(fg).mockImplementation((async (pattern: string) => { + if (String(pattern).includes('pages')) return pages ? ['pages/_app.tsx'] : []; + return app ? ['app/layout.tsx'] : []; + }) as never); +} + +const modes: InteractionMode[] = ['human', 'agent', 'ci']; + +describe('getNextJsRouter', () => { + beforeEach(() => { + resetInteractionModeForTests(); + vi.clearAllMocks(); + vi.mocked(clack.isCancel).mockReturnValue(false); + }); + + afterEach(() => { + resetInteractionModeForTests(); + }); + + it.each(modes)('pages-only detection returns pages router without prompting (%s mode)', async (mode) => { + setInteractionMode({ mode, source: mode === 'human' ? 'default' : 'env' }); + mockDetection({ pages: true, app: false }); + + const result = await getNextJsRouter({ installDir: '/proj' }); + + expect(result).toBe(NextJsRouter.PAGES_ROUTER); + expect(clack.select).not.toHaveBeenCalled(); + }); + + it.each(modes)('app-only detection returns app router without prompting (%s mode)', async (mode) => { + setInteractionMode({ mode, source: mode === 'human' ? 'default' : 'env' }); + mockDetection({ pages: false, app: true }); + + const result = await getNextJsRouter({ installDir: '/proj' }); + + expect(result).toBe(NextJsRouter.APP_ROUTER); + expect(clack.select).not.toHaveBeenCalled(); + }); + + it('ambiguous detection in human mode prompts and uses the answer', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockDetection({ pages: true, app: true }); + vi.mocked(clack.select).mockResolvedValueOnce(NextJsRouter.PAGES_ROUTER as never); + + const result = await getNextJsRouter({ installDir: '/proj' }); + + expect(result).toBe(NextJsRouter.PAGES_ROUTER); + expect(clack.select).toHaveBeenCalledOnce(); + }); + + it('ambiguous detection in agent mode defaults to app router with a warning (no prompt)', async () => { + setInteractionMode({ mode: 'agent', source: 'env' }); + mockDetection({ pages: true, app: true }); + + const result = await getNextJsRouter({ installDir: '/proj' }); + + expect(result).toBe(NextJsRouter.APP_ROUTER); + expect(clack.select).not.toHaveBeenCalled(); + expect(clack.log.warn).toHaveBeenCalled(); + }); + + it('ambiguous detection in ci mode defaults to app router with a warning (no prompt)', async () => { + setInteractionMode({ mode: 'ci', source: 'env' }); + mockDetection({ pages: true, app: true }); + + const result = await getNextJsRouter({ installDir: '/proj' }); + + expect(result).toBe(NextJsRouter.APP_ROUTER); + expect(clack.select).not.toHaveBeenCalled(); + expect(clack.log.warn).toHaveBeenCalled(); + }); + + it('--router pages overrides ambiguous detection with no prompt', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockDetection({ pages: true, app: true }); + + const result = await getNextJsRouter({ installDir: '/proj', router: 'pages' }); + + expect(result).toBe(NextJsRouter.PAGES_ROUTER); + expect(clack.select).not.toHaveBeenCalled(); + }); + + it('--router app wins over detection with no prompt', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockDetection({ pages: true, app: false }); + + const result = await getNextJsRouter({ installDir: '/proj', router: 'app' }); + + expect(result).toBe(NextJsRouter.APP_ROUTER); + expect(clack.select).not.toHaveBeenCalled(); + }); +}); diff --git a/src/integrations/nextjs/utils.ts b/src/integrations/nextjs/utils.ts index e627faa8..f6235e85 100644 --- a/src/integrations/nextjs/utils.ts +++ b/src/integrations/nextjs/utils.ts @@ -4,6 +4,7 @@ import clack from '../../utils/clack.js'; import { getVersionBucket } from '../../utils/semver.js'; import type { InstallerOptions } from '../../utils/types.js'; import { IGNORE_PATTERNS } from '../../lib/constants.js'; +import { isPromptAllowed } from '../../utils/interaction-mode.js'; export function getNextJsVersionBucket(version: string | undefined): string { return getVersionBucket(version, 11); @@ -14,7 +15,17 @@ export enum NextJsRouter { PAGES_ROUTER = 'pages-router', } -export async function getNextJsRouter({ installDir }: Pick): Promise { +export async function getNextJsRouter({ + installDir, + router, +}: Pick): Promise { + // Explicit flag wins over detection (deterministic for agents). + if (router) { + const chosen = router === 'pages' ? NextJsRouter.PAGES_ROUTER : NextJsRouter.APP_ROUTER; + clack.log.info(`Using ${getNextJsRouterName(chosen)} (--router)`); + return chosen; + } + const pagesMatches = await fg('**/pages/_app.@(ts|tsx|js|jsx)', { dot: true, cwd: installDir, @@ -41,6 +52,17 @@ export async function getNextJsRouter({ installDir }: Pick ({ default: vi.fn(async () => []) })); + +vi.mock('../../utils/clack.js', () => ({ + default: { + select: vi.fn(), + isCancel: vi.fn(() => false), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn(), step: vi.fn(), message: vi.fn() }, + }, +})); + +// Passthrough abortIfCancelled + a package.json with no react-router version, so +// getReactRouterMode always hits the ambiguous "no version" prompt branch. +vi.mock('../../utils/clack-utils.js', () => ({ + abortIfCancelled: vi.fn(async (p) => await p), + getPackageDotJson: vi.fn(async () => ({})), +})); + +const clack = (await import('../../utils/clack.js')).default; +const { getReactRouterMode, ReactRouterMode } = await import('./utils.js'); +const { setInteractionMode, resetInteractionModeForTests } = await import('../../utils/interaction-mode.js'); + +describe('getReactRouterMode — ambiguous-branch defaults', () => { + beforeEach(() => { + resetInteractionModeForTests(); + vi.clearAllMocks(); + vi.mocked(clack.isCancel).mockReturnValue(false); + }); + + afterEach(() => { + resetInteractionModeForTests(); + }); + + it('agent mode with no detectable version defaults to v7 Framework with a warning (no prompt)', async () => { + setInteractionMode({ mode: 'agent', source: 'env' }); + + const result = await getReactRouterMode({ installDir: '/proj' } as never); + + expect(result).toBe(ReactRouterMode.V7_FRAMEWORK); + expect(clack.select).not.toHaveBeenCalled(); + expect(clack.log.warn).toHaveBeenCalled(); + }); + + it('ci mode with no detectable version defaults to v7 Framework with a warning (no prompt)', async () => { + setInteractionMode({ mode: 'ci', source: 'env' }); + + const result = await getReactRouterMode({ installDir: '/proj' } as never); + + expect(result).toBe(ReactRouterMode.V7_FRAMEWORK); + expect(clack.select).not.toHaveBeenCalled(); + expect(clack.log.warn).toHaveBeenCalled(); + }); + + it('human mode with no detectable version prompts and uses the answer', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + vi.mocked(clack.select).mockResolvedValueOnce(ReactRouterMode.V6 as never); + + const result = await getReactRouterMode({ installDir: '/proj' } as never); + + expect(result).toBe(ReactRouterMode.V6); + expect(clack.select).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/integrations/react-router/utils.ts b/src/integrations/react-router/utils.ts index 3680097f..e52f713f 100644 --- a/src/integrations/react-router/utils.ts +++ b/src/integrations/react-router/utils.ts @@ -6,6 +6,7 @@ import { getVersionBucket } from '../../utils/semver.js'; import type { InstallerOptions } from '../../utils/types.js'; import { IGNORE_PATTERNS } from '../../lib/constants.js'; import { getPackageVersion } from '../../utils/package-json.js'; +import { isPromptAllowed } from '../../utils/interaction-mode.js'; import chalk from 'chalk'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -85,6 +86,13 @@ export async function getReactRouterMode(options: InstallerOptions): Promise