diff --git a/CLAUDE.md b/CLAUDE.md index c3c5a033..92193875 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ WorkOS CLI for installing AuthKit integrations and managing WorkOS resources (or - **Auth**: Exits code 4 instead of opening browser. Requires prior `workos auth login` or `WORKOS_API_KEY` env var. - **Errors**: Structured JSON to stderr: `{ "error": { "code": "...", "message": "..." } }` - **Exit codes**: 0=success, 1=error, 2=cancelled, 4=auth required (follows `gh` CLI convention) -- **Headless flags**: `--no-branch`, `--no-commit`, `--create-pr`, `--no-git-check` +- **Headless flags**: `--no-branch`, `--no-commit`, `--create-pr`, `--no-git-check`. CI mode (`WORKOS_MODE=ci`) auto-continues past a dirty tree without `--no-git-check`; agent mode requires the flag. ## Tech Constraints 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..7034397b 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -60,6 +60,11 @@ import { CliExit } from './utils/cli-exit.js'; import { telemetryClient } from './utils/telemetry-client.js'; import { ExitCode } from './utils/exit-codes.js'; import { analytics } from './utils/analytics.js'; +import { formatWorkOSCommand, getWorkOSCommand } from './utils/command-invocation.js'; +import { MIGRATIONS_DESCRIPTION } from './lib/constants.js'; +// Type-only import (erased at build, does not pull the handler into the startup +// path) so the `argv.method as VerifyLoginMethod` cast type-checks below. +import type { VerifyLoginMethod } from './commands/verify-login.js'; // Enable debug logging for all commands via env var. // Subsumes the installer's --debug flag for non-installer commands. @@ -241,6 +246,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) @@ -587,6 +597,46 @@ async function runCli(): Promise { await handleDoctor(argv); }, ) + .command( + 'verify-login', + 'Verify the AuthKit login loop end-to-end against the active environment (creates and deletes a throwaway user)', + (yargs) => + yargs.options({ + ...insecureStorageOption, + 'api-key': { + type: 'string' as const, + describe: 'WorkOS API key (overrides environment config). Format: sk_test_* (production keys are refused)', + }, + 'client-id': { + type: 'string' as const, + describe: 'WorkOS client ID (overrides the active environment)', + }, + method: { + type: 'string' as const, + choices: ['password'] as const, + default: 'password', + describe: 'Authentication method to verify', + }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage as boolean | undefined); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { getActiveEnvironment } = await import('./lib/config-store.js'); + const { runVerifyLogin } = await import('./commands/verify-login.js'); + + const apiKey = resolveApiKey({ apiKey: argv.apiKey as string | undefined }); // exits 4 if none + const activeEnv = getActiveEnvironment(); + + await runVerifyLogin({ + apiKey, + clientId: (argv.clientId as string | undefined) ?? activeEnv?.clientId, + baseUrl: resolveApiBaseUrl(), + envType: activeEnv?.type ?? null, + envName: activeEnv?.name, + method: argv.method as VerifyLoginMethod, + }); + }, + ) // 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); @@ -614,7 +664,7 @@ async function runCli(): Promise { registerSubcommand( yargs, 'remove ', - 'Remove an environment configuration', + 'Remove an environment from local CLI config (does not delete or unclaim the environment in WorkOS)', (y) => y.positional('name', { type: 'string', demandOption: true, describe: 'Environment name' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); @@ -631,7 +681,7 @@ async function runCli(): Promise { if (!argv.name && !isPromptAllowed()) { exitWithError({ code: 'missing_args', - message: 'Environment name required. Usage: workos env switch ', + message: `Environment name required. Usage: ${formatWorkOSCommand('env switch ')}`, }); } await applyInsecureStorage(argv.insecureStorage); @@ -653,7 +703,7 @@ async function runCli(): Promise { registerSubcommand( yargs, 'claim', - 'Claim an unclaimed environment (link it to your account)', + 'Claim an unclaimed environment — link it to your account (permanent — cannot be undone)', (y) => y, async (argv) => { await applyInsecureStorage(argv.insecureStorage); @@ -661,6 +711,17 @@ async function runCli(): Promise { await runClaim(); }, ); + registerSubcommand( + yargs, + 'provision', + 'Provision a new unclaimed WorkOS environment (credentials only, no code changes)', + (y) => y, + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runEnvProvision } = await import('./commands/env.js'); + await runEnvProvision(); + }, + ); return yargs.demandCommand(1, 'Please specify an env subcommand').strict(); }) .command( @@ -2471,7 +2532,7 @@ async function runCli(): Promise { // Alias — canonical command is `workos env claim` .command( 'claim', - 'Claim an unclaimed WorkOS environment (link it to your account)', + 'Claim an unclaimed WorkOS environment — link it to your account (permanent — cannot be undone)', (yargs) => yargs.options({ ...insecureStorageOption, @@ -2636,11 +2697,11 @@ async function runCli(): Promise { await runDebugToken(); }, ); - return yargs.demandCommand(1, 'Run "workos debug " for debug tools.').strict(); + return yargs.demandCommand(1, `Run "${getWorkOSCommand()} debug " for debug tools.`).strict(); }) .command( 'migrations', - 'Migrate users from identity providers (Auth0, Cognito, Clerk, Firebase) to WorkOS', + MIGRATIONS_DESCRIPTION, (yargs) => yargs .strictCommands(false) @@ -2677,9 +2738,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/api/index.spec.ts b/src/commands/api/index.spec.ts index f438483d..3b52830f 100644 --- a/src/commands/api/index.spec.ts +++ b/src/commands/api/index.spec.ts @@ -160,6 +160,56 @@ describe('runApiInteractive', () => { }); expect(errorLine).toBeDefined(); }); + + describe('command hints route through formatWorkOSCommand', () => { + const NPM_KEYS = ['npm_command', 'npm_execpath', 'npm_config_user_agent'] as const; + let saved: Record; + + beforeEach(() => { + saved = {}; + for (const k of NPM_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + }); + + afterEach(() => { + for (const k of NPM_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + }); + + function parseTtyError(): { message?: string; details?: { usage?: string[] } } { + const errorLine = stderrOutput.find((line) => { + try { + return (JSON.parse(line) as { error?: { code?: string } }).error?.code === 'tty_required'; + } catch { + return false; + } + }); + expect(errorLine).toBeDefined(); + return (JSON.parse(errorLine!) as { error: { message?: string; details?: { usage?: string[] } } }).error; + } + + it('JSON refusal carries the bare form when not launched via npx', async () => { + setOutputMode('json'); + await expectExit(runApiInteractive(), 1); + const error = parseTtyError(); + expect(error.message).toContain('workos api ls'); + expect(error.details?.usage).toContain('workos api '); + expect(error.details?.usage?.some((u) => u.includes('npx'))).toBe(false); + }); + + it('JSON refusal carries the npx form when launched via npm exec', async () => { + process.env.npm_command = 'exec'; + setOutputMode('json'); + await expectExit(runApiInteractive(), 1); + const error = parseTtyError(); + expect(error.message).toContain('npx workos@latest api ls'); + expect(error.details?.usage).toContain('npx workos@latest api '); + }); + }); }); describe('runApiLs', () => { diff --git a/src/commands/api/index.ts b/src/commands/api/index.ts index 8799cddf..2f133f11 100644 --- a/src/commands/api/index.ts +++ b/src/commands/api/index.ts @@ -7,7 +7,7 @@ import { exitWithError, isJsonMode, outputJson } from '../../utils/output.js'; import { ExitCode, exitWithCode } from '../../utils/exit-codes.js'; import { isCiMode, isPromptAllowed } from '../../utils/interaction-mode.js'; import { confirmationRecovery } from '../../utils/recovery-hints.js'; -import { formatWorkOSCommandArgs } from '../../utils/command-invocation.js'; +import { formatWorkOSCommand, formatWorkOSCommandArgs } from '../../utils/command-invocation.js'; import { colorMethod, printResponse } from './format.js'; export { colorMethod } from './format.js'; @@ -31,9 +31,9 @@ export async function runApiInteractive(options?: { apiKey?: string }): Promise< if (isJsonMode()) { exitWithError({ code: 'tty_required', - message: 'Interactive mode is not available with --json. Provide an endpoint or use `workos api ls`.', + message: `Interactive mode is not available with --json. Provide an endpoint or use \`${formatWorkOSCommand('api ls')}\`.`, details: { - usage: ['workos api ', 'workos api ls [filter]'], + usage: [formatWorkOSCommand('api '), formatWorkOSCommand('api ls [filter]')], }, }); } @@ -41,8 +41,7 @@ export async function runApiInteractive(options?: { apiKey?: string }): Promise< if (!isPromptAllowed()) { exitWithError({ code: 'tty_required', - message: - 'Interactive API mode requires human mode. Usage: workos api or workos api ls [filter]. Example: workos api /user_management/users', + message: `Interactive API mode requires human mode. Usage: ${formatWorkOSCommand('api ')} or ${formatWorkOSCommand('api ls [filter]')}. Example: ${formatWorkOSCommand('api /user_management/users')}`, }); } diff --git a/src/commands/claim.spec.ts b/src/commands/claim.spec.ts index 7c3ab64c..e0603b1d 100644 --- a/src/commands/claim.spec.ts +++ b/src/commands/claim.spec.ts @@ -192,16 +192,46 @@ describe('claim command', () => { await runClaim(); - expect(mockOutputJson).toHaveBeenCalledWith({ - status: 'claim_url', - claimUrl: 'https://dashboard.workos.com/claim?nonce=nonce_abc123', - nonce: 'nonce_abc123', - }); + expect(mockOutputJson).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'claim_url', + claimUrl: 'https://dashboard.workos.com/claim?nonce=nonce_abc123', + nonce: 'nonce_abc123', + permanent: true, + }), + ); // Should NOT open browser or start polling in JSON mode expect(mockOpen).not.toHaveBeenCalled(); expect(mockSpinner.start).not.toHaveBeenCalled(); }); + it('warns that claiming is permanent before opening the browser (human mode)', async () => { + mockGetActiveEnvironment.mockReturnValue({ + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }); + mockIsUnclaimedEnvironment.mockReturnValue(true); + mockCreateClaimNonce.mockResolvedValueOnce({ nonce: 'nonce_abc123', alreadyClaimed: false }); + // Poll returns claimed immediately so the flow completes. + mockCreateClaimNonce.mockResolvedValueOnce({ alreadyClaimed: true }); + + const claimPromise = runClaim(); + await vi.advanceTimersByTimeAsync(6_000); + await claimPromise; + + const warnCall = vi + .mocked(mockClack.log.warn) + .mock.calls.find((c) => /permanent|cannot be undone/i.test(String(c[0]))); + expect(warnCall).toBeDefined(); + // The permanence warning must fire before the browser is opened. + const warnOrder = vi.mocked(mockClack.log.warn).mock.invocationCallOrder[0]; + const openOrder = vi.mocked(mockOpen).mock.invocationCallOrder[0]; + expect(warnOrder).toBeLessThan(openOrder); + }); + it('refuses claim browser flow in CI mode', async () => { mockGetActiveEnvironment.mockReturnValue({ name: 'unclaimed', @@ -436,5 +466,38 @@ describe('claim command', () => { expect(mockClack.log.info).toHaveBeenCalledWith(expect.stringContaining('Could not open browser')); }); + + it('timeout hint uses the npx form when launched via npm exec', async () => { + const NPM_KEYS = ['npm_command', 'npm_execpath', 'npm_config_user_agent'] as const; + const saved: Record = {}; + for (const k of NPM_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + process.env.npm_command = 'exec'; + try { + mockGetActiveEnvironment.mockReturnValue({ + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }); + mockIsUnclaimedEnvironment.mockReturnValue(true); + mockCreateClaimNonce.mockResolvedValueOnce({ nonce: 'nonce_abc123', alreadyClaimed: false }); + mockCreateClaimNonce.mockResolvedValue({ nonce: 'nonce_abc123', alreadyClaimed: false }); + + const claimPromise = runClaim(); + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 5_000); + await claimPromise; + + expect(mockClack.log.info).toHaveBeenCalledWith(expect.stringContaining('npx workos@latest env list')); + } finally { + for (const k of NPM_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + } + }); }); }); diff --git a/src/commands/claim.ts b/src/commands/claim.ts index bff7da13..fa476bf4 100644 --- a/src/commands/claim.ts +++ b/src/commands/claim.ts @@ -59,7 +59,13 @@ export async function runClaim(): Promise { const claimUrl = `https://dashboard.workos.com/claim?nonce=${result.nonce}`; if (isJsonMode()) { - outputJson({ status: 'claim_url', claimUrl, nonce: result.nonce }); + outputJson({ + status: 'claim_url', + claimUrl, + nonce: result.nonce, + permanent: true, + note: 'Claiming permanently links this environment to your account and cannot be undone.', + }); return; } @@ -71,6 +77,7 @@ export async function runClaim(): Promise { }); } + clack.log.warn('Claiming permanently links this environment to your account and cannot be undone.'); clack.log.info(`Open this URL to claim your environment:\n\n ${claimUrl}`); try { @@ -135,7 +142,7 @@ export async function runClaim(): Promise { } spinner.stop('Claim timed out'); - clack.log.info('Complete the claim in your browser, then run `workos env list` to verify.'); + clack.log.info(`Complete the claim in your browser, then run \`${formatWorkOSCommand('env list')}\` to verify.`); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; logError('[claim] Error:', message); diff --git a/src/commands/dev.ts b/src/commands/dev.ts index 4a940f39..320dd7a1 100644 --- a/src/commands/dev.ts +++ b/src/commands/dev.ts @@ -8,6 +8,7 @@ import chalk from 'chalk'; import { IS_WINDOWS, SPAWN_OPTS } from '../utils/platform.js'; import { ExitCode, exitWithCode } from '../utils/exit-codes.js'; import { exitWithError } from '../utils/output.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; export interface DevArgs { port: number; @@ -128,7 +129,7 @@ export async function runDev(argv: DevArgs): Promise { }); } catch { console.error(chalk.red(`Failed to start: ${devCmd.command} ${devCmd.args.join(' ')}`)); - console.error(chalk.dim('Try specifying the command explicitly: workos dev -- ')); + console.error(chalk.dim(`Try specifying the command explicitly: ${formatWorkOSCommand('dev -- ')}`)); await emulator.close(); exitWithCode(ExitCode.GENERAL_ERROR); } @@ -137,7 +138,9 @@ export async function runDev(argv: DevArgs): Promise { console.error(chalk.red(`Failed to start: ${devCmd.command}`)); if ((err as NodeJS.ErrnoException).code === 'ENOENT') { console.error(chalk.dim(`Command not found: ${devCmd.command}`)); - console.error(chalk.dim('Try specifying the command explicitly: workos dev -- ')); + console.error( + chalk.dim(`Try specifying the command explicitly: ${formatWorkOSCommand('dev -- ')}`), + ); } else { console.error(chalk.dim(err.message)); } diff --git a/src/commands/env.spec.ts b/src/commands/env.spec.ts index 20dcf8dd..30d858e2 100644 --- a/src/commands/env.spec.ts +++ b/src/commands/env.spec.ts @@ -25,6 +25,16 @@ vi.mock('../utils/clack.js', () => ({ }, })); +// Partial-mock the unclaimed-env API so we control provisioning outcomes but +// keep the real UnclaimedEnvApiError class for instanceof checks. +vi.mock('../lib/unclaimed-env-api.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, provisionUnclaimedEnvironment: vi.fn() }; +}); + +// Guard: runEnvProvision must NEVER write project .env files. +vi.mock('../lib/env-writer.js', () => ({ writeCredentialsEnv: vi.fn() })); + let testDir: string; vi.mock('node:os', async (importOriginal) => { @@ -39,8 +49,10 @@ 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 { getConfig, saveConfig, setInsecureConfigStorage, clearConfig } = await import('../lib/config-store.js'); +const { runEnvAdd, runEnvRemove, runEnvSwitch, runEnvList, runEnvProvision } = await import('./env.js'); +const { provisionUnclaimedEnvironment, UnclaimedEnvApiError } = await import('../lib/unclaimed-env-api.js'); +const { writeCredentialsEnv } = await import('../lib/env-writer.js'); const { setOutputMode } = await import('../utils/output.js'); const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); const { CliExit } = await import('../utils/cli-exit.js'); @@ -157,6 +169,40 @@ describe('env commands', () => { it('errors when no environments configured', async () => { await expect(runEnvRemove('anything')).rejects.toThrow(CliExit); }); + + it('warns that removal is local-only for an ordinary environment', async () => { + await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' }); + await runEnvRemove('prod'); + const warnMsg = vi + .mocked(clack.log.warn) + .mock.calls.map((c) => String(c[0])) + .join('\n'); + expect(warnMsg).toMatch(/local/i); + // Ordinary env: must NOT claim the claim token was lost. + expect(warnMsg).not.toMatch(/claim token/i); + }); + + it('warns that an unclaimed environment loses its claim token when removed', async () => { + saveConfig({ + activeEnvironment: 'unclaimed', + environments: { + unclaimed: { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_abc', + clientId: 'client_abc', + claimToken: 'tok_abc', + }, + }, + }); + await runEnvRemove('unclaimed'); + const warnMsg = vi + .mocked(clack.log.warn) + .mock.calls.map((c) => String(c[0])) + .join('\n'); + expect(warnMsg).toMatch(/local/i); + expect(warnMsg).toMatch(/claim/i); + }); }); describe('runEnvSwitch', () => { @@ -226,6 +272,133 @@ describe('env commands', () => { }); }); + describe('runEnvProvision', () => { + const CREDS = { + clientId: 'client_x', + apiKey: 'sk_test_x', + claimToken: 'ct_x', + authkitDomain: 'foo.authkit.app', + }; + + let consoleOutput: string[]; + let logSpy: ReturnType; + + beforeEach(() => { + vi.mocked(provisionUnclaimedEnvironment).mockReset(); + vi.mocked(writeCredentialsEnv).mockReset(); + consoleOutput = []; + logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + logSpy.mockRestore(); + setOutputMode('human'); + }); + + it('emits the provisioned credentials as JSON (agent credential delivery)', async () => { + setOutputMode('json'); + vi.mocked(provisionUnclaimedEnvironment).mockResolvedValue(CREDS); + + await runEnvProvision(); + + const out = JSON.parse(consoleOutput[0]); + expect(out.status).toBe('ok'); + expect(out.data.apiKey).toBe('sk_test_x'); + expect(out.data.clientId).toBe('client_x'); + expect(out.data.claimToken).toBe('ct_x'); + expect(out.data.authkitDomain).toBe('foo.authkit.app'); + }); + + it('persists the provisioned env locally as the active unclaimed env', async () => { + setOutputMode('json'); + vi.mocked(provisionUnclaimedEnvironment).mockResolvedValue(CREDS); + + await runEnvProvision(); + + const config = getConfig(); + const env = config?.environments.unclaimed; + expect(env?.type).toBe('unclaimed'); + expect(env?.clientId).toBe('client_x'); + expect((env as { claimToken?: string } | undefined)?.claimToken).toBe('ct_x'); + expect(config?.activeEnvironment).toBe('unclaimed'); + }); + + it('never writes a project .env file', async () => { + setOutputMode('json'); + vi.mocked(provisionUnclaimedEnvironment).mockResolvedValue(CREDS); + + await runEnvProvision(); + + expect(writeCredentialsEnv).not.toHaveBeenCalled(); + }); + + it('preserves an existing unclaimed env (and its claim token) by using a fresh key', async () => { + setOutputMode('json'); + vi.mocked(provisionUnclaimedEnvironment).mockResolvedValueOnce({ + clientId: 'client_old', + apiKey: 'sk_test_old', + claimToken: 'ct_old', + authkitDomain: 'old.authkit.app', + }); + await runEnvProvision(); + + vi.mocked(provisionUnclaimedEnvironment).mockResolvedValueOnce(CREDS); + await runEnvProvision(); + + const config = getConfig(); + // The first env's claim token lives only in this config — it must never be clobbered. + expect((config?.environments.unclaimed as { claimToken?: string } | undefined)?.claimToken).toBe('ct_old'); + const second = config?.environments['unclaimed-2'] as { claimToken?: string } | undefined; + expect(second?.claimToken).toBe('ct_x'); + expect(config?.activeEnvironment).toBe('unclaimed-2'); + expect(JSON.parse(consoleOutput[1]).data.name).toBe('unclaimed-2'); + }); + + it('surfaces a 429 as a structured rate_limited error — no config write, no login fallback', async () => { + setOutputMode('json'); + setInteractionMode({ mode: 'agent', source: 'env' }); + vi.mocked(provisionUnclaimedEnvironment).mockRejectedValue( + new UnclaimedEnvApiError('Rate limited. Please wait a moment and try again.', 429), + ); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await expect(runEnvProvision()).rejects.toThrow(CliExit); + const parsed = JSON.parse(String(errorSpy.mock.calls[0][0])); + expect(parsed.error.code).toBe('rate_limited'); + expect(getConfig()).toBeNull(); + expect(writeCredentialsEnv).not.toHaveBeenCalled(); + } finally { + errorSpy.mockRestore(); + } + }); + + it('maps an unexpected non-API error to provision_failed', async () => { + setOutputMode('json'); + setInteractionMode({ mode: 'agent', source: 'env' }); + vi.mocked(provisionUnclaimedEnvironment).mockRejectedValue(new Error('boom')); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await expect(runEnvProvision()).rejects.toThrow(CliExit); + const parsed = JSON.parse(String(errorSpy.mock.calls[0][0])); + expect(parsed.error.code).toBe('provision_failed'); + } finally { + errorSpy.mockRestore(); + } + }); + + it('prints the credentials and the env-claim hint in human mode', async () => { + setOutputMode('human'); + vi.mocked(provisionUnclaimedEnvironment).mockResolvedValue(CREDS); + + await runEnvProvision(); + + expect(consoleOutput.join('\n')).toContain('sk_test_x'); + expect(clack.log.info).toHaveBeenCalledWith(expect.stringContaining('env claim')); + }); + }); + describe('JSON output mode', () => { let consoleOutput: string[]; @@ -259,6 +432,28 @@ describe('env commands', () => { expect(output.status).toBe('ok'); expect(output.message).toBe('Environment removed'); expect(output.data.name).toBe('prod'); + expect(output.data.localOnly).toBe(true); + expect(output.data.wasUnclaimed).toBe(false); + }); + + it('runEnvRemove reports wasUnclaimed for an unclaimed env in JSON', async () => { + saveConfig({ + activeEnvironment: 'unclaimed', + environments: { + unclaimed: { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_abc', + clientId: 'client_abc', + claimToken: 'tok_abc', + }, + }, + }); + consoleOutput = []; + await runEnvRemove('unclaimed'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data.localOnly).toBe(true); + expect(output.data.wasUnclaimed).toBe(true); }); it('runEnvSwitch outputs JSON success', async () => { @@ -309,4 +504,74 @@ describe('env commands', () => { expect(output.data).toEqual([]); }); }); + + describe('command hints route through formatWorkOSCommand (npx vs bare)', () => { + // getWorkOSCommand reads all three of these; clear/set them deterministically. + const NPM_KEYS = ['npm_command', 'npm_execpath', 'npm_config_user_agent'] as const; + let saved: Record; + + beforeEach(() => { + saved = {}; + for (const k of NPM_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + }); + + afterEach(() => { + for (const k of NPM_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + }); + + it('runEnvList empty hint uses the bare command when not launched via npx', async () => { + await runEnvList(); + expect(clack.log.info).toHaveBeenCalledWith(expect.stringContaining('workos env add')); + expect(clack.log.info).not.toHaveBeenCalledWith(expect.stringContaining('npx workos@latest')); + }); + + it('runEnvList empty hint uses npx form when launched via npm exec', async () => { + process.env.npm_command = 'exec'; + await runEnvList(); + expect(clack.log.info).toHaveBeenCalledWith(expect.stringContaining('npx workos@latest env add')); + }); + + it('unclaimed-table footer uses npx form when launched via npm exec', async () => { + process.env.npm_command = 'exec'; + saveConfig({ + activeEnvironment: 'unclaimed', + environments: { + unclaimed: { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_abc', + clientId: 'client_abc', + claimToken: 'tok_abc', + }, + }, + }); + const out: string[] = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + out.push(args.map(String).join(' ')); + }); + await runEnvList(); + expect(out.join('\n')).toContain('npx workos@latest env claim'); + }); + + it('runEnvSwitch no-envs JSON error carries npx form', async () => { + process.env.npm_command = 'exec'; + setOutputMode('json'); + setInteractionMode({ mode: 'agent', source: 'env' }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await expect(runEnvSwitch('anything')).rejects.toThrow(CliExit); + const parsed = JSON.parse(String(errorSpy.mock.calls[0][0])); + expect(parsed.error.message).toContain('npx workos@latest env add'); + } finally { + errorSpy.mockRestore(); + setOutputMode('human'); + } + }); + }); }); diff --git a/src/commands/env.ts b/src/commands/env.ts index 9613b5a4..2d600a3f 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -1,12 +1,17 @@ import chalk from 'chalk'; import clack from '../utils/clack.js'; -import { getConfig, saveConfig, isUnclaimedEnvironment } from '../lib/config-store.js'; +import { getConfig, saveConfig, isUnclaimedEnvironment, freshEnvKey } from '../lib/config-store.js'; import type { CliConfig } from '../lib/config-store.js'; import { outputSuccess, outputJson, exitWithError, isJsonMode } from '../utils/output.js'; import { isAgentMode, isCiMode, isPromptAllowed } from '../utils/interaction-mode.js'; import { missingArgsRecovery } from '../utils/recovery-hints.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; import { ExitCode, exitWithCode } from '../utils/exit-codes.js'; +import { + provisionUnclaimedEnvironment, + UnclaimedEnvApiError, + type UnclaimedEnvProvisionResult, +} from '../lib/unclaimed-env-api.js'; const ENV_NAME_REGEX = /^[a-z0-9\-_]+$/; @@ -119,12 +124,79 @@ export async function runEnvAdd(options: { outputSuccess('Environment added', { name: name!, type, active: isFirst }); } +/** + * `workos env provision` — provision a fresh unclaimed environment (credentials only). + * + * Calls the low-level `provisionUnclaimedEnvironment()` directly (no auth, no + * code-gen). Credentials are delivered on stdout (JSON is the agent credential + * channel) and persisted locally as an unclaimed env so a follow-up + * `env claim` works. NEVER writes to the project directory or any `.env` file, + * and NEVER falls back to an interactive login on failure. + */ +export async function runEnvProvision(): Promise { + let result: UnclaimedEnvProvisionResult; + try { + result = await provisionUnclaimedEnvironment(); + } catch (error) { + if (error instanceof UnclaimedEnvApiError) { + exitWithError({ + code: error.statusCode === 429 ? 'rate_limited' : 'provision_failed', + message: error.message, + ...(error.statusCode && { apiContext: { status: error.statusCode } }), + }); + } + const message = error instanceof Error ? error.message : 'Unknown error'; + exitWithError({ code: 'provision_failed', message: `Failed to provision environment: ${message}` }); + } + + // Persist as an unclaimed env (parity with install) — NEVER writes to the project dir. + // A fresh key ('unclaimed', 'unclaimed-2', …) so a repeated provision never clobbers an + // earlier env's claim token, which lives only in this config and is unrecoverable. + const config = getOrCreateConfig(); + const key = freshEnvKey(config, 'unclaimed'); + config.environments[key] = { + name: key, + type: 'unclaimed', + apiKey: result.apiKey, + clientId: result.clientId, + claimToken: result.claimToken, + }; + config.activeEnvironment = key; + saveConfig(config); + + if (isJsonMode()) { + outputSuccess('Environment provisioned', { + name: key, + type: 'unclaimed', + active: true, + apiKey: result.apiKey, + clientId: result.clientId, + claimToken: result.claimToken, + authkitDomain: result.authkitDomain, + }); + return; + } + + clack.log.success('Provisioned a new WorkOS environment'); + console.log(''); + console.log(` ${chalk.dim('API key')} ${result.apiKey}`); + console.log(` ${chalk.dim('Client ID')} ${result.clientId}`); + console.log(` ${chalk.dim('AuthKit')} ${result.authkitDomain}`); + console.log(''); + clack.log.info( + `Set as active environment (${key}). Run \`${formatWorkOSCommand('env claim')}\` to link it to your account (permanent).`, + ); + if (key !== 'unclaimed') { + clack.log.info(`Your earlier unclaimed environment(s) are kept. See \`${formatWorkOSCommand('env list')}\`.`); + } +} + 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.', + message: `No environments configured. Run \`${formatWorkOSCommand('env add')}\` to get started.`, }); } @@ -133,8 +205,20 @@ export async function runEnvRemove(name: string): Promise { exitWithError({ code: 'not_found', message: `Environment "${name}" not found. Available: ${available}` }); } + // Capture the claim risk BEFORE deleting — an unclaimed env's claim token lives + // only in this local config, so removing it permanently loses the ability to claim. + const wasUnclaimed = isUnclaimedEnvironment(config.environments[name]); + delete config.environments[name]; + if (!isJsonMode()) { + clack.log.warn( + wasUnclaimed + ? `Removed only the local CLI config for "${name}". This environment was unclaimed — its claim token lived only here, so it can no longer be claimed.` + : `Removed only the local CLI config for "${name}". The environment still exists in WorkOS.`, + ); + } + if (config.activeEnvironment === name) { const remaining = Object.keys(config.environments); config.activeEnvironment = remaining.length > 0 ? remaining[0] : undefined; @@ -144,7 +228,12 @@ export async function runEnvRemove(name: string): Promise { } saveConfig(config); - outputSuccess('Environment removed', { name, newActive: config.activeEnvironment ?? null }); + outputSuccess('Environment removed', { + name, + newActive: config.activeEnvironment ?? null, + localOnly: true, + wasUnclaimed, + }); } export async function runEnvSwitch(name?: string): Promise { @@ -152,7 +241,7 @@ export async function runEnvSwitch(name?: string): Promise { if (!config || Object.keys(config.environments).length === 0) { exitWithError({ code: 'no_environments', - message: 'No environments configured. Run `workos env add` to get started.', + message: `No environments configured. Run \`${formatWorkOSCommand('env add')}\` to get started.`, }); } @@ -201,7 +290,7 @@ export async function runEnvList(): Promise { if (isJsonMode()) { outputJson({ data: [] }); } else { - clack.log.info('No environments configured. Run `workos env add` to get started.'); + clack.log.info(`No environments configured. Run \`${formatWorkOSCommand('env add')}\` to get started.`); } return; } @@ -253,6 +342,6 @@ export async function runEnvList(): Promise { if (hasUnclaimed) { console.log(''); - console.log(chalk.dim(' Run `workos env claim` to keep this environment.')); + console.log(chalk.dim(` Run \`${formatWorkOSCommand('env claim')}\` to keep this environment.`)); } } 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/commands/login.spec.ts b/src/commands/login.spec.ts index ef6fb9bd..7bdb45c7 100644 --- a/src/commands/login.spec.ts +++ b/src/commands/login.spec.ts @@ -37,11 +37,13 @@ vi.mock('../utils/clack.js', () => ({ error: vi.fn(), info: vi.fn(), step: vi.fn(), + warn: vi.fn(), }, spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn(), })), + confirm: vi.fn(), isCancel: vi.fn(() => false), }, })); @@ -60,6 +62,7 @@ vi.mock('./install-skill.js', () => ({ vi.mock('../utils/output.js', () => ({ isJsonMode: vi.fn(() => false), exitWithError: vi.fn(), + outputJson: vi.fn(), })); let testDir: string; @@ -76,10 +79,10 @@ vi.mock('node:os', async (importOriginal) => { }; }); -const { getConfig, setInsecureConfigStorage, clearConfig } = await import('../lib/config-store.js'); +const { getConfig, saveConfig, setInsecureConfigStorage, clearConfig } = await import('../lib/config-store.js'); const { provisionStagingEnvironment, installSkillsAfterLogin, runLogin } = await import('./login.js'); const { autoInstallSkills } = await import('./install-skill.js'); -const { isJsonMode } = await import('../utils/output.js'); +const { isJsonMode, outputJson } = await import('../utils/output.js'); const { clearCredentials, setInsecureStorage } = await import('../lib/credentials.js'); const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); const clackMod = await import('../utils/clack.js'); @@ -128,18 +131,20 @@ describe('login', () => { apiKey: 'sk_test_staging_abc', }); - const result = await provisionStagingEnvironment('access_token_xyz'); + const result = await provisionStagingEnvironment('access_token_xyz', { userId: 'u1', email: 'a@example.com' }); - expect(result).toBe(true); + expect(result.provisioned).toBe(true); expect(mockFetchStagingCredentials).toHaveBeenCalledWith('access_token_xyz'); const config = getConfig(); expect(config).not.toBeNull(); - expect(config?.environments['staging']).toEqual({ + expect(config?.environments['staging']).toMatchObject({ name: 'staging', type: 'sandbox', apiKey: 'sk_test_staging_abc', clientId: 'client_staging_123', + ownerEmail: 'a@example.com', + ownerUserId: 'u1', }); }); @@ -149,7 +154,7 @@ describe('login', () => { apiKey: 'sk_test_abc', }); - await provisionStagingEnvironment('token'); + await provisionStagingEnvironment('token', { userId: 'u1', email: 'a@example.com' }); const config = getConfig(); expect(config?.activeEnvironment).toBe('staging'); @@ -174,7 +179,7 @@ describe('login', () => { apiKey: 'sk_test_abc', }); - await provisionStagingEnvironment('token'); + await provisionStagingEnvironment('token', { userId: 'u1', email: 'a@example.com' }); const config = getConfig(); expect(config?.activeEnvironment).toBe('production'); @@ -182,8 +187,9 @@ describe('login', () => { expect(config?.environments['production']).toBeDefined(); }); - it('updates existing staging environment if already present', async () => { - const { saveConfig } = await import('../lib/config-store.js'); + it('updates existing staging environment in place on re-login as the same account', async () => { + // Same account (email match) — even a reissued clientId updates in place, + // never spawning a staging-2. ownerEmail is what identifies "same account". saveConfig({ activeEnvironment: 'staging', environments: { @@ -192,6 +198,8 @@ describe('login', () => { type: 'sandbox', apiKey: 'sk_test_old', clientId: 'client_old', + ownerEmail: 'a@example.com', + ownerUserId: 'u1', }, }, }); @@ -201,46 +209,142 @@ describe('login', () => { apiKey: 'sk_test_new', }); - const result = await provisionStagingEnvironment('token'); + const result = await provisionStagingEnvironment('token', { userId: 'u1', email: 'a@example.com' }); - expect(result).toBe(true); + expect(result.provisioned).toBe(true); + expect(result.mismatch).toBe(false); const config = getConfig(); + expect(config?.environments['staging-2']).toBeUndefined(); expect(config?.environments['staging']?.apiKey).toBe('sk_test_new'); expect(config?.environments['staging']?.clientId).toBe('client_new'); + expect(config?.environments['staging']?.ownerEmail).toBe('a@example.com'); + expect(config?.environments['staging']?.ownerUserId).toBe('u1'); }); - it('returns false and does not throw on API 403 error', async () => { + it('returns not-provisioned and does not throw on API 403 error', async () => { mockFetchStagingCredentials.mockRejectedValueOnce(new Error('Access denied')); - const result = await provisionStagingEnvironment('token'); + const result = await provisionStagingEnvironment('token', { userId: 'u1', email: 'a@example.com' }); - expect(result).toBe(false); + expect(result.provisioned).toBe(false); const config = getConfig(); expect(config).toBeNull(); }); - it('returns false and does not throw on API 404 error', async () => { + it('returns not-provisioned and does not throw on API 404 error', async () => { mockFetchStagingCredentials.mockRejectedValueOnce(new Error('No staging environment found')); - const result = await provisionStagingEnvironment('token'); + const result = await provisionStagingEnvironment('token', { userId: 'u1', email: 'a@example.com' }); - expect(result).toBe(false); + expect(result.provisioned).toBe(false); }); - it('returns false and does not throw on network error', async () => { + it('returns not-provisioned and does not throw on network error', async () => { mockFetchStagingCredentials.mockRejectedValueOnce(new Error('Network error')); - const result = await provisionStagingEnvironment('token'); + const result = await provisionStagingEnvironment('token', { userId: 'u1', email: 'a@example.com' }); - expect(result).toBe(false); + expect(result.provisioned).toBe(false); }); - it('returns false and does not throw on timeout', async () => { + it('returns not-provisioned and does not throw on timeout', async () => { mockFetchStagingCredentials.mockRejectedValueOnce(new Error('Request timed out')); - const result = await provisionStagingEnvironment('token'); + const result = await provisionStagingEnvironment('token', { userId: 'u1', email: 'a@example.com' }); - expect(result).toBe(false); + expect(result.provisioned).toBe(false); + }); + }); + + describe('provisionStagingEnvironment mismatch', () => { + it('stamps ownerEmail and ownerUserId on the provisioned env', async () => { + mockFetchStagingCredentials.mockResolvedValueOnce({ clientId: 'client_gmail', apiKey: 'sk_test' }); + + await provisionStagingEnvironment('token', { email: 'g@x.com', userId: 'u1' }); + + const config = getConfig(); + expect(config?.environments['staging']?.ownerEmail).toBe('g@x.com'); + expect(config?.environments['staging']?.ownerUserId).toBe('u1'); + }); + + it('adds a second staging env without repointing when the active claimed env is a different account', async () => { + saveConfig({ + activeEnvironment: 'sandbox', + environments: { + sandbox: { name: 'sandbox', type: 'sandbox', apiKey: 'sk', clientId: 'client_akshay' }, + }, + }); + mockFetchStagingCredentials.mockResolvedValueOnce({ clientId: 'client_gmail', apiKey: 'sk_test' }); + + const result = await provisionStagingEnvironment('token', { email: 'g@x.com', userId: 'u1' }); + + expect(result.mismatch).toBe(true); + expect(result.switched).toBe(false); + const config = getConfig(); + expect(config?.activeEnvironment).toBe('sandbox'); // preserved + expect(config?.environments['staging']?.clientId).toBe('client_gmail'); + }); + + it('never clobbers a different account already in the staging slot (in-place-clobber guard)', async () => { + saveConfig({ + activeEnvironment: 'staging', + environments: { + staging: { + name: 'staging', + type: 'sandbox', + apiKey: 'sk', + clientId: 'client_akshay', + ownerEmail: 'akshay@x.com', + }, + }, + }); + mockFetchStagingCredentials.mockResolvedValueOnce({ clientId: 'client_gmail', apiKey: 'sk_test' }); + + const result = await provisionStagingEnvironment('token', { email: 'g@x.com', userId: 'u1' }); + + const config = getConfig(); + // The original staging slot is untouched — pre-fix code overwrote it here. + expect(config?.environments['staging']?.clientId).toBe('client_akshay'); + expect(config?.environments['staging-2']?.clientId).toBe('client_gmail'); + expect(config?.activeEnvironment).toBe('staging'); + expect(result.mismatch).toBe(true); + expect(result.envName).toBe('staging-2'); + }); + + it('updates in place with no mismatch when the same account logs in again', async () => { + saveConfig({ + activeEnvironment: 'staging', + environments: { + staging: { + name: 'staging', + type: 'sandbox', + apiKey: 'sk_old', + clientId: 'client_gmail', + ownerEmail: 'g@x.com', + }, + }, + }); + mockFetchStagingCredentials.mockResolvedValueOnce({ clientId: 'client_gmail', apiKey: 'sk_new' }); + + const result = await provisionStagingEnvironment('token', { email: 'g@x.com', userId: 'u1' }); + + expect(result.mismatch).toBe(false); + const config = getConfig(); + expect(config?.environments['staging-2']).toBeUndefined(); + expect(config?.environments['staging']?.apiKey).toBe('sk_new'); + expect(config?.activeEnvironment).toBe('staging'); + }); + + it('auto-assigns active on first login onto an empty config', async () => { + mockFetchStagingCredentials.mockResolvedValueOnce({ clientId: 'client_gmail', apiKey: 'sk_test' }); + + const result = await provisionStagingEnvironment('token', { email: 'g@x.com', userId: 'u1' }); + + const config = getConfig(); + expect(config?.activeEnvironment).toBe('staging'); + expect(config?.environments['staging']?.ownerEmail).toBe('g@x.com'); + expect(result.mismatch).toBe(false); + expect(result.switched).toBe(true); }); }); @@ -265,6 +369,86 @@ describe('login', () => { expect(mockOpen).toHaveBeenCalledWith('https://auth.example.com/device?code=ABCD', { wait: false }); expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('manual URL')); }); + + it('prints a "Now using" line naming the account in human mode', async () => { + setInteractionMode({ mode: 'human', source: 'env' }); + mockFetchStagingCredentials.mockResolvedValue({ clientId: 'client_user', apiKey: 'sk_test_user' }); + + await runLogin(); + + const successSpy = vi.mocked(clackMod.default.log.success); + const nowUsing = successSpy.mock.calls.map((c) => String(c[0])).find((m) => m.includes('Now using')); + expect(nowUsing).toBeDefined(); + expect(nowUsing).toContain('user@example.com'); + }); + + it('switches the active env when the user confirms on a cross-account login', async () => { + setInteractionMode({ mode: 'human', source: 'env' }); + saveConfig({ + activeEnvironment: 'sandbox', + environments: { sandbox: { name: 'sandbox', type: 'sandbox', apiKey: 'sk', clientId: 'client_other' } }, + }); + mockFetchStagingCredentials.mockResolvedValue({ clientId: 'client_user', apiKey: 'sk_test_user' }); + vi.mocked(clackMod.default.confirm).mockResolvedValueOnce(true); + + await runLogin(); + + expect(getConfig()?.activeEnvironment).toBe('staging'); + }); + + it('keeps the prior active env when the user declines the switch', async () => { + setInteractionMode({ mode: 'human', source: 'env' }); + saveConfig({ + activeEnvironment: 'sandbox', + environments: { sandbox: { name: 'sandbox', type: 'sandbox', apiKey: 'sk', clientId: 'client_other' } }, + }); + mockFetchStagingCredentials.mockResolvedValue({ clientId: 'client_user', apiKey: 'sk_test_user' }); + vi.mocked(clackMod.default.confirm).mockResolvedValueOnce(false); + + await runLogin(); + + expect(getConfig()?.activeEnvironment).toBe('sandbox'); + }); + + it('warns and never prompts on a cross-account login in agent mode', async () => { + setInteractionMode({ mode: 'agent', source: 'env' }); + saveConfig({ + activeEnvironment: 'sandbox', + environments: { sandbox: { name: 'sandbox', type: 'sandbox', apiKey: 'sk', clientId: 'client_other' } }, + }); + mockFetchStagingCredentials.mockResolvedValue({ clientId: 'client_user', apiKey: 'sk_test_user' }); + + await runLogin(); + + expect(clackMod.default.confirm).not.toHaveBeenCalled(); + expect(getConfig()?.activeEnvironment).toBe('sandbox'); + const warned = vi + .mocked(clackMod.default.log.warn) + .mock.calls.map((c) => String(c[0])) + .join('\n'); + expect(warned).toContain('sandbox'); + expect(warned).toContain('user@example.com'); + }); + + it('emits structured provisioning fields and never prompts in JSON mode', async () => { + vi.mocked(isJsonMode).mockReturnValueOnce(true); + saveConfig({ + activeEnvironment: 'sandbox', + environments: { sandbox: { name: 'sandbox', type: 'sandbox', apiKey: 'sk', clientId: 'client_other' } }, + }); + mockFetchStagingCredentials.mockResolvedValue({ clientId: 'client_user', apiKey: 'sk_test_user' }); + + await runLogin(); + + expect(clackMod.default.confirm).not.toHaveBeenCalled(); + expect(outputJson).toHaveBeenCalledWith( + expect.objectContaining({ + mismatch: true, + activeEnvironment: 'sandbox', + account: expect.objectContaining({ email: 'user@example.com' }), + }), + ); + }); }); describe('installSkillsAfterLogin', () => { diff --git a/src/commands/login.ts b/src/commands/login.ts index 998d8406..9e3d04da 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -6,12 +6,12 @@ import { getCliAuthClientId, getAuthkitDomain } from '../lib/settings.js'; import { refreshAccessToken } from '../lib/token-refresh-client.js'; import { logInfo, logError } from '../utils/debug.js'; import { fetchStagingCredentials } from '../lib/staging-api.js'; -import { getConfig, saveConfig } from '../lib/config-store.js'; -import type { CliConfig } from '../lib/config-store.js'; +import { getConfig, saveConfig, getActiveEnvironment, setActiveEnvironment, freshEnvKey } from '../lib/config-store.js'; +import type { CliConfig, EnvironmentConfig } from '../lib/config-store.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; import { autoInstallSkills } from './install-skill.js'; -import { isJsonMode } from '../utils/output.js'; -import { isAgentMode, isCiMode } from '../utils/interaction-mode.js'; +import { isJsonMode, outputJson } from '../utils/output.js'; +import { isAgentMode, isCiMode, isPromptAllowed } from '../utils/interaction-mode.js'; import { ExitCode, exitWithAuthRequired, exitWithCode } from '../utils/exit-codes.js'; import { requestDeviceCode, pollForToken, DeviceAuthTimeoutError } from '../lib/device-auth.js'; import { observeHostFailure } from '../lib/host-probe.js'; @@ -50,37 +50,105 @@ export async function installSkillsAfterLogin(): Promise { } } +/** + * Result of a post-login staging provision. Carries enough context for + * `runLogin` to detect a cross-account switch and decide how to surface it + * without ever silently repointing the active environment. + */ +export interface StagingProvisionResult { + provisioned: boolean; + account: { email?: string; userId: string }; + /** Active env name AFTER provisioning (unchanged unless there was no prior active env). */ + activeEnvironment?: string; + /** Key the new Staging env was written under ('staging' or a fresh 'staging-N'). */ + envName?: string; + envType?: EnvironmentConfig['type']; + mismatch: boolean; + /** True only when provisioning repointed the active env (the no-prior-active case). */ + switched: boolean; + priorEnvName?: string; + priorAccount?: { email?: string; clientId?: string }; +} + +/** + * Does the just-authenticated account differ from the account that owns `prior`? + * + * Email comparison wins when both sides have it (human-readable, survives a + * clientId reissue); otherwise fall back to clientId. When neither is available + * we cannot tell — return false and let the always-on "Now using" line protect + * the user. + */ +function isMismatch( + prior: EnvironmentConfig | undefined, + account: { email?: string }, + staging: { clientId: string }, +): boolean { + if (!prior) return false; + if (prior.ownerEmail && account.email) return prior.ownerEmail !== account.email; + if (prior.clientId) return prior.clientId !== staging.clientId; + return false; +} + /** * Auto-provision a staging environment after login. * - * Fetches staging credentials using the access token, then saves them - * as a "staging" environment in the config store. Non-fatal — logs a - * hint on failure instead of throwing. + * Fetches staging credentials for the just-authenticated account and stores + * them. On a detected cross-account mismatch, the new account's Staging is + * written under a DISTINCT key rather than clobbering the active slot, and the + * active pointer is left alone — only a login onto an empty config auto-assigns + * the active env. Non-fatal — logs a hint on failure instead of throwing. */ -export async function provisionStagingEnvironment(accessToken: string): Promise { +export async function provisionStagingEnvironment( + accessToken: string, + account: { email?: string; userId: string }, +): Promise { try { const staging = await fetchStagingCredentials(accessToken); const config: CliConfig = getConfig() ?? { environments: {} }; - const isFirst = Object.keys(config.environments).length === 0; - config.environments['staging'] = { - name: 'staging', + const priorName = config.activeEnvironment; + const prior = priorName ? config.environments[priorName] : undefined; // capture BEFORE write + const mismatch = isMismatch(prior, account, staging); + + // Never overwrite a DIFFERENT account's 'staging' slot in place. + const stagingSlot = config.environments['staging']; + const slotMismatch = isMismatch(stagingSlot, account, staging); + const key = slotMismatch ? freshEnvKey(config, 'staging') : 'staging'; + + config.environments[key] = { + name: key, type: 'sandbox', apiKey: staging.apiKey, clientId: staging.clientId, + ...(account.email && { ownerEmail: account.email }), + ownerUserId: account.userId, }; - if (isFirst || !config.activeEnvironment) { - config.activeEnvironment = 'staging'; + // Only auto-assign active when there is NO valid prior active env. + let switched = false; + if (!prior) { + config.activeEnvironment = key; + switched = true; } saveConfig(config); - logInfo('[login] Staging environment auto-provisioned'); - return true; + logInfo('[login] Staging environment provisioned'); + + return { + provisioned: true, + account, + activeEnvironment: config.activeEnvironment, + envName: key, + envType: 'sandbox', + mismatch, + switched, + priorEnvName: priorName, + priorAccount: prior ? { email: prior.ownerEmail, clientId: prior.clientId } : undefined, + }; } catch (error) { - logError('[login] Failed to auto-provision staging environment:', error instanceof Error ? error.message : error); - return false; + logError('[login] Failed to provision staging environment:', error instanceof Error ? error.message : error); + return { provisioned: false, account, mismatch: false, switched: false }; } } @@ -176,11 +244,41 @@ export async function runLogin(): Promise { clack.log.success(`Logged in as ${result.email || result.userId}`); clack.log.info(`Token expires in ${expiresInSec} seconds`); - const provisioned = await provisionStagingEnvironment(result.accessToken); - if (provisioned) { - clack.log.success('Staging environment configured automatically'); + const account = { email: result.email, userId: result.userId }; + const provision = await provisionStagingEnvironment(result.accessToken, account); + + if (isJsonMode()) { + outputJson({ + status: 'ok', + account: { email: account.email ?? null, userId: account.userId }, + activeEnvironment: provision.activeEnvironment ?? null, + mismatch: provision.mismatch, + }); + } else if (provision.provisioned) { + if (provision.mismatch) { + const priorLabel = provision.priorAccount?.email ?? provision.priorAccount?.clientId ?? provision.priorEnvName; + if (isPromptAllowed()) { + const answer = await clack.confirm({ + message: `You were using ${provision.priorEnvName} (${priorLabel}, a different account). Switch active environment to ${account.email ?? account.userId}'s Staging?`, + initialValue: false, // default: keep current + }); + if (!clack.isCancel(answer) && answer && provision.envName) { + setActiveEnvironment(provision.envName); + } + } else { + clack.log.warn( + `Logged in as ${account.email ?? account.userId}, but the active environment "${provision.priorEnvName}" belongs to a different account (${priorLabel}). Keeping it active. Run \`${formatWorkOSCommand('env switch')}\` to change environments.`, + ); + } + } + const active = getActiveEnvironment(); + if (active) { + clack.log.success(`Now using: ${active.name} (${active.type}) — ${account.email ?? account.userId}`); + } else { + clack.log.info(chalk.dim(`Run \`${formatWorkOSCommand('env add')}\` to configure an environment manually`)); + } } else { - clack.log.info(chalk.dim('Run `workos env add` to configure an environment manually')); + clack.log.info(chalk.dim(`Run \`${formatWorkOSCommand('env add')}\` to configure an environment manually`)); } await installSkillsAfterLogin(); diff --git a/src/commands/migrations.spec.ts b/src/commands/migrations.spec.ts index 1dbc873f..9e064dc2 100644 --- a/src/commands/migrations.spec.ts +++ b/src/commands/migrations.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; const mockParseAsync = vi.fn(); const mockName = vi.fn(); @@ -43,6 +43,37 @@ describe('runMigrations', () => { expect(mockParseAsync).toHaveBeenCalledWith(args, { from: 'user' }); }); + describe('program name routes through getWorkOSCommand', () => { + const NPM_KEYS = ['npm_command', 'npm_execpath', 'npm_config_user_agent'] as const; + let saved: Record; + + beforeEach(() => { + saved = {}; + for (const k of NPM_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + }); + + afterEach(() => { + for (const k of NPM_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + }); + + it('uses the bare command name when not launched via npx', async () => { + await runMigrations(['wizard']); + expect(mockName).toHaveBeenCalledWith('workos migrations'); + }); + + it('uses the npx command name when launched via npm exec', async () => { + process.env.npm_command = 'exec'; + await runMigrations(['wizard']); + expect(mockName).toHaveBeenCalledWith('npx workos@latest migrations'); + }); + }); + it('sets WORKOS_API_URL when apiBaseUrl is provided', async () => { await runMigrations(['import', '--csv', 'users.csv'], 'sk_test_123', 'https://api.staging.workos.com'); expect(process.env.WORKOS_API_URL).toBe('https://api.staging.workos.com'); diff --git a/src/commands/migrations.ts b/src/commands/migrations.ts index 9e39e58b..e84742c5 100644 --- a/src/commands/migrations.ts +++ b/src/commands/migrations.ts @@ -1,3 +1,5 @@ +import { getWorkOSCommand } from '../utils/command-invocation.js'; + const workosOnlyMigrationsFlags = new Map([ ['--api-key', true], ['--insecure-storage', false], @@ -58,6 +60,6 @@ export async function runMigrations(args: string[], apiKey?: string, apiBaseUrl? }; }; - program.name('workos migrations'); + program.name(`${getWorkOSCommand()} migrations`); await program.parseAsync(args, { from: 'user' }); } diff --git a/src/commands/seed.spec.ts b/src/commands/seed.spec.ts index 0fc8fca2..59110916 100644 --- a/src/commands/seed.spec.ts +++ b/src/commands/seed.spec.ts @@ -285,6 +285,64 @@ config: }); }); + describe('command hints route through formatWorkOSCommand', () => { + const NPM_KEYS = ['npm_command', 'npm_execpath', 'npm_config_user_agent'] as const; + let saved: Record; + + beforeEach(() => { + saved = {}; + for (const k of NPM_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + process.env.npm_command = 'exec'; + setOutputMode('json'); + }); + + afterEach(() => { + setOutputMode('human'); + for (const k of NPM_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + }); + + function lastErrorMessage(): string { + const line = [...consoleErrors].reverse().find((l) => { + try { + return typeof (JSON.parse(l) as { error?: { message?: string } }).error?.message === 'string'; + } catch { + return false; + } + }); + expect(line).toBeDefined(); + return (JSON.parse(line!) as { error: { message: string } }).error.message; + } + + it('missing --file error carries the npx seed hints', async () => { + await expect(runSeed({}, 'sk_test')).rejects.toThrow(CliExit); + const message = lastErrorMessage(); + expect(message).toContain('npx workos@latest seed --file=workos-seed.yml'); + expect(message).toContain('npx workos@latest seed --init'); + }); + + it('file-not-found error carries the npx seed hint', async () => { + mockExistsSync.mockReturnValue(false); + await expect(runSeed({ file: 'missing.yml' }, 'sk_test')).rejects.toThrow(CliExit); + expect(lastErrorMessage()).toContain('npx workos@latest seed'); + }); + + it('seed-failed error carries the npx seed --clean hint', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(FULL_SEED_YAML); + mockSdk.authorization.createPermission.mockResolvedValue({ slug: 'read-users' }); + mockSdk.authorization.createEnvironmentRole.mockRejectedValue(new Error('Server exploded')); + + await expect(runSeed({ file: 'workos-seed.yml' }, 'sk_test')).rejects.toThrow(CliExit); + expect(lastErrorMessage()).toContain('npx workos@latest seed --clean'); + }); + }); + describe('runSeed --clean', () => { it('deletes resources in reverse order: orgs → permissions', async () => { mockExistsSync.mockReturnValue(true); diff --git a/src/commands/seed.ts b/src/commands/seed.ts index fc71599e..f0030155 100644 --- a/src/commands/seed.ts +++ b/src/commands/seed.ts @@ -4,6 +4,7 @@ import { parse as parseYaml } from 'yaml'; import type { DomainData } from '@workos-inc/node'; import { createWorkOSClient, type WorkOSCLIClient } from '../lib/workos-client.js'; import { outputJson, outputSuccess, isJsonMode, exitWithError } from '../utils/output.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; const STATE_FILE = '.workos-seed-state.json'; @@ -92,7 +93,7 @@ export function runSeedInit(): void { outputJson({ status: 'ok', message: `Created ${DEFAULT_SEED_FILE}`, file: DEFAULT_SEED_FILE }); } else { console.log(chalk.green(`Created ${DEFAULT_SEED_FILE}`)); - console.log(chalk.dim('Edit the file, then run: workos seed --file=workos-seed.yml')); + console.log(chalk.dim(`Edit the file, then run: ${formatWorkOSCommand('seed --file=workos-seed.yml')}`)); } } @@ -114,15 +115,14 @@ export async function runSeed( if (!options.file) { return exitWithError({ code: 'missing_args', - message: - 'Provide a seed file: workos seed --file=workos-seed.yml\nRun workos seed --init to create an example seed file.', + message: `Provide a seed file: ${formatWorkOSCommand('seed --file=workos-seed.yml')}\nRun ${formatWorkOSCommand('seed --init')} to create an example seed file.`, }); } if (!existsSync(options.file)) { return exitWithError({ code: 'file_not_found', - message: `Seed file not found: ${options.file}. Create workos-seed.yml or run \`workos seed\` without --file for interactive mode.`, + message: `Seed file not found: ${options.file}. Create workos-seed.yml or run \`${formatWorkOSCommand('seed')}\` without --file for interactive mode.`, }); } @@ -235,7 +235,7 @@ export async function runSeed( saveState(state); exitWithError({ code: 'seed_failed', - message: `Seed failed: ${error instanceof Error ? error.message : 'Unknown error'}. Partial state saved to ${STATE_FILE}. Run \`workos seed --clean\` to tear down.`, + message: `Seed failed: ${error instanceof Error ? error.message : 'Unknown error'}. Partial state saved to ${STATE_FILE}. Run \`${formatWorkOSCommand('seed --clean')}\` to tear down.`, details: state, }); } diff --git a/src/commands/vault-run.spec.ts b/src/commands/vault-run.spec.ts index 6edd7921..d33cb716 100644 --- a/src/commands/vault-run.spec.ts +++ b/src/commands/vault-run.spec.ts @@ -352,6 +352,39 @@ describe('vault-run', () => { expect(exitErrors[0].message).toMatch(/no-such-env/); expect(mockSpawn).not.toHaveBeenCalled(); }); + + describe('command hints route through formatWorkOSCommand', () => { + const NPM_KEYS = ['npm_command', 'npm_execpath', 'npm_config_user_agent'] as const; + let saved: Record; + + beforeEach(() => { + saved = {}; + for (const k of NPM_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + process.env.npm_command = 'exec'; + }); + + afterEach(() => { + for (const k of NPM_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + }); + + it('missing-command usage error carries the npx form', async () => { + await expect(runVaultRun({ secrets: ['DB_URL=db'], command: [] })).rejects.toThrow(/__EXIT__/); + expect(exitErrors[0].message).toContain('npx workos@latest vault run --secret ENV=name -- command'); + }); + + it('unknown-env error carries the npx env-list hint', async () => { + await expect(runVaultRun({ secrets: ['DB_URL=db'], command: ['echo'], env: 'no-such-env' })).rejects.toThrow( + /__EXIT__/, + ); + expect(exitErrors[0].message).toContain('npx workos@latest env list'); + }); + }); }); describe('JSON mode metadata on execution', () => { diff --git a/src/commands/vault-run.ts b/src/commands/vault-run.ts index 9cc890b9..d5ede416 100644 --- a/src/commands/vault-run.ts +++ b/src/commands/vault-run.ts @@ -5,6 +5,7 @@ import { createApiErrorHandler } from '../lib/api-error-handler.js'; import { isJsonMode, outputJson, exitWithError } from '../utils/output.js'; import { formatTable } from '../utils/table.js'; import { SPAWN_OPTS, IS_WINDOWS } from '../utils/platform.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; const handleApiError = createApiErrorHandler('Vault'); @@ -97,7 +98,7 @@ async function resolveRunApiKey(envName: string | undefined, flagApiKey?: string if (!env || !env.apiKey) { exitWithError({ code: 'env_not_found', - message: `Environment '${envName}' not found or has no API key. Run 'workos env list' to see available environments.`, + message: `Environment '${envName}' not found or has no API key. Run '${formatWorkOSCommand('env list')}' to see available environments.`, }); } return env.apiKey; @@ -205,7 +206,7 @@ export async function runVaultRun(options: VaultRunOptions, flagApiKey?: string) if (!options.command || options.command.length === 0) { exitWithError({ code: 'missing_command', - message: 'No command specified. Usage: workos vault run --secret ENV=name -- command', + message: `No command specified. Usage: ${formatWorkOSCommand('vault run --secret ENV=name -- command')}`, }); } diff --git a/src/commands/verify-login.spec.ts b/src/commands/verify-login.spec.ts new file mode 100644 index 00000000..7b659ec2 --- /dev/null +++ b/src/commands/verify-login.spec.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +// Type-only import is erased at build time, so it does not load the module +// before vi.mock applies (the runtime handler is loaded via dynamic import below). +import type { VerifyLoginOptions } from './verify-login.js'; + +const mockSdk = { + userManagement: { + createUser: vi.fn(), + authenticateWithPassword: vi.fn(), + authenticateWithMagicAuth: vi.fn(), + createMagicAuth: vi.fn(), + deleteUser: vi.fn(), + }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), +})); + +const { setOutputMode } = await import('../utils/output.js'); +const { CliExit } = await import('../utils/cli-exit.js'); +const { runVerifyLogin } = await import('./verify-login.js'); + +function baseOptions(overrides: Partial = {}): VerifyLoginOptions { + return { + apiKey: 'sk_test_x', + clientId: 'client_x', + baseUrl: 'https://api.workos.com', + envType: 'sandbox', + envName: 'sandbox', + method: 'password', + ...overrides, + }; +} + +/** Run the handler and return the thrown CliExit (fails the test if none thrown). */ +async function expectCliExit(options: VerifyLoginOptions): Promise> { + try { + await runVerifyLogin(options); + } catch (err) { + expect(err).toBeInstanceOf(CliExit); + return err as InstanceType; + } + throw new Error('Expected runVerifyLogin to throw CliExit, but it resolved'); +} + +describe('verify-login', () => { + let consoleOutput: string[]; + let errorOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + errorOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errorOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('smoke: exports runVerifyLogin', () => { + expect(typeof runVerifyLogin).toBe('function'); + }); + + it('happy path: create → authenticate → assert tokens → cleanup, in order', async () => { + mockSdk.userManagement.createUser.mockResolvedValue({ id: 'user_x' }); + mockSdk.userManagement.authenticateWithPassword.mockResolvedValue({ + accessToken: 'a', + refreshToken: 'r', + user: {}, + }); + mockSdk.userManagement.deleteUser.mockResolvedValue(undefined); + + await runVerifyLogin(baseOptions()); + + expect(mockSdk.userManagement.createUser).toHaveBeenCalledTimes(1); + const createArgs = mockSdk.userManagement.createUser.mock.calls[0][0]; + expect(createArgs).toEqual(expect.objectContaining({ emailVerified: true })); + expect(createArgs.email).toMatch(/^verify-login\+.*@example\.workos\.dev$/); + expect(typeof createArgs.password).toBe('string'); + expect(createArgs.password.length).toBeGreaterThan(0); + + expect(mockSdk.userManagement.authenticateWithPassword).toHaveBeenCalledTimes(1); + const authArgs = mockSdk.userManagement.authenticateWithPassword.mock.calls[0][0]; + expect(authArgs).toEqual(expect.objectContaining({ clientId: 'client_x' })); + // Same throwaway credentials must be reused for the authenticate step. + expect(authArgs.email).toBe(createArgs.email); + expect(authArgs.password).toBe(createArgs.password); + + expect(mockSdk.userManagement.deleteUser).toHaveBeenCalledTimes(1); + expect(mockSdk.userManagement.deleteUser).toHaveBeenCalledWith('user_x'); + + expect(consoleOutput.some((l) => /passed/i.test(l))).toBe(true); + }); + + it('cleanup-on-failure: authenticate rejects but deleteUser still runs; exit 1', async () => { + mockSdk.userManagement.createUser.mockResolvedValue({ id: 'user_x' }); + mockSdk.userManagement.authenticateWithPassword.mockRejectedValue(new Error('invalid credentials')); + mockSdk.userManagement.deleteUser.mockResolvedValue(undefined); + + const exit = await expectCliExit(baseOptions()); + + expect(exit.exitCode).toBe(1); + expect(mockSdk.userManagement.deleteUser).toHaveBeenCalledWith('user_x'); + }); + + it('createUser throws: no orphan, verdict false, no deleteUser call; exit 1', async () => { + mockSdk.userManagement.createUser.mockRejectedValue(new Error('boom')); + + const exit = await expectCliExit(baseOptions()); + + expect(exit.exitCode).toBe(1); + expect(mockSdk.userManagement.authenticateWithPassword).not.toHaveBeenCalled(); + expect(mockSdk.userManagement.deleteUser).not.toHaveBeenCalled(); + }); + + it('orphaned user: cleanup fails after a passing verification; resolves (exit 0)', async () => { + mockSdk.userManagement.createUser.mockResolvedValue({ id: 'user_x' }); + mockSdk.userManagement.authenticateWithPassword.mockResolvedValue({ + accessToken: 'a', + refreshToken: 'r', + user: {}, + }); + mockSdk.userManagement.deleteUser.mockRejectedValue(new Error('network')); + + // Verification passed → a leaked user does not fail the login verdict. + await expect(runVerifyLogin(baseOptions())).resolves.toBeUndefined(); + + expect(mockSdk.userManagement.deleteUser).toHaveBeenCalledWith('user_x'); + expect(consoleOutput.some((l) => l.includes('user_x'))).toBe(true); + }); + + it('production refusal (env.type): NO SDK calls, exit 1', async () => { + const exit = await expectCliExit(baseOptions({ envType: 'production' })); + + expect(exit.exitCode).toBe(1); + expect(mockSdk.userManagement.createUser).not.toHaveBeenCalled(); + expect(mockSdk.userManagement.authenticateWithPassword).not.toHaveBeenCalled(); + expect(mockSdk.userManagement.deleteUser).not.toHaveBeenCalled(); + }); + + it('production refusal (sk_live_ key): NO SDK calls, exit 1', async () => { + const exit = await expectCliExit(baseOptions({ envType: null, apiKey: 'sk_live_x' })); + + expect(exit.exitCode).toBe(1); + expect(mockSdk.userManagement.createUser).not.toHaveBeenCalled(); + expect(mockSdk.userManagement.authenticateWithPassword).not.toHaveBeenCalled(); + expect(mockSdk.userManagement.deleteUser).not.toHaveBeenCalled(); + }); + + it('missing clientId: structured error, NO SDK calls', async () => { + const exit = await expectCliExit(baseOptions({ clientId: undefined })); + + expect(exit.exitCode).toBe(1); + expect(mockSdk.userManagement.createUser).not.toHaveBeenCalled(); + // Human mode: the error message references configuring a client id. + expect(errorOutput.some((l) => /client id/i.test(l))).toBe(true); + }); + + it('tokens missing: verdict false, deleteUser still called; exit 1', async () => { + mockSdk.userManagement.createUser.mockResolvedValue({ id: 'user_x' }); + mockSdk.userManagement.authenticateWithPassword.mockResolvedValue({ + accessToken: '', + refreshToken: '', + user: {}, + }); + mockSdk.userManagement.deleteUser.mockResolvedValue(undefined); + + const exit = await expectCliExit(baseOptions()); + + expect(exit.exitCode).toBe(1); + expect(mockSdk.userManagement.deleteUser).toHaveBeenCalledWith('user_x'); + }); + + describe('JSON output mode', () => { + beforeEach(() => { + setOutputMode('json'); + }); + + afterEach(() => { + setOutputMode('human'); + }); + + it('verdict shape (happy path)', async () => { + mockSdk.userManagement.createUser.mockResolvedValue({ id: 'user_x' }); + mockSdk.userManagement.authenticateWithPassword.mockResolvedValue({ + accessToken: 'a', + refreshToken: 'r', + user: {}, + }); + mockSdk.userManagement.deleteUser.mockResolvedValue(undefined); + + await runVerifyLogin(baseOptions()); + + const output = JSON.parse(consoleOutput[0]); + expect(output.success).toBe(true); + expect(output.method).toBe('password'); + expect(output.environment).toBe('sandbox'); + expect(Array.isArray(output.checks)).toBe(true); + expect(output.checks).toHaveLength(3); + for (const check of output.checks) { + expect(check).toHaveProperty('name'); + expect(check).toHaveProperty('passed'); + expect(check).toHaveProperty('detail'); + } + expect(output.userId).toBe('user_x'); + expect(output.userCleanedUp).toBe(true); + expect(output.orphanedUserId).toBeNull(); + }); + + it('verdict shape (failed auth)', async () => { + mockSdk.userManagement.createUser.mockResolvedValue({ id: 'user_x' }); + mockSdk.userManagement.authenticateWithPassword.mockRejectedValue(new Error('invalid credentials')); + mockSdk.userManagement.deleteUser.mockResolvedValue(undefined); + + const exit = await expectCliExit(baseOptions()); + expect(exit.exitCode).toBe(1); + + const output = JSON.parse(consoleOutput[0]); + expect(output.success).toBe(false); + expect(output.userCleanedUp).toBe(true); + const authCheck = output.checks.find((c: { name: string }) => c.name === 'authenticate'); + expect(authCheck.passed).toBe(false); + }); + + it('production refusal emits error envelope with production_env_refused code', async () => { + const exit = await expectCliExit(baseOptions({ envType: 'production' })); + expect(exit.exitCode).toBe(1); + expect(JSON.parse(errorOutput[0]).error.code).toBe('production_env_refused'); + expect(mockSdk.userManagement.createUser).not.toHaveBeenCalled(); + }); + + it('missing clientId emits error envelope with missing_client_id code', async () => { + const exit = await expectCliExit(baseOptions({ clientId: undefined })); + expect(exit.exitCode).toBe(1); + expect(JSON.parse(errorOutput[0]).error.code).toBe('missing_client_id'); + expect(mockSdk.userManagement.createUser).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/commands/verify-login.ts b/src/commands/verify-login.ts new file mode 100644 index 00000000..0faf8ffe --- /dev/null +++ b/src/commands/verify-login.ts @@ -0,0 +1,210 @@ +/** + * verify-login: prove the AuthKit login loop end-to-end against the active + * environment, headlessly (no browser, no email/OTP retrieval). + * + * The command creates a throwaway user, authenticates it with the password + * grant, asserts access + refresh tokens come back, and deletes the user in a + * `finally` block so cleanup always runs. It refuses to touch a production + * environment (unconditional, no override) because it mutates real users. + */ + +import chalk from 'chalk'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { outputJson, isJsonMode, exitWithError } from '../utils/output.js'; +import { exitWithCode, ExitCode } from '../utils/exit-codes.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; + +export type VerifyLoginMethod = 'password' | 'magic-auth'; + +export interface VerifyLoginOptions { + /** Already resolved by bin.ts via resolveApiKey (exits 4 if none). */ + apiKey: string; + /** --client-id ?? activeEnv.clientId. */ + clientId?: string; + /** resolveApiBaseUrl(). */ + baseUrl?: string; + /** activeEnv.type — drives the production refusal. */ + envType?: 'production' | 'sandbox' | 'unclaimed' | null; + /** activeEnv.name — display only. */ + envName?: string; + /** Authentication method to verify (default 'password'). */ + method?: VerifyLoginMethod; +} + +interface Check { + name: 'create_user' | 'authenticate' | 'tokens'; + passed: boolean; + detail: string; +} + +interface VerifyLoginResult { + success: boolean; + method: VerifyLoginMethod; + environment: string | null; + checks: Check[]; + userId: string | null; + userCleanedUp: boolean; + orphanedUserId: string | null; +} + +/** + * A clearly-labeled, non-routable throwaway email so the user is obvious in the + * dashboard if cleanup ever fails. + */ +function generateEmail(): string { + return `verify-login+${crypto.randomUUID()}@example.workos.dev`; +} + +/** + * A strong random password (>= 24 chars) mixing character classes so it + * satisfies the API password policy regardless of the specific rules. The + * fixed `Aa1!` suffix guarantees upper/lower/digit/symbol coverage on top of + * the random base64url body. + */ +function generatePassword(): string { + const bytes = new Uint8Array(24); + crypto.getRandomValues(bytes); + const body = Buffer.from(bytes).toString('base64url'); + return `${body}Aa1!`; +} + +/** + * Translate an authenticate error into a human-readable check detail. When the + * failure looks like "password authentication not enabled" (a config gap, not a + * broken login) surface an actionable message instead of a bare error so agents + * do not misread it as "login is broken". + * + * NOTE: the exact code/message returned when password auth is disabled is + * UNCONFIRMED against a live environment; the heuristic below is best-effort and + * falls back to the raw error message. + */ +function describeAuthError(err: unknown): string { + const message = err instanceof Error ? err.message : String(err); + if (/password/i.test(message) && /(not enabled|disabled|not available|unavailable)/i.test(message)) { + return ( + 'Password authentication is not enabled for this environment. Enable it in the WorkOS ' + + 'Dashboard (User Management → Authentication).' + ); + } + return message; +} + +function printHuman(result: VerifyLoginResult): void { + for (const check of result.checks) { + const mark = check.passed ? chalk.green('✓') : chalk.red('✗'); + console.log(`${mark} ${check.detail}`); + } + + if (result.userId) { + if (result.userCleanedUp) { + console.log(`${chalk.green('✓')} Cleaned up test user`); + } else { + console.log( + chalk.yellow(`⚠ Could not delete test user ${result.orphanedUserId} — delete it manually in the dashboard.`), + ); + } + } + + const envLabel = result.environment ?? 'the active environment'; + if (result.success) { + console.log(chalk.green(`Login verification passed for ${envLabel}.`)); + } else { + console.log(chalk.red(`Login verification FAILED for ${envLabel}.`)); + } +} + +export async function runVerifyLogin(options: VerifyLoginOptions): Promise { + const method: VerifyLoginMethod = options.method ?? 'password'; + const environment = options.envName ?? null; + + // 1. Production refusal — before any SDK call. verify-login mutates real + // users, so it must never touch production. Refuse on either the stored env + // type OR a production-format key (belt-and-suspenders for keys passed via + // --api-key / WORKOS_API_KEY with no matching stored env). + if (options.envType === 'production' || options.apiKey.startsWith('sk_live_')) { + exitWithError({ + code: 'production_env_refused', + message: + 'verify-login creates and deletes real users and will not run against a production ' + + 'environment. Switch to a staging/sandbox environment first.', + }); + } + + // 2. clientId check — required for the password grant. + const clientId = options.clientId; + if (!clientId) { + exitWithError({ + code: 'missing_client_id', + message: + `No client ID for the active environment. Pass --client-id, or configure one via ` + + `\`${formatWorkOSCommand('env add --client-id ')}\`.`, + }); + } + + // 3. Construct the client. + const { sdk } = createWorkOSClient(options.apiKey, options.baseUrl); + + // 4. Generate throwaway credentials. + const email = generateEmail(); + const password = generatePassword(); + + // 5. Run the round-trip. Cleanup runs in `finally` so no orphaned user + // remains on the happy path or on an authentication failure. + const checks: Check[] = []; + let userId: string | null = null; + let userCleanedUp = true; + let orphanedUserId: string | null = null; + + try { + const user = await sdk.userManagement.createUser({ email, password, emailVerified: true }); + userId = user.id; + checks.push({ name: 'create_user', passed: true, detail: `Created test user ${user.id}` }); + + const auth = await sdk.userManagement.authenticateWithPassword({ clientId, email, password }); + checks.push({ name: 'authenticate', passed: true, detail: 'password grant succeeded' }); + + const hasTokens = Boolean(auth.accessToken && auth.refreshToken); + checks.push({ + name: 'tokens', + passed: hasTokens, + detail: hasTokens ? 'access + refresh tokens present' : 'tokens missing from response', + }); + } catch (err) { + const failedStep: Check['name'] = userId ? 'authenticate' : 'create_user'; + checks.push({ name: failedStep, passed: false, detail: describeAuthError(err) }); + } finally { + if (userId) { + try { + await sdk.userManagement.deleteUser(userId); + } catch { + userCleanedUp = false; + orphanedUserId = userId; + } + } + } + + const success = checks.length > 0 && checks.every((c) => c.passed); + + const result: VerifyLoginResult = { + success, + method, + environment, + checks, + userId, + userCleanedUp, + orphanedUserId, + }; + + if (isJsonMode()) { + outputJson(result); + } else { + printHuman(result); + } + + // Exit follows the login verdict, not cleanup: a leaked user is surfaced + // machine-readably but does not fail the verification. No error arg here — the + // verdict was already emitted, so a second envelope would confuse parsers. + if (!success) { + exitWithCode(ExitCode.GENERAL_ERROR); + } +} diff --git a/src/doctor/checks/framework.spec.ts b/src/doctor/checks/framework.spec.ts index 6ac246be..2d5fda05 100644 --- a/src/doctor/checks/framework.spec.ts +++ b/src/doctor/checks/framework.spec.ts @@ -89,6 +89,14 @@ describe('checkFramework - new frameworks', () => { expect(result.name).toBe('SvelteKit'); }); + it('reports the vite.config port for a modern TanStack Start app (detectPort fan-out)', async () => { + await writeFile(join(dir, 'package.json'), makePackageJson({ '@tanstack/react-start': '^1.0.0' })); + await writeFile(join(dir, 'vite.config.ts'), 'export default { server: { host: "::", port: 8080 } }\n'); + const result = await checkFramework({ installDir: dir }); + expect(result.name).toBe('TanStack Start'); + expect(result.detectedPort).toBe(8080); + }); + it('returns null for no frameworks', async () => { await writeFile(join(dir, 'package.json'), makePackageJson({})); const result = await checkFramework({ installDir: dir }); diff --git a/src/doctor/issues.spec.ts b/src/doctor/issues.spec.ts index f3a9f498..33dd3b8e 100644 --- a/src/doctor/issues.spec.ts +++ b/src/doctor/issues.spec.ts @@ -1,7 +1,25 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { detectIssues } from './issues.js'; import type { DoctorReport } from './types.js'; +// getWorkOSCommand reads these; clear them so remediation copy is deterministic +// regardless of how the test runner itself was launched. +const NPM_KEYS = ['npm_command', 'npm_execpath', 'npm_config_user_agent'] as const; +let savedNpmEnv: Record; +function clearNpmEnv(): void { + savedNpmEnv = {}; + for (const k of NPM_KEYS) { + savedNpmEnv[k] = process.env[k]; + delete process.env[k]; + } +} +function restoreNpmEnv(): void { + for (const k of NPM_KEYS) { + if (savedNpmEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedNpmEnv[k]; + } +} + function baseReport(): Omit { return { version: '1.0.0', @@ -62,7 +80,51 @@ describe('detectIssues', () => { ); }); + describe('command hints in remediation route through formatWorkOSCommand', () => { + beforeEach(clearNpmEnv); + afterEach(restoreNpmEnv); + + function reportWithStaleSkills(): DoctorReport { + const report = baseReport() as DoctorReport; + report.skills = { + bundledVersion: '2.0.0', + agents: [{ agent: 'Claude Code', installedVersion: '1.0.0', stale: true }], + }; + return report; + } + + function reportWithMisconfiguredMcp(): DoctorReport { + const report = baseReport() as DoctorReport; + report.mcp = { + serverUrl: 'https://mcp.workos.com/mcp', + agents: [{ agent: 'Cursor', available: true, installed: true, misconfigured: true }], + }; + return report; + } + + it('uses bare commands when not launched via npx', () => { + const skills = detectIssues(reportWithStaleSkills()).find((i) => i.code === 'SKILLS_OUTDATED'); + expect(skills?.remediation).toBe('Run: workos skills install'); + + const mcp = detectIssues(reportWithMisconfiguredMcp()).find((i) => i.code === 'MCP_MISCONFIGURED'); + expect(mcp?.remediation).toBe('Run: workos mcp install'); + }); + + it('uses npx form when launched via npm exec', () => { + process.env.npm_command = 'exec'; + + const skills = detectIssues(reportWithStaleSkills()).find((i) => i.code === 'SKILLS_OUTDATED'); + expect(skills?.remediation).toBe('Run: npx workos@latest skills install'); + + const mcp = detectIssues(reportWithMisconfiguredMcp()).find((i) => i.code === 'MCP_MISCONFIGURED'); + expect(mcp?.remediation).toBe('Run: npx workos@latest mcp install'); + }); + }); + describe('MCP', () => { + beforeEach(clearNpmEnv); + afterEach(restoreNpmEnv); + it('adds no issue when MCP is absent or merely not installed', () => { const report = baseReport(); // No mcp field at all. diff --git a/src/doctor/issues.ts b/src/doctor/issues.ts index 6cce23be..a8b9fa3d 100644 --- a/src/doctor/issues.ts +++ b/src/doctor/issues.ts @@ -1,5 +1,6 @@ import type { Issue, DoctorReport } from './types.js'; import { getInstallHint, languageToSdkLanguage } from './checks/language.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; export const ISSUE_DEFINITIONS = { MISSING_API_KEY: { @@ -172,7 +173,7 @@ export function detectIssues(report: Omit): code: 'SKILLS_OUTDATED', severity: 'warning', message: `WorkOS skills outdated for ${agentList} — bundled: ${report.skills.bundledVersion}`, - remediation: 'Run: workos skills install', + remediation: `Run: ${formatWorkOSCommand('skills install')}`, details: { bundledVersion: report.skills.bundledVersion, stale: stale.map((a) => a.agent) }, }); } @@ -189,7 +190,7 @@ export function detectIssues(report: Omit): code: 'MCP_MISCONFIGURED', severity: 'warning', message: `WorkOS MCP server configured with an unexpected URL for ${agentList} — expected ${report.mcp.serverUrl}`, - remediation: 'Run: workos mcp install', + remediation: `Run: ${formatWorkOSCommand('mcp install')}`, details: { agents: misconfigured.map((a) => a.agent), expectedUrl: report.mcp.serverUrl }, }); } diff --git a/src/doctor/output.ts b/src/doctor/output.ts index 973b5820..296dcc8d 100644 --- a/src/doctor/output.ts +++ b/src/doctor/output.ts @@ -5,6 +5,7 @@ import type { InteractionModeSource } from '../utils/interaction-mode.js'; import type { DoctorReport, Issue } from './types.js'; import { renderSummaryBox, type SummaryBoxItem } from '../utils/summary-box.js'; import type { LockExpression } from '../utils/lock-art.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; export interface FormatOptions { verbose?: boolean; @@ -270,7 +271,7 @@ export function formatReport(report: DoctorReport, options?: FormatOptions): voi expression, title, items, - footer: 'workos doctor --copy | https://workos.com/docs', + footer: `${formatWorkOSCommand('doctor --copy')} | https://workos.com/docs`, }), ); console.log(''); diff --git a/src/integrations/nextjs/index.ts b/src/integrations/nextjs/index.ts index 6e1bcdc7..d7d9179e 100644 --- a/src/integrations/nextjs/index.ts +++ b/src/integrations/nextjs/index.ts @@ -75,6 +75,10 @@ export const config: FrameworkConfig = { 'Start your development server to test authentication', 'Visit the WorkOS Dashboard to manage users and settings', ], + // Client-side sign-in per the AuthKit Next.js guidance: trigger sign-in from + // a click handler with refreshAuth (getSignInUrl must not run during render). + getSignInSnippet: () => + 'Add a sign-in button in a client component: call refreshAuth({ ensureSignedIn: true }) on click', }, }; 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 { expect(sendEvent).toHaveBeenCalledWith({ type: 'CANCEL' }); }); - it('shows success summary box on complete', async () => { + it('shows structured success summary box on complete', async () => { await adapter.start(); const consoleSpy = vi.spyOn(console, 'log'); - emitter.emit('complete', { success: true, summary: 'All done!' }); + emitter.emit('complete', { + success: true, + summary: 'All done!', + completion: { + integration: 'nextjs', + devCommand: 'pnpm run dev', + url: 'http://localhost:3000', + files: ['app/auth/route.ts'], + nextSteps: [ + 'Run `pnpm run dev` to start your dev server', + 'Open http://localhost:3000 to test authentication', + ], + docsUrl: 'https://workos.com/docs/user-management/authkit/nextjs', + dashboardUrl: 'https://dashboard.workos.com', + }, + }); const output = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n'); expect(output).toContain('WorkOS AuthKit Installed'); + expect(output).toContain('pnpm run dev'); + expect(output).toContain('app/auth/route.ts'); consoleSpy.mockRestore(); }); + it('renders persistent step lines for file operations', async () => { + await adapter.start(); + const clack = await import('../../utils/clack.js'); + + emitter.emit('agent:start', {}); + emitter.emit('file:write', { path: '/proj/src/auth.ts', content: 'secret' }); + emitter.emit('file:edit', { path: '/proj/src/app.ts', oldContent: 'a', newContent: 'b' }); + + const stepCalls = vi.mocked(clack.default.log.step).mock.calls.map((c) => String(c[0])); + expect(stepCalls.some((s) => s.includes('src/auth.ts'))).toBe(true); + expect(stepCalls.some((s) => s.includes('src/app.ts'))).toBe(true); + }); + + it('dedupes consecutive same-path file operations', async () => { + await adapter.start(); + const clack = await import('../../utils/clack.js'); + + emitter.emit('agent:start', {}); + emitter.emit('file:edit', { path: '/proj/src/app.ts', oldContent: 'a', newContent: 'b' }); + emitter.emit('file:edit', { path: '/proj/src/app.ts', oldContent: 'b', newContent: 'c' }); + + const appCalls = vi.mocked(clack.default.log.step).mock.calls.filter((c) => String(c[0]).includes('src/app.ts')); + expect(appCalls).toHaveLength(1); + }); + + it('does not clobber the phase message back to a generic string', async () => { + vi.useFakeTimers(); + try { + await adapter.start(); + const clack = await import('../../utils/clack.js'); + const spinnerMock = { start: vi.fn(), stop: vi.fn(), message: vi.fn() }; + vi.mocked(clack.default.spinner).mockReturnValue(spinnerMock); + + emitter.emit('agent:start', {}); + emitter.emit('agent:progress', { step: 'Configuring middleware' }); + vi.advanceTimersByTime(5000); + + const clobbered = spinnerMock.message.mock.calls + .map((c) => String(c[0])) + .filter((m) => /Running AI agent/.test(m)); + expect(clobbered).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); + + it('restarts the spinner on the last phase message after logging a file op', async () => { + await adapter.start(); + const clack = await import('../../utils/clack.js'); + const spinnerMock = { start: vi.fn(), stop: vi.fn(), message: vi.fn() }; + vi.mocked(clack.default.spinner).mockReturnValue(spinnerMock); + + emitter.emit('agent:start', {}); + emitter.emit('agent:progress', { step: 'Configuring middleware' }); + emitter.emit('file:write', { path: '/proj/src/auth.ts', content: 'x' }); + + expect(spinnerMock.stop).toHaveBeenCalled(); + expect(spinnerMock.start).toHaveBeenCalledWith('Configuring middleware'); + }); + + it('renders Bash tool calls as step lines (agent:tool)', async () => { + await adapter.start(); + const clack = await import('../../utils/clack.js'); + + emitter.emit('agent:start', {}); + emitter.emit('agent:tool', { kind: 'command', detail: 'pnpm add @workos-inc/authkit-nextjs' }); + + const stepCalls = vi.mocked(clack.default.log.step).mock.calls.map((c) => String(c[0])); + expect(stepCalls.some((s) => s.includes('pnpm add @workos-inc/authkit-nextjs'))).toBe(true); + }); + it('shows error summary box on failure complete', async () => { await adapter.start(); const consoleSpy = vi.spyOn(console, 'log'); @@ -267,4 +355,30 @@ describe('CLIAdapter', () => { } }); }); + + describe('staging success copy', () => { + it('device path announces a fresh environment without "retrieved"', async () => { + await adapter.start(); + const clack = await import('../../utils/clack.js'); + + emitter.emit('staging:fetching', {}); + emitter.emit('staging:success', { source: 'device' }); + + const calls = vi.mocked(clack.default.log.success).mock.calls.map((c) => String(c[0])); + expect(calls).toContain('Set up a WorkOS environment for this install'); + expect(calls.join('\n')).not.toMatch(/retrieved/i); + }); + + it('stored path announces reuse of the active environment', async () => { + await adapter.start(); + const clack = await import('../../utils/clack.js'); + + emitter.emit('staging:fetching', {}); + emitter.emit('staging:success', { source: 'stored' }); + + const calls = vi.mocked(clack.default.log.success).mock.calls.map((c) => String(c[0])); + expect(calls).toContain('Using your active WorkOS environment'); + expect(calls.join('\n')).not.toMatch(/retrieved/i); + }); + }); }); diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index 9d1526d5..a4fd9c67 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -1,5 +1,6 @@ import type { InstallerAdapter, AdapterConfig } from './types.js'; import type { InstallerEventEmitter, InstallerEvents } from '../events.js'; +import { relative } from 'node:path'; import clack from '../../utils/clack.js'; import chalk from 'chalk'; import { getConfig } from '../settings.js'; @@ -35,9 +36,14 @@ export class CLIAdapter implements InstallerAdapter { // SIGINT handler for cleanup private sigIntHandler: (() => void) | null = null; - // Long-running agent update interval + // Long-running agent update interval (kept as a no-op field; never started) private agentUpdateInterval: NodeJS.Timeout | null = null; + // Last phase message shown on the agent spinner, restored after logging above it. + private lastAgentMessage = 'Running AI agent...'; + // Last file path rendered as a step line, to dedupe consecutive same-path ops. + private lastFileOp: string | null = null; + constructor(config: AdapterConfig) { this.emitter = config.emitter; this.sendEvent = config.sendEvent; @@ -112,6 +118,10 @@ export class CLIAdapter implements InstallerAdapter { this.subscribe('config:complete', this.handleConfigComplete); this.subscribe('agent:start', this.handleAgentStart); this.subscribe('agent:progress', this.handleAgentProgress); + // Persistent, append-only log of file operations + tool calls above the spinner. + this.subscribe('file:write', this.handleFileWrite); + this.subscribe('file:edit', this.handleFileEdit); + this.subscribe('agent:tool', this.handleAgentTool); this.subscribe('validation:start', this.handleValidationStart); this.subscribe('validation:issues', this.handleValidationIssues); this.subscribe('validation:complete', this.handleValidationComplete); @@ -268,9 +278,17 @@ export class CLIAdapter implements InstallerAdapter { this.spinner.start('Fetching your WorkOS credentials...'); }; - private handleStagingSuccess = (): void => { - this.stopSpinner('Credentials fetched'); - clack.log.success('WorkOS credentials retrieved automatically'); + private handleStagingSuccess = ({ source }: InstallerEvents['staging:success']): void => { + if (source === 'device') { + this.stopSpinner('Environment ready'); + clack.log.success('Set up a WorkOS environment for this install'); + } else if (source === 'stored') { + this.stopSpinner('Using active environment'); + clack.log.success('Using your active WorkOS environment'); + } else { + this.stopSpinner('Environment ready'); + clack.log.success('Using your WorkOS environment'); + } }; private handleEnvCredentialsFound = ({ sourcePath }: InstallerEvents['credentials:env:found']): void => { @@ -359,22 +377,52 @@ export class CLIAdapter implements InstallerAdapter { private handleAgentStart = (): void => { this.spinner = clack.spinner(); - this.spinner.start('Running AI agent...'); - - // Periodic status updates for long-running operations - let dots = 0; - this.agentUpdateInterval = setInterval(() => { - dots = (dots + 1) % 4; - const dotStr = '.'.repeat(dots + 1); - this.spinner?.message(`Running AI agent${dotStr}`); - }, 2000); + this.spinner.start(this.lastAgentMessage); + // No setInterval: clack animates its own frames, and the old 2s reset + // clobbered the current phase text set by handleAgentProgress. }; private handleAgentProgress = ({ step, detail }: InstallerEvents['agent:progress']): void => { const message = detail ? `${step}: ${detail}` : step; + this.lastAgentMessage = message; this.spinner?.message(message); }; + /** + * Render a persistent line above the running spinner: stop the spinner to + * finalize its line, emit the log, then restart it on the last phase message. + * Mirrors the existing stop→log and stop→start-new-spinner precedents. + */ + private logAboveSpinner(render: () => void): void { + const wasRunning = this.spinner !== null; + this.spinner?.stop(); + this.spinner = null; + render(); + if (wasRunning) { + this.spinner = clack.spinner(); + this.spinner.start(this.lastAgentMessage); + } + } + + private handleFileWrite = ({ path }: InstallerEvents['file:write']): void => { + if (path === this.lastFileOp) return; // dedupe consecutive same-path ops + this.lastFileOp = path; + const rel = relative(process.cwd(), path); + this.logAboveSpinner(() => clack.log.step(`Creating ${chalk.dim(rel)}`)); + }; + + private handleFileEdit = ({ path }: InstallerEvents['file:edit']): void => { + if (path === this.lastFileOp) return; + this.lastFileOp = path; + const rel = relative(process.cwd(), path); + this.logAboveSpinner(() => clack.log.step(`Editing ${chalk.dim(rel)}`)); + }; + + private handleAgentTool = ({ detail }: InstallerEvents['agent:tool']): void => { + const cmd = detail.length > 80 ? `${detail.slice(0, 77)}…` : detail; + this.logAboveSpinner(() => clack.log.step(`Running ${chalk.dim(cmd)}`)); + }; + private handleValidationStart = (): void => { this.stopAgentUpdates(); this.stopSpinner('Agent completed'); @@ -401,12 +449,12 @@ export class CLIAdapter implements InstallerAdapter { } }; - private handleComplete = ({ success, summary }: InstallerEvents['complete']): void => { + private handleComplete = ({ success, summary, completion }: InstallerEvents['complete']): void => { this.stopAgentUpdates(); this.stopSpinner(success ? 'Done' : 'Failed'); console.log(''); - console.log(renderCompletionSummary(success, summary)); + console.log(renderCompletionSummary(success, summary, completion)); console.log(''); // When we scaffolded a fresh app, the install ran in the current dir, so diff --git a/src/lib/adapters/dashboard-adapter.ts b/src/lib/adapters/dashboard-adapter.ts index c0a23809..90c832c2 100644 --- a/src/lib/adapters/dashboard-adapter.ts +++ b/src/lib/adapters/dashboard-adapter.ts @@ -1,5 +1,5 @@ import type { InstallerAdapter, AdapterConfig } from './types.js'; -import type { InstallerEventEmitter, InstallerEvents } from '../events.js'; +import type { InstallerEventEmitter, InstallerEvents, CompletionData } from '../events.js'; import { renderCompletionSummary } from '../../utils/summary-box.js'; /** @@ -13,7 +13,7 @@ export class DashboardAdapter implements InstallerAdapter { private sendEvent: AdapterConfig['sendEvent']; private cleanup: (() => void) | null = null; private isStarted = false; - private completionData: { success: boolean; summary?: string } | null = null; + private completionData: { success: boolean; summary?: string; completion?: CompletionData } | null = null; constructor(config: AdapterConfig) { this.emitter = config.emitter; @@ -65,8 +65,8 @@ export class DashboardAdapter implements InstallerAdapter { /** * Capture completion data for display after exit. */ - private handleComplete = ({ success, summary }: InstallerEvents['complete']): void => { - this.completionData = { success, summary }; + private handleComplete = ({ success, summary, completion }: InstallerEvents['complete']): void => { + this.completionData = { success, summary, completion }; }; async stop(): Promise { @@ -87,7 +87,13 @@ export class DashboardAdapter implements InstallerAdapter { if (this.completionData) { console.log(); - console.log(renderCompletionSummary(this.completionData.success, this.completionData.summary)); + console.log( + renderCompletionSummary( + this.completionData.success, + this.completionData.summary, + this.completionData.completion, + ), + ); console.log(); } diff --git a/src/lib/adapters/headless-adapter.spec.ts b/src/lib/adapters/headless-adapter.spec.ts index 0d788aa4..ec1c2e6f 100644 --- a/src/lib/adapters/headless-adapter.spec.ts +++ b/src/lib/adapters/headless-adapter.spec.ts @@ -144,6 +144,21 @@ describe('HeadlessAdapter', () => { expect(mockExit).not.toHaveBeenCalled(); await adapter.stop(); }); + + it('continues in CI mode without --no-git-check (WORKOS_MODE=ci bridges --ci)', async () => { + const adapter = createAdapter({ ci: true }); + await adapter.start(); + + emitter.emit('git:dirty', { files: ['package.json'] }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ + type: 'git:decision', + action: 'continue', + }); + expect(sendEvent).toHaveBeenCalledWith({ type: 'GIT_CONFIRMED' }); + expect(mockExit).not.toHaveBeenCalled(); + await adapter.stop(); + }); }); describe('credentials auto-resolution', () => { @@ -329,6 +344,56 @@ describe('HeadlessAdapter', () => { }); }); + describe('file operations', () => { + it('writes path-only NDJSON for file:write (never content)', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('file:write', { path: 'src/auth.ts', content: 'secret' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'file:write', path: 'src/auth.ts' }); + const leakedContent = mockWriteNDJSON.mock.calls.some((c) => c[0] && 'content' in (c[0] as object)); + expect(leakedContent).toBe(false); + await adapter.stop(); + }); + + it('writes path-only NDJSON for file:edit (never content)', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('file:edit', { path: 'src/app.ts', oldContent: 'a', newContent: 'b' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'file:edit', path: 'src/app.ts' }); + const leaked = mockWriteNDJSON.mock.calls.some( + (c) => c[0] && ('oldContent' in (c[0] as object) || 'newContent' in (c[0] as object)), + ); + expect(leaked).toBe(false); + await adapter.stop(); + }); + + it('dedupes consecutive same-path file operations', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('file:edit', { path: 'src/app.ts', oldContent: 'a', newContent: 'b' }); + emitter.emit('file:edit', { path: 'src/app.ts', oldContent: 'b', newContent: 'c' }); + + const editCalls = mockWriteNDJSON.mock.calls.filter((c) => (c[0] as { type?: string })?.type === 'file:edit'); + expect(editCalls).toHaveLength(1); + await adapter.stop(); + }); + + it('writes agent:tool NDJSON for surfaced commands', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('agent:tool', { kind: 'command', detail: 'ls' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'agent:tool', kind: 'command', detail: 'ls' }); + await adapter.stop(); + }); + }); + describe('terminal events', () => { it('writes complete event', async () => { const adapter = createAdapter(); @@ -345,6 +410,38 @@ describe('HeadlessAdapter', () => { await adapter.stop(); }); + it('spreads structured completion fields into the complete event when present', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('complete', { + success: true, + summary: 'ok', + completion: { + integration: 'nextjs', + devCommand: 'pnpm run dev', + url: 'http://localhost:3000', + files: ['a.ts'], + nextSteps: ['x'], + docsUrl: 'https://d', + dashboardUrl: 'https://dash', + }, + }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'complete', + success: true, + integration: 'nextjs', + devCommand: 'pnpm run dev', + url: 'http://localhost:3000', + files: ['a.ts'], + nextSteps: ['x'], + }), + ); + await adapter.stop(); + }); + it('writes error event', async () => { const adapter = createAdapter(); await adapter.start(); @@ -359,4 +456,16 @@ describe('HeadlessAdapter', () => { await adapter.stop(); }); }); + + describe('staging events', () => { + it('forwards the staging:success source onto the NDJSON stream', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('staging:success', { source: 'stored' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'staging:success', source: 'stored' }); + await adapter.stop(); + }); + }); }); diff --git a/src/lib/adapters/headless-adapter.ts b/src/lib/adapters/headless-adapter.ts index 6f167bbe..f981ed6b 100644 --- a/src/lib/adapters/headless-adapter.ts +++ b/src/lib/adapters/headless-adapter.ts @@ -14,6 +14,8 @@ export interface HeadlessOptions { noCommit?: boolean; createPr?: boolean; noGitCheck?: boolean; + /** CI mode (WORKOS_MODE=ci, or --ci when headless): pipelines never stop for a dirty tree. */ + ci?: boolean; } /** @@ -30,6 +32,7 @@ export class HeadlessAdapter implements InstallerAdapter { private options: HeadlessOptions; private isStarted = false; private scaffolded = false; + private lastFileOp: string | null = null; private handlers = new Map void>(); constructor(config: AdapterConfig & { options: HeadlessOptions }) { @@ -81,6 +84,11 @@ export class HeadlessAdapter implements InstallerAdapter { this.subscribe('agent:start', this.handleAgentStart); this.subscribe('agent:progress', this.handleAgentProgress); + // File operations + tool calls — path/detail only, never content + this.subscribe('file:write', this.handleFileWrite); + this.subscribe('file:edit', this.handleFileEdit); + this.subscribe('agent:tool', this.handleAgentTool); + // Validation this.subscribe('validation:start', this.handleValidationStart); this.subscribe('validation:issues', this.handleValidationIssues); @@ -181,7 +189,7 @@ export class HeadlessAdapter implements InstallerAdapter { private handleGitDirty = ({ files }: InstallerEvents['git:dirty']): void => { writeNDJSON({ type: 'git:status', dirty: true, files }); - if (this.options.noGitCheck) { + if (this.options.noGitCheck || this.options.ci) { writeNDJSON({ type: 'git:decision', action: 'continue' }); this.sendEvent({ type: 'GIT_CONFIRMED' }); return; @@ -258,8 +266,8 @@ export class HeadlessAdapter implements InstallerAdapter { writeNDJSON({ type: 'staging:fetching' }); }; - private handleStagingSuccess = (): void => { - writeNDJSON({ type: 'staging:success' }); + private handleStagingSuccess = ({ source }: InstallerEvents['staging:success']): void => { + writeNDJSON({ type: 'staging:success', source }); }; // ===== Config ===== @@ -279,6 +287,24 @@ export class HeadlessAdapter implements InstallerAdapter { writeNDJSON({ type: 'agent:progress', message }); }; + // ===== File Operations (path-only, no content) ===== + + private handleFileWrite = ({ path }: InstallerEvents['file:write']): void => { + if (path === this.lastFileOp) return; + this.lastFileOp = path; + writeNDJSON({ type: 'file:write', path }); + }; + + private handleFileEdit = ({ path }: InstallerEvents['file:edit']): void => { + if (path === this.lastFileOp) return; + this.lastFileOp = path; + writeNDJSON({ type: 'file:edit', path }); + }; + + private handleAgentTool = ({ kind, detail }: InstallerEvents['agent:tool']): void => { + writeNDJSON({ type: 'agent:tool', kind, detail }); + }; + // ===== Validation ===== private handleValidationStart = ({ framework }: InstallerEvents['validation:start']): void => { @@ -363,8 +389,24 @@ export class HeadlessAdapter implements InstallerAdapter { // ===== Terminal Events ===== - private handleComplete = ({ success, summary }: InstallerEvents['complete']): void => { - writeNDJSON({ type: 'complete', success, summary, scaffolded: this.scaffolded }); + private handleComplete = ({ success, summary, completion }: InstallerEvents['complete']): void => { + writeNDJSON({ + type: 'complete', + success, + summary, + scaffolded: this.scaffolded, + // Spread structured fields only when present, so existing consumers that + // emit `complete` without `completion` keep their exact payload shape. + ...(completion + ? { + integration: completion.integration, + devCommand: completion.devCommand, + url: completion.url, + files: completion.files, + nextSteps: completion.nextSteps, + } + : {}), + }); }; private handleError = ({ message, stack }: InstallerEvents['error']): void => { diff --git a/src/lib/agent-interface.ts b/src/lib/agent-interface.ts index d839e86b..5f6e74d0 100644 --- a/src/lib/agent-interface.ts +++ b/src/lib/agent-interface.ts @@ -841,6 +841,15 @@ function handleSDKMessage( } } + // Surface Bash commands (post-permission: this branch only fires for + // tool calls that were actually allowed, unlike the canUseTool closure). + if (toolName === 'Bash' && input) { + const command = input.command as string; + if (command) { + emitter?.emit('agent:tool', { kind: 'command', detail: command }); + } + } + // Track Read operations for caching file content later if (toolName === 'Read' && input && block.id) { const filePath = input.file_path as string; diff --git a/src/lib/api-key.ts b/src/lib/api-key.ts index d8135b86..decdebb4 100644 --- a/src/lib/api-key.ts +++ b/src/lib/api-key.ts @@ -9,6 +9,7 @@ import { getActiveEnvironment } from './config-store.js'; import { exitWithError } from '../utils/output.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; const DEFAULT_BASE_URL = 'https://api.workos.com'; @@ -22,7 +23,7 @@ export function resolveApiKey(options?: ApiKeyOptions): string { exitWithError({ code: 'no_api_key', - message: 'No API key configured. Run `workos env add` to configure an environment, or set WORKOS_API_KEY.', + message: `No API key configured. Run \`${formatWorkOSCommand('env add')}\` to configure an environment, or set WORKOS_API_KEY.`, }); } diff --git a/src/lib/completion-data.spec.ts b/src/lib/completion-data.spec.ts new file mode 100644 index 00000000..1bbe9796 --- /dev/null +++ b/src/lib/completion-data.spec.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { buildCompletionData } from './completion-data.js'; +import { resolveDevCommand } from './dev-command.js'; +import { detectPort } from './port-detection.js'; + +describe('buildCompletionData', () => { + let installDir: string; + + beforeEach(() => { + installDir = mkdtempSync(join(tmpdir(), 'workos-completion-test-')); + }); + + afterEach(() => { + rmSync(installDir, { recursive: true, force: true }); + }); + + function writePackageJson(content: Record): void { + writeFileSync(join(installDir, 'package.json'), JSON.stringify(content)); + } + + function writeFile(name: string, content = ''): void { + writeFileSync(join(installDir, name), content); + } + + const baseDeps = { + resolveDevCommand, + detectPort, + docsUrl: 'https://d', + dashboardUrl: 'https://dash', + }; + + it('builds a lockfile-aware dev command, url, files, and next steps (happy path)', async () => { + writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } }); + writeFile('pnpm-lock.yaml', 'lockfileVersion: 9\n'); + + const data = await buildCompletionData( + { integration: 'nextjs', changedFiles: ['app/auth/route.ts', '.env.local'], installDir }, + baseDeps, + ); + + expect(data.devCommand).toBe('pnpm run dev'); + expect(data.url).toBe('http://localhost:3000'); + expect(data.files).toHaveLength(2); + expect(data.nextSteps[0]).toContain('pnpm run dev'); + expect(data.nextSteps[1]).toContain('http://localhost:3000'); + expect(data.docsUrl).toBe('https://d'); + expect(data.dashboardUrl).toBe('https://dash'); + expect(data.integration).toBe('nextjs'); + }); + + it('respects a Vite server.port override for react', async () => { + writePackageJson({ scripts: { dev: 'vite' }, dependencies: { react: '18.0.0', vite: '5.0.0' } }); + writeFile('vite.config.ts', 'export default { server: { port: 8080 } };'); + + const data = await buildCompletionData({ integration: 'react', changedFiles: [], installDir }, baseDeps); + + expect(data.url).toBe('http://localhost:8080'); + }); + + it('handles empty changedFiles (--no-commit shape) without throwing', async () => { + writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } }); + + const data = await buildCompletionData({ integration: 'nextjs', changedFiles: [], installDir }, baseDeps); + + expect(data.files).toEqual([]); + expect(data.nextSteps[0]).toContain('start your dev server'); + expect(data.nextSteps[1]).toContain('test authentication'); + }); + + it('drops the generic "start dev server" framework step but keeps others', async () => { + writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } }); + + const data = await buildCompletionData( + { integration: 'nextjs', changedFiles: [], installDir }, + { + ...baseDeps, + frameworkNextSteps: [ + 'Start your development server to test authentication', + 'Visit the WorkOS Dashboard to manage users and settings', + ], + }, + ); + + // The generic "start dev server" duplicate is filtered out... + expect(data.nextSteps.filter((s) => /start your development server/i.test(s))).toHaveLength(0); + // ...but the dashboard step is retained. + expect(data.nextSteps.some((s) => /WorkOS Dashboard/.test(s))).toBe(true); + }); + + it('passes through a sign-in snippet into nextSteps and completion', async () => { + writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } }); + + const snippet = 'Add a sign-in button using refreshAuth({ ensureSignedIn: true })'; + const data = await buildCompletionData( + { integration: 'nextjs', changedFiles: [], installDir }, + { ...baseDeps, signInSnippet: snippet }, + ); + + expect(data.signInSnippet).toBe(snippet); + expect(data.nextSteps).toContain(snippet); + }); + + it('omits the sign-in snippet when not provided', async () => { + writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } }); + + const data = await buildCompletionData({ integration: 'nextjs', changedFiles: [], installDir }, baseDeps); + + expect(data.signInSnippet).toBeUndefined(); + expect(data.nextSteps.some((s) => /refreshAuth/.test(s))).toBe(false); + }); +}); diff --git a/src/lib/completion-data.ts b/src/lib/completion-data.ts new file mode 100644 index 00000000..929ccf73 --- /dev/null +++ b/src/lib/completion-data.ts @@ -0,0 +1,62 @@ +import type { CompletionData } from './events.js'; +import type { DevCommandResult } from './dev-command.js'; +import type { Integration } from './constants.js'; + +/** + * Machine-context slice needed to build completion data. + */ +export interface CompletionContext { + integration: string; + changedFiles?: string[]; + installDir: string; +} + +/** + * Injected dependencies. The impure lookups (registry/settings) are resolved by + * the caller and passed as plain values so the builder stays pure + unit-testable. + */ +export interface CompletionDataDeps { + resolveDevCommand: (dir: string) => Promise; + detectPort: (integration: Integration, dir: string) => number; + docsUrl: string; + dashboardUrl: string; + /** Enriched getOutroNextSteps copy for the framework */ + frameworkNextSteps?: string[]; + /** Per-framework "add a sign-in link" snippet */ + signInSnippet?: string; +} + +/** + * Build the structured completion payload for a successful install. + * + * Deterministic given its inputs. The dev command is derived from the + * lockfile-aware `resolveDevCommand` (never from context.packageManager, which + * is flag/env-derived and unreliable). Copy fields are injected by the caller. + */ +export async function buildCompletionData(ctx: CompletionContext, deps: CompletionDataDeps): Promise { + const dev = await deps.resolveDevCommand(ctx.installDir); + const devCommand = [dev.command, ...dev.args].join(' '); + const port = deps.detectPort(ctx.integration as Integration, ctx.installDir); + const url = `http://localhost:${port}`; + const files = ctx.changedFiles ?? []; + + const concrete = [ + `Run \`${devCommand}\` to start your dev server`, + `Open ${url} to test authentication`, + ...(deps.signInSnippet ? [deps.signInSnippet] : []), + ]; + // Drop the framework's generic "start dev server" line — the concrete step + // above already names the exact lockfile-aware command. + const framework = (deps.frameworkNextSteps ?? []).filter((s) => !/start .*dev(elopment)? server/i.test(s)); + + return { + integration: ctx.integration, + devCommand, + url, + files, + nextSteps: [...concrete, ...framework], + docsUrl: deps.docsUrl, + dashboardUrl: deps.dashboardUrl, + signInSnippet: deps.signInSnippet, + }; +} diff --git a/src/lib/config-store.spec.ts b/src/lib/config-store.spec.ts index b44acafa..ced52314 100644 --- a/src/lib/config-store.spec.ts +++ b/src/lib/config-store.spec.ts @@ -433,6 +433,47 @@ describe('config-store', () => { }); }); + describe('owner identity fields', () => { + it('round-trips ownerEmail and ownerUserId through file storage', () => { + saveConfig({ + activeEnvironment: 'staging', + environments: { + staging: { + name: 'staging', + type: 'sandbox', + apiKey: 'sk_test', + clientId: 'client_x', + ownerEmail: 'a@example.com', + ownerUserId: 'u1', + }, + }, + }); + const env = getConfig()?.environments['staging']; + expect(env?.ownerEmail).toBe('a@example.com'); + expect(env?.ownerUserId).toBe('u1'); + }); + + it('round-trips owner fields through keyring storage', () => { + setInsecureConfigStorage(false); + saveConfig({ + activeEnvironment: 'staging', + environments: { + staging: { + name: 'staging', + type: 'sandbox', + apiKey: 'sk_test', + clientId: 'client_x', + ownerEmail: 'a@example.com', + ownerUserId: 'u1', + }, + }, + }); + const env = getConfig()?.environments['staging']; + expect(env?.ownerEmail).toBe('a@example.com'); + expect(env?.ownerUserId).toBe('u1'); + }); + }); + describe('markEnvironmentClaimed', () => { it('renames environment from unclaimed to sandbox', () => { saveConfig({ @@ -459,6 +500,50 @@ describe('config-store', () => { expect(config?.activeEnvironment).toBe('sandbox'); }); + it('leaves owner fields undefined when the unclaimed env had none', () => { + saveConfig({ + activeEnvironment: 'unclaimed', + environments: { + unclaimed: { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }, + }, + }); + + markEnvironmentClaimed(); + + const sandbox = getConfig()?.environments['sandbox']; + expect(sandbox?.ownerEmail).toBeUndefined(); + expect(sandbox?.ownerUserId).toBeUndefined(); + }); + + it('carries through owner fields present on the unclaimed env', () => { + saveConfig({ + activeEnvironment: 'unclaimed', + environments: { + unclaimed: { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + ownerEmail: 'a@example.com', + ownerUserId: 'u1', + }, + }, + }); + + markEnvironmentClaimed(); + + const sandbox = getConfig()?.environments['sandbox']; + expect(sandbox?.ownerEmail).toBe('a@example.com'); + expect(sandbox?.ownerUserId).toBe('u1'); + }); + it('does nothing when no config', () => { // No config saved — should not throw expect(() => markEnvironmentClaimed()).not.toThrow(); diff --git a/src/lib/config-store.ts b/src/lib/config-store.ts index fa48f623..1e1fc99f 100644 --- a/src/lib/config-store.ts +++ b/src/lib/config-store.ts @@ -20,6 +20,10 @@ interface BaseEnvironmentConfig { name: string; apiKey: string; endpoint?: string; + /** Email of the account that owns this environment. Stamped when `auth login` provisions Staging for a known account; undefined for unclaimed/manually-added envs. */ + ownerEmail?: string; + /** User ID of the account that owns this environment. Stamped alongside ownerEmail. */ + ownerUserId?: string; } export interface ClaimedEnvironmentConfig extends BaseEnvironmentConfig { @@ -235,6 +239,27 @@ export function getActiveEnvironment(): EnvironmentConfig | null { return config.environments[config.activeEnvironment] ?? null; } +/** + * Set the active environment by name. + * + * No-op when there is no config or the named environment does not exist — + * callers should not be able to point `activeEnvironment` at a missing key. + */ +export function setActiveEnvironment(name: string): void { + const config = getConfig(); + if (!config || !config.environments[name]) return; + config.activeEnvironment = name; + saveConfig(config); +} + +/** Pick a non-colliding environments key: `base`, else `base-2`, `base-3`, … */ +export function freshEnvKey(config: CliConfig, base: string): string { + if (!config.environments[base]) return base; + let i = 2; + while (config.environments[`${base}-${i}`]) i++; + return `${base}-${i}`; +} + export function getConfigPath(): string { return getConfigFilePath(); } @@ -301,6 +326,8 @@ export function markEnvironmentClaimed(): void { apiKey: env.apiKey, clientId: env.clientId, ...(env.endpoint && { endpoint: env.endpoint }), + ...(env.ownerEmail && { ownerEmail: env.ownerEmail }), + ...(env.ownerUserId && { ownerUserId: env.ownerUserId }), }; if (oldKey !== newKey) { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 0163dee4..12efb8bc 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -46,6 +46,16 @@ export const OAUTH_PORT = settings.legacy.oauthPort; export const MCP_SERVER_NAME = 'workos'; export const MCP_SERVER_URL = 'https://mcp.workos.com/mcp'; +/** + * Shared description for the `migrations` command, referenced by both the yargs + * registration (`bin.ts`) and the JSON help registry (`help-json.ts`) so the two + * surfaces cannot drift. Advertises the generic-CSV path (e.g. Supabase) without + * implying a dedicated exporter exists for it. Hand-maintained — not derived from + * `@workos/migrations`. + */ +export const MIGRATIONS_DESCRIPTION = + 'Migrate users to WorkOS from Auth0, Cognito, Clerk, Firebase, or any provider via CSV (e.g. Supabase)'; + /** * Common glob patterns to ignore when searching for files. * Used by multiple integrations. diff --git a/src/lib/events.ts b/src/lib/events.ts index abedc70a..86358e17 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -1,5 +1,32 @@ import { EventEmitter } from 'events'; +/** + * Structured data describing a successful installation, used to render the + * completion summary and enrich the headless `complete` NDJSON event. + * + * Defined here (not in installer-core.types.ts) to avoid an import cycle: + * installer-core.types.ts already imports from this module, so this module + * must not import back. + */ +export interface CompletionData { + /** Integration identifier (e.g. 'nextjs') */ + integration: string; + /** Lockfile-aware dev command, e.g. "pnpm run dev" */ + devCommand: string; + /** App URL with the detected port, e.g. "http://localhost:3000" */ + url: string; + /** Changed files (git-relative), full list — display cap lives in the renderer */ + files: string[]; + /** Composed concrete + framework next-step lines */ + nextSteps: string[]; + /** Per-framework docs URL */ + docsUrl: string; + /** WorkOS dashboard URL */ + dashboardUrl: string; + /** Optional per-framework "add a sign-in link" snippet */ + signInSnippet?: string; +} + export interface InstallerEvents { status: { message: string }; output: { text: string; isError?: boolean }; @@ -11,7 +38,7 @@ export interface InstallerEvents { 'confirm:response': { id: string; confirmed: boolean }; 'credentials:request': { requiresApiKey: boolean }; 'credentials:response': { apiKey: string; clientId: string }; - complete: { success: boolean; summary?: string }; + complete: { success: boolean; summary?: string; completion?: CompletionData }; error: { message: string; stack?: string }; 'state:enter': { state: string }; @@ -44,7 +71,7 @@ export interface InstallerEvents { 'device:error': { message: string }; // Staging API events 'staging:fetching': Record; - 'staging:success': Record; + 'staging:success': { source?: 'device' | 'stored' }; 'staging:error': { message: string; statusCode?: number }; 'config:start': Record; 'config:complete': Record; @@ -53,6 +80,8 @@ export interface InstallerEvents { 'agent:success': { summary?: string }; 'agent:failure': { message: string; stack?: string }; 'agent:retry': { attempt: number; maxRetries: number }; + // Surfaced agent tool activity (e.g. Bash commands run during install) + 'agent:tool': { kind: 'command'; detail: string }; 'validation:retry:start': { attempt: number }; 'validation:retry:complete': { attempt: number; passed: boolean }; diff --git a/src/lib/framework-config.ts b/src/lib/framework-config.ts index 5ff1e6d8..df2bfdcb 100644 --- a/src/lib/framework-config.ts +++ b/src/lib/framework-config.ts @@ -143,6 +143,13 @@ export interface UIConfig { /** Generate "Next steps" bullets from context */ getOutroNextSteps: (context: any) => string[]; + + /** + * Optional per-framework "add a sign-in link" snippet, surfaced as a next + * step in the completion summary. Only defined for frameworks that scaffold + * interactive auth UI. Copy must stay accurate to what the agent scaffolds. + */ + getSignInSnippet?: (context: any) => string; } /** diff --git a/src/lib/installer-core.test-utils.ts b/src/lib/installer-core.test-utils.ts index d736b0d0..2cd0b27e 100644 --- a/src/lib/installer-core.test-utils.ts +++ b/src/lib/installer-core.test-utils.ts @@ -47,6 +47,7 @@ export function createEventCapture() { 'agent:progress', 'agent:success', 'agent:failure', + 'agent:tool', 'file:write', 'file:edit', 'prompt:request', diff --git a/src/lib/installer-core.ts b/src/lib/installer-core.ts index 3b13c2f9..59d97d34 100644 --- a/src/lib/installer-core.ts +++ b/src/lib/installer-core.ts @@ -13,10 +13,12 @@ import type { WorkspaceCheckOutput, } from './installer-core.types.js'; import type { InstallerOptions } from '../utils/types.js'; +import type { CompletionData } from './events.js'; import type { DeviceAuthResult, DeviceAuthResponse } from './device-auth.js'; import type { StagingCredentials } from './staging-api.js'; import { getManualPrInstructions } from './post-install.js'; import { hasGhCli } from '../utils/git-utils.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; export const installerMachine = setup({ types: { @@ -164,7 +166,7 @@ export const installerMachine = setup({ context.emitter.emit('staging:fetching', {}); }, emitStagingSuccess: ({ context }) => { - context.emitter.emit('staging:success', {}); + context.emitter.emit('staging:success', { source: context.deviceAuth ? 'device' : 'stored' }); }, emitStagingError: ({ context }) => { const message = context.error?.message ?? 'Failed to fetch staging credentials'; @@ -312,8 +314,11 @@ export const installerMachine = setup({ }, emitComplete: ({ context }) => { const summary = context.agentSummary ?? 'WorkOS AuthKit installed successfully!'; - context.emitter.emit('complete', { success: true, summary }); + context.emitter.emit('complete', { success: true, summary, completion: context.completion }); }, + assignCompletion: assign({ + completion: ({ event }) => (event as unknown as { output?: CompletionData }).output, + }), }, guards: { @@ -354,6 +359,9 @@ export const installerMachine = setup({ runAgent: fromPromise(async () => { throw new Error('runAgent not implemented - provide via machine.provide()'); }), + buildCompletion: fromPromise(async () => { + throw new Error('buildCompletion not implemented - provide via machine.provide()'); + }), // Credential discovery actors detectEnvFiles: fromPromise(async () => { throw new Error('detectEnvFiles not implemented - provide via machine.provide()'); @@ -704,7 +712,7 @@ export const installerMachine = setup({ `Because this directory isn't empty, a new app wasn't scaffolded, and no recognized ` + `framework (such as a package.json with Next.js) was found to install into.\n\n` + `Next steps:\n` + - ` - New app: run \`workos install\` in an empty directory to scaffold Next.js + AuthKit.\n` + + ` - New app: run \`${formatWorkOSCommand('install')}\` in an empty directory to scaffold Next.js + AuthKit.\n` + ` - Existing project: run from the directory that contains your package.json, or pass --install-dir .`, ); }, @@ -1000,7 +1008,7 @@ export const installerMachine = setup({ checking: { always: [ { - target: '#installer.complete', + target: '#installer.buildingCompletion', guard: 'shouldSkipPostInstall', }, { target: 'detectingChanges' }, @@ -1156,7 +1164,20 @@ export const installerMachine = setup({ }, }, onDone: { - target: 'complete', + target: 'buildingCompletion', + }, + }, + + // Compute the structured completion payload before entering `complete`. + // `emitComplete` is a synchronous entry action, but the builder is async + // (lockfile-aware dev command, port detection), so it must run in an actor + // first. Errors never block completion — they fall through to the static box. + buildingCompletion: { + invoke: { + src: 'buildCompletion', + input: ({ context }) => ({ context }), + onDone: { target: 'complete', actions: ['assignCompletion'] }, + onError: { target: 'complete' }, }, }, diff --git a/src/lib/installer-core.types.ts b/src/lib/installer-core.types.ts index f004455e..46aec300 100644 --- a/src/lib/installer-core.types.ts +++ b/src/lib/installer-core.types.ts @@ -1,4 +1,4 @@ -import type { InstallerEventEmitter } from './events.js'; +import type { InstallerEventEmitter, CompletionData } from './events.js'; import type { InstallerOptions } from '../utils/types.js'; import type { Integration } from './constants.js'; import type { PackageManager } from './scaffold/scaffold.js'; @@ -68,6 +68,8 @@ export interface InstallerMachineContext { autoScaffold?: boolean; /** Whether create-next-app actually ran and succeeded (for telemetry) */ scaffolded?: boolean; + /** Structured completion data for the success summary (built pre-complete) */ + completion?: CompletionData; } /** diff --git a/src/lib/port-detection.spec.ts b/src/lib/port-detection.spec.ts index 35591576..ea574cfd 100644 --- a/src/lib/port-detection.spec.ts +++ b/src/lib/port-detection.spec.ts @@ -155,3 +155,73 @@ describe('port-detection — ruby/rails puma', () => { expect(detectPort('ruby', dir)).toBe(4000); }); }); + +describe('port-detection — tanstack-start config', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'port-tanstack-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('parses port from vite.config.ts (modern @tanstack/react-start)', async () => { + await writeFile( + join(dir, 'vite.config.ts'), + "import { defineConfig } from 'vite'\nexport default defineConfig({ server: { host: '::', port: 8080 }, plugins: [] })\n", + ); + expect(detectPort('tanstack-start', dir)).toBe(8080); + }); + + it('falls back to legacy app.config.ts (Vinxi) when no vite config', async () => { + await writeFile(join(dir, 'app.config.ts'), 'export default { server: { port: 4200 } }\n'); + expect(detectPort('tanstack-start', dir)).toBe(4200); + }); + + it('prefers vite.config.ts over app.config.ts when both present', async () => { + await writeFile( + join(dir, 'vite.config.ts'), + "import { defineConfig } from 'vite'\nexport default defineConfig({ server: { port: 8080 } })\n", + ); + await writeFile(join(dir, 'app.config.ts'), 'export default { server: { port: 4200 } }\n'); + expect(detectPort('tanstack-start', dir)).toBe(8080); + }); + + it.each(['vite.config.js', 'vite.config.mjs'])('parses port from %s', async (fileName) => { + await writeFile(join(dir, fileName), 'export default { server: { port: 5555 } }\n'); + expect(detectPort('tanstack-start', dir)).toBe(5555); + }); + + it('falls back to default 3000 when no config file present', () => { + expect(detectPort('tanstack-start', dir)).toBe(3000); + }); +}); + +describe('port-detection — vite frameworks', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'port-vite-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it.each(['react', 'react-router', 'vanilla-js'] as const)('%s parses port from vite.config.ts', async (framework) => { + await writeFile( + join(dir, 'vite.config.ts'), + "import { defineConfig } from 'vite'\nexport default defineConfig({ server: { port: 4200 } })\n", + ); + expect(detectPort(framework, dir)).toBe(4200); + }); + + it.each(['react', 'react-router', 'vanilla-js'] as const)( + '%s falls back to default 5173 when no vite config present', + (framework) => { + expect(detectPort(framework, dir)).toBe(5173); + }, + ); +}); diff --git a/src/lib/port-detection.ts b/src/lib/port-detection.ts index f0a8cb74..420de6ad 100644 --- a/src/lib/port-detection.ts +++ b/src/lib/port-detection.ts @@ -52,6 +52,20 @@ function parseViteConfigPort(configPath: string): number | null { return null; } +/** + * Try vite.config.{ts,js,mjs} in installDir for a server.port value. + * NOTE: parseViteConfigPort uses a greedy first-match /port\s*:\s*.../ regex, + * so an unrelated `port:` (e.g. hmr.port) appearing before server.port wins. + * Pre-existing behavior for all Vite frameworks — not a regression here. + */ +function parseViteConfigPortFromDir(installDir: string): number | null { + for (const p of ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']) { + const port = parseViteConfigPort(join(installDir, p)); + if (port) return port; + } + return null; +} + /** * Parse port from Next.js package.json scripts. * Next.js uses: "dev": "next dev -p 4000" or --port 4000 @@ -206,24 +220,17 @@ export function detectPort(integration: Integration, installDir: string): number break; case 'tanstack-start': - detectedPort = parseTanStackPort(installDir); + // Modern @tanstack/react-start is Vite-based (port in vite.config.ts); + // legacy Vinxi projects use app.config.ts. Try Vite first. + detectedPort = parseViteConfigPortFromDir(installDir) ?? parseTanStackPort(installDir); break; case 'react': case 'react-router': - case 'vanilla-js': { + case 'vanilla-js': // Vite-based frameworks - const viteConfigs = [ - join(installDir, 'vite.config.ts'), - join(installDir, 'vite.config.js'), - join(installDir, 'vite.config.mjs'), - ]; - for (const configPath of viteConfigs) { - detectedPort = parseViteConfigPort(configPath); - if (detectedPort) break; - } + detectedPort = parseViteConfigPortFromDir(installDir); break; - } case 'dotnet': detectedPort = parseDotnetPort(installDir); diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 34fda45b..47a5b623 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -4,6 +4,10 @@ import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; import { installerMachine } from './installer-core.js'; import { createInstallerEventEmitter } from './events.js'; +import type { CompletionData } from './events.js'; +import { buildCompletionData } from './completion-data.js'; +import { resolveDevCommand } from './dev-command.js'; +import { getConfig as getInstallerSettings } from './settings.js'; import { CLIAdapter } from './adapters/cli-adapter.js'; import { DashboardAdapter } from './adapters/dashboard-adapter.js'; import type { InstallerAdapter } from './adapters/types.js'; @@ -224,6 +228,7 @@ export async function runWithCore(options: InstallerOptions): Promise { noCommit: augmentedOptions.noCommit, createPr: augmentedOptions.createPr, noGitCheck: augmentedOptions.noGitCheck, + ci: augmentedOptions.ci, }, }); } else if (options.dashboard) { @@ -335,6 +340,7 @@ export async function runWithCore(options: InstallerOptions): Promise { ...installerOptions, apiKey: credentials?.apiKey, clientId: credentials?.clientId, + credentialSource: context.credentialSource, emitter: context.emitter, }; const summary = await runIntegrationInstallerFn(integration, agentOptions); @@ -350,6 +356,33 @@ export async function runWithCore(options: InstallerOptions): Promise { } }), + buildCompletion: fromPromise( + async ({ input }) => { + const { integration, changedFiles, options: installerOptions } = input.context; + if (!integration) return undefined; + try { + const registry = await getRegistry(); + const mod = registry.get(integration); + const cfg = mod?.config; + const settings = getInstallerSettings(); + return await buildCompletionData( + { integration, changedFiles, installDir: installerOptions.installDir }, + { + resolveDevCommand, + detectPort, + docsUrl: cfg?.metadata.docsUrl ?? settings.documentation.workosDocsUrl, + dashboardUrl: settings.documentation.dashboardUrl, + frameworkNextSteps: cfg?.ui.getOutroNextSteps?.({}) ?? [], + signInSnippet: cfg?.ui.getSignInSnippet?.({}), + }, + ); + } catch { + // Degrade to the static fallback box rather than blocking completion. + return undefined; + } + }, + ), + // Credential discovery actors detectEnvFiles: fromPromise(async ({ input }) => { return checkForEnvFiles(input.installDir); diff --git a/src/lib/unclaimed-env-provision.spec.ts b/src/lib/unclaimed-env-provision.spec.ts index 95a23d92..c1346a9b 100644 --- a/src/lib/unclaimed-env-provision.spec.ts +++ b/src/lib/unclaimed-env-provision.spec.ts @@ -25,7 +25,9 @@ vi.mock('../utils/clack.js', () => ({ default: mockClack })); const mockGetConfig = vi.fn(); const mockSaveConfig = vi.fn(); const mockGetActiveEnvironment = vi.fn(() => null); -vi.mock('./config-store.js', () => ({ +vi.mock('./config-store.js', async (importOriginal) => ({ + // freshEnvKey is pure — use the real one so key-collision behavior stays honest. + freshEnvKey: (await importOriginal()).freshEnvKey, getConfig: (...args: unknown[]) => mockGetConfig(...args), saveConfig: (...args: unknown[]) => mockSaveConfig(...args), getActiveEnvironment: (...args: unknown[]) => mockGetActiveEnvironment(...args), @@ -135,6 +137,35 @@ describe('unclaimed-env-provision', () => { ); }); + it('never clobbers an existing unclaimed env — saves under a fresh key', async () => { + mockGetConfig.mockReturnValue({ + activeEnvironment: 'unclaimed', + environments: { + unclaimed: { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_prior', + clientId: 'client_prior', + claimToken: 'ct_prior', + }, + }, + }); + mockProvisionUnclaimedEnvironment.mockResolvedValueOnce(validProvisionResult); + + await tryProvisionUnclaimedEnv({ installDir: testDir }); + + expect(mockSaveConfig).toHaveBeenCalledWith( + expect.objectContaining({ + environments: expect.objectContaining({ + // The prior claim token lives only in this config — it must survive. + unclaimed: expect.objectContaining({ claimToken: 'ct_prior' }), + 'unclaimed-2': expect.objectContaining({ claimToken: 'ct_token123' }), + }), + activeEnvironment: 'unclaimed-2', + }), + ); + }); + it('writes .env.local with all credentials including cookie password and claim token (JS project)', async () => { writeFileSync(join(testDir, 'package.json'), '{}'); mockProvisionUnclaimedEnvironment.mockResolvedValueOnce(validProvisionResult); diff --git a/src/lib/unclaimed-env-provision.ts b/src/lib/unclaimed-env-provision.ts index e3149e6e..787cca81 100644 --- a/src/lib/unclaimed-env-provision.ts +++ b/src/lib/unclaimed-env-provision.ts @@ -8,12 +8,13 @@ import chalk from 'chalk'; import { provisionUnclaimedEnvironment, UnclaimedEnvApiError } from './unclaimed-env-api.js'; -import { getConfig, saveConfig, getActiveEnvironment } from './config-store.js'; +import { getConfig, saveConfig, getActiveEnvironment, freshEnvKey } from './config-store.js'; import type { CliConfig } from './config-store.js'; import { writeCredentialsEnv } from './env-writer.js'; import { logInfo, logError } from '../utils/debug.js'; import { renderStderrBox } from '../utils/box.js'; import clack from '../utils/clack.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; export interface UnclaimedEnvProvisionOptions { installDir: string; @@ -52,16 +53,19 @@ export async function tryProvisionUnclaimedEnv(options: UnclaimedEnvProvisionOpt writeCredentialsEnv(options.installDir, envVars); - // Save to config store (after .env.local succeeds) + // Save to config store (after .env.local succeeds). A fresh key so a repeated + // provision never clobbers an earlier env's claim token (unrecoverable — it + // lives only in this config). const config: CliConfig = getConfig() ?? { environments: {} }; - config.environments['unclaimed'] = { - name: 'unclaimed', + const key = freshEnvKey(config, 'unclaimed'); + config.environments[key] = { + name: key, type: 'unclaimed', apiKey: result.apiKey, clientId: result.clientId, claimToken: result.claimToken, }; - config.activeEnvironment = 'unclaimed'; + config.activeEnvironment = key; saveConfig(config); // Verify config persisted — critical for `workos env claim` in a later process @@ -73,7 +77,7 @@ export async function tryProvisionUnclaimedEnv(options: UnclaimedEnvProvisionOpt } logInfo('[unclaimed-env-provision] Unclaimed environment provisioned and saved'); - const inner = ` ✓ ${chalk.green('Environment provisioned')} — Run ${chalk.cyan('workos env claim')} to keep it. `; + const inner = ` ✓ ${chalk.green('Environment provisioned')} — Run ${chalk.cyan(formatWorkOSCommand('env claim'))} to keep it. `; renderStderrBox(inner, chalk.green); return true; diff --git a/src/lib/unclaimed-warning.ts b/src/lib/unclaimed-warning.ts index 9ce9c033..bb48f40f 100644 --- a/src/lib/unclaimed-warning.ts +++ b/src/lib/unclaimed-warning.ts @@ -14,6 +14,7 @@ import { logError, logInfo } from '../utils/debug.js'; import { isJsonMode } from '../utils/output.js'; import { renderStderrBox } from '../utils/box.js'; import { markStartupNoticeShown } from './startup-notice-gate.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; let warningShownThisSession = false; let claimCheckDoneThisSession = false; @@ -60,7 +61,7 @@ export async function warnIfUnclaimed(): Promise { warningShownThisSession = true; if (!isJsonMode()) { - const inner = ` ${chalk.yellow('⚠ Unclaimed environment')} — Run ${chalk.cyan('workos env claim')} to keep your data. `; + const inner = ` ${chalk.yellow('⚠ Unclaimed environment')} — Run ${chalk.cyan(formatWorkOSCommand('env claim'))} to keep your data. `; renderStderrBox(inner, chalk.yellow); // Claim the one-notice-per-run slot so the lower-priority MCP banner defers. markStartupNoticeShown(); diff --git a/src/lib/validation/security-checks.ts b/src/lib/validation/security-checks.ts index 5cef486a..321812f4 100644 --- a/src/lib/validation/security-checks.ts +++ b/src/lib/validation/security-checks.ts @@ -1,6 +1,7 @@ import { checkAuthPatterns } from '../../doctor/checks/auth-patterns.js'; import type { AuthPatternFinding, FrameworkInfo, EnvironmentInfo, SdkInfo } from '../../doctor/types.js'; import type { ValidationIssue } from './types.js'; +import { formatWorkOSCommand } from '../../utils/command-invocation.js'; /** * The "security subset" of `workos doctor`'s auth-pattern checks that the @@ -117,6 +118,6 @@ export function formatBlockingSecurityError(blocking: AuthPatternFinding[]): str '', ...lines, '', - 'Fix the issues above (or run `workos doctor` for details and remediation) and re-run the installer.', + `Fix the issues above (or run \`${formatWorkOSCommand('doctor')}\` for details and remediation) and re-run the installer.`, ].join('\n'); } diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index 8aae97ca..7c1fef7b 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -373,7 +373,7 @@ describe('validateInstallation', () => { // Redirect URI says /auth/callback but route is at /callback writeFileSync( join(testDir, '.env.local'), - 'WORKOS_API_KEY=sk_test\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:3000/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + 'WORKOS_API_KEY=sk_test\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:5173/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', ); // Route exists at DIFFERENT path (dot notation) mkdirSync(join(testDir, 'app', 'routes'), { recursive: true }); @@ -390,7 +390,7 @@ describe('validateInstallation', () => { writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-router': '^7.0.0' } })); writeFileSync( join(testDir, '.env.local'), - 'WORKOS_API_KEY=sk_test\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:3000/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + 'WORKOS_API_KEY=sk_test\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:5173/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', ); // Route at correct path using dot notation: auth.callback.tsx mkdirSync(join(testDir, 'app', 'routes'), { recursive: true }); @@ -819,4 +819,174 @@ describe('validateInstallation', () => { expect(envIssue?.hint).toContain('VITE_WORKOS_CLIENT_ID'); }); }); + + describe('redirect URI port validation', () => { + const portError = (result: { issues: { severity: string; message: string }[] }) => + result.issues.find((i) => i.severity === 'error' && /port/i.test(i.message)); + + it('flags TanStack redirect URI port that mismatches the vite.config port (friction scenario)', async () => { + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ dependencies: { '@tanstack/react-start': '^1.0.0' } }), + ); + // dev server runs on 8080 (Vite), but the redirect URI points at :3000 + writeFileSync(join(testDir, 'vite.config.ts'), 'export default { server: { host: "::", port: 8080 } }\n'); + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:3000/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'routes', 'auth', 'callback'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'routes', 'auth', 'callback', 'index.tsx'), + 'export default function Callback() {}', + ); + + const result = await validateInstallation('tanstack-start', testDir, { runBuild: false }); + + expect(result.passed).toBe(false); + expect(portError(result)).toBeDefined(); + }); + + it('does not flag a port issue when TanStack redirect URI port matches the vite.config port', async () => { + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ dependencies: { '@tanstack/react-start': '^1.0.0' } }), + ); + writeFileSync(join(testDir, 'vite.config.ts'), 'export default { server: { host: "::", port: 8080 } }\n'); + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:8080/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'routes', 'auth', 'callback'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'routes', 'auth', 'callback', 'index.tsx'), + 'export default function Callback() {}', + ); + + const result = await validateInstallation('tanstack-start', testDir, { runBuild: false }); + + expect(portError(result)).toBeUndefined(); + const mismatchIssue = result.issues.find((i) => i.message.includes('no matching route file')); + expect(mismatchIssue).toBeUndefined(); + }); + + it('flags React Router redirect URI port against the default 5173 (helper in isolation)', async () => { + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-router': '^7.0.0' } })); + // No vite.config → detected port is the RR default 5173; env points at :3000 + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:3000/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'routes'), { recursive: true }); + writeFileSync(join(testDir, 'app', 'routes', 'callback.tsx'), 'export default function Callback() {}'); + + const result = await validateInstallation('react-router', testDir, { runBuild: false }); + + expect(result.passed).toBe(false); + expect(portError(result)).toBeDefined(); + }); + + it('flags Next.js redirect URI port against a -p dev-script port', async () => { + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ + dependencies: { '@workos-inc/authkit-nextjs': '^1.0.0' }, + scripts: { dev: 'next dev -p 4000' }, + }), + ); + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nNEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/api/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'api', 'auth', 'callback'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'api', 'auth', 'callback', 'route.ts'), + "import { handleAuth } from '@workos-inc/authkit-nextjs';", + ); + writeFileSync(join(testDir, 'middleware.ts'), 'export const authkitMiddleware = () => {};'); + writeFileSync(join(testDir, 'app', 'layout.tsx'), ''); + + const result = await validateInstallation('nextjs', testDir, { runBuild: false }); + + expect(portError(result)).toBeDefined(); + }); + + it('skips the port check for non-local (production) hosts', async () => { + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ dependencies: { '@tanstack/react-start': '^1.0.0' } }), + ); + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=https://app.example.com/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'routes', 'auth', 'callback'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'routes', 'auth', 'callback', 'index.tsx'), + 'export default function Callback() {}', + ); + + const result = await validateInstallation('tanstack-start', testDir, { runBuild: false }); + + expect(portError(result)).toBeUndefined(); + }); + + it('normalizes a missing port to the protocol default (80) before comparing', async () => { + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-router': '^7.0.0' } })); + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'routes'), { recursive: true }); + writeFileSync(join(testDir, 'app', 'routes', 'callback.tsx'), 'export default function Callback() {}'); + + const result = await validateInstallation('react-router', testDir, { runBuild: false }); + + expect(portError(result)).toBeDefined(); + }); + + it('recognizes the IPv6 localhost form [::1]', async () => { + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-router': '^7.0.0' } })); + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://[::1]:3000/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'routes'), { recursive: true }); + writeFileSync(join(testDir, 'app', 'routes', 'callback.tsx'), 'export default function Callback() {}'); + + const result = await validateInstallation('react-router', testDir, { runBuild: false }); + + expect(portError(result)).toBeDefined(); + }); + + it('route-mismatch hint uses the redirect URI real origin, not a hardcoded localhost:3000', async () => { + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ + dependencies: { '@workos-inc/authkit-nextjs': '^1.0.0' }, + scripts: { dev: 'next dev -p 8080' }, + }), + ); + // detected port (8080) matches the env port so no port-mismatch noise + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nNEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:8080/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + // route lives at a DIFFERENT path → triggers the mismatch hint + mkdirSync(join(testDir, 'app', 'api', 'auth', 'callback'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'api', 'auth', 'callback', 'route.ts'), + "import { handleAuth } from '@workos-inc/authkit-nextjs';", + ); + writeFileSync(join(testDir, 'middleware.ts'), 'export const authkitMiddleware = () => {};'); + writeFileSync(join(testDir, 'app', 'layout.tsx'), ''); + + const result = await validateInstallation('nextjs', testDir, { runBuild: false }); + + const mismatchIssue = result.issues.find((i) => i.message.includes('no matching route file')); + expect(mismatchIssue).toBeDefined(); + expect(mismatchIssue?.hint).toContain('http://localhost:8080'); + expect(mismatchIssue?.hint).not.toContain('localhost:3000'); + }); + }); }); diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index 18146b7a..dfb44950 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -4,6 +4,7 @@ import { join } from 'path'; import fg from 'fast-glob'; import type { ValidationResult, ValidationRules, ValidationIssue } from './types.js'; import { runBuildValidation } from './build-validator.js'; +import { detectPort } from '../port-detection.js'; export interface ValidateOptions { variant?: string; @@ -250,6 +251,32 @@ export async function validateFrameworkSpecific(framework: string, projectDir: s return issues; } +/** + * Compares the redirect URI's effective port to the detected dev server port, + * for local dev hosts only, and records an error-severity issue on mismatch. + * Skips production URIs and placeholder hosts like http://test. + */ +function validateRedirectUriPort(url: URL, framework: string, projectDir: string, issues: ValidationIssue[]): void { + // Only enforce for local dev hosts; skip production URIs and placeholder + // hosts like http://test. Node parses IPv6 hostnames with brackets: [::1]. + const localHosts = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); + if (!localHosts.has(url.hostname)) return; + + const effectivePort = url.port || (url.protocol === 'https:' ? '443' : '80'); + const detected = detectPort(framework, projectDir); + + if (Number(effectivePort) !== detected) { + issues.push({ + type: 'env', + severity: 'error', + message: `Redirect URI port :${effectivePort} does not match the detected dev server port :${detected}`, + hint: + `Update the redirect URI to use port :${detected} in .env.local and the WorkOS dashboard ` + + `(redirect URI, CORS origin, homepage URL), or change your dev server to run on port ${effectivePort}.`, + }); + } +} + /** * Validates that the Next.js redirect URI matches an existing callback route. * @@ -277,6 +304,7 @@ async function validateNextjsRedirectUri(projectDir: string, issues: ValidationI try { const url = new URL(redirectUri); callbackPath = url.pathname; + validateRedirectUriPort(url, 'nextjs', projectDir, issues); } catch { issues.push({ type: 'env', @@ -319,7 +347,7 @@ async function validateNextjsRedirectUri(projectDir: string, issues: ValidationI const actualPath = '/' + existingRoutes[0].replace(/^(src\/)?app\//, '').replace(/\/route\.(ts|tsx|js|jsx)$/, ''); hint = `Found callback route at ${existingRoutes[0]} but redirect URI points to ${callbackPath}. Either:\n` + - ` 1. Change NEXT_PUBLIC_WORKOS_REDIRECT_URI to http://localhost:3000${actualPath}\n` + + ` 1. Change NEXT_PUBLIC_WORKOS_REDIRECT_URI to ${new URL(redirectUri).origin}${actualPath}\n` + ` 2. Move the route to app/${routePath}/route.ts`; } @@ -360,6 +388,7 @@ async function validateReactRouterRedirectUri(projectDir: string, issues: Valida try { const url = new URL(redirectUri); callbackPath = url.pathname; + validateRedirectUriPort(url, 'react-router', projectDir, issues); } catch { issues.push({ type: 'env', @@ -418,7 +447,7 @@ async function validateReactRouterRedirectUri(projectDir: string, issues: Valida .replace(/\./g, '/'); hint = `Found callback route at ${actualFile} but redirect URI points to ${callbackPath}. Either:\n` + - ` 1. Change WORKOS_REDIRECT_URI to http://localhost:3000${actualPath}\n` + + ` 1. Change WORKOS_REDIRECT_URI to ${new URL(redirectUri).origin}${actualPath}\n` + ` 2. Move the route to app/routes/${dotPath}.tsx`; } @@ -458,6 +487,7 @@ async function validateTanstackStartRedirectUri(projectDir: string, issues: Vali try { const url = new URL(redirectUri); callbackPath = url.pathname; + validateRedirectUriPort(url, 'tanstack-start', projectDir, issues); } catch { issues.push({ type: 'env', @@ -500,7 +530,7 @@ async function validateTanstackStartRedirectUri(projectDir: string, issues: Vali .replace(/\/index$/, ''); hint = `Found callback route at ${actualFile} but redirect URI points to ${callbackPath}. Either:\n` + - ` 1. Change WORKOS_REDIRECT_URI to http://localhost:3000${actualPath}\n` + + ` 1. Change WORKOS_REDIRECT_URI to ${new URL(redirectUri).origin}${actualPath}\n` + ` 2. Move the route to app/routes/${routePath}.tsx`; } diff --git a/src/run.ts b/src/run.ts index 1a61a5ca..91d0195f 100644 --- a/src/run.ts +++ b/src/run.ts @@ -2,6 +2,7 @@ import { readEnvironment } from './utils/environment.js'; import { runWithCore } from './lib/run-with-core.js'; import type { InstallerOptions } from './utils/types.js'; import { createInstallerEventEmitter } from './lib/events.js'; +import { isCiMode } from './utils/interaction-mode.js'; import path from 'path'; import { EventEmitter } from 'events'; @@ -33,6 +34,7 @@ export type InstallerArgs = { direct?: boolean; scaffold?: boolean; pm?: string; + router?: 'app' | 'pages'; }; /** @@ -58,7 +60,9 @@ function buildOptions(argv: InstallerArgs): InstallerOptions { forceInstall: merged.forceInstall ?? false, installDir, local: merged.local ?? false, - ci: merged.ci ?? false, + // Bridge WORKOS_MODE=ci to the downstream `options.ci` paths (git checks, + // package-manager select) so it fully behaves like the hidden --ci flag. + ci: (merged.ci ?? false) || isCiMode(), skipAuth: merged.skipAuth ?? false, apiKey: merged.apiKey, clientId: merged.clientId, @@ -74,6 +78,7 @@ function buildOptions(argv: InstallerArgs): InstallerOptions { direct: merged.direct ?? false, scaffold: merged.scaffold ?? false, pm: merged.pm, + router: merged.router, emitter: createInstallerEventEmitter(), // Will be replaced in runWithCore }; } diff --git a/src/steps/upload-environment-variables/index.spec.ts b/src/steps/upload-environment-variables/index.spec.ts new file mode 100644 index 00000000..61bdbe71 --- /dev/null +++ b/src/steps/upload-environment-variables/index.spec.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Force a provider to be detected so we reach the upload decision point. +// Use a class (not an arrow fn) so `new VercelEnvironmentProvider()` constructs. +vi.mock('./providers/vercel.js', () => ({ + VercelEnvironmentProvider: class { + name = 'Vercel'; + detect = vi.fn(async () => true); + uploadEnvVars = 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() }, + }, +})); + +vi.mock('../../utils/analytics.js', () => ({ + analytics: { capture: vi.fn(), shutdown: vi.fn(), setTag: vi.fn() }, +})); + +const clack = (await import('../../utils/clack.js')).default; +const { uploadEnvironmentVariablesStep } = await import('./index.js'); +const { setInteractionMode, resetInteractionModeForTests } = await import('../../utils/interaction-mode.js'); + +const integration = 'nextjs' as never; +const options = { installDir: '/proj' } as never; + +describe('uploadEnvironmentVariablesStep — non-interactive skip', () => { + beforeEach(() => { + resetInteractionModeForTests(); + vi.clearAllMocks(); + vi.mocked(clack.isCancel).mockReturnValue(false); + }); + + afterEach(() => { + resetInteractionModeForTests(); + }); + + it('ci mode skips the upload prompt and returns []', async () => { + setInteractionMode({ mode: 'ci', source: 'env' }); + + const result = await uploadEnvironmentVariablesStep({}, { integration, options }); + + expect(result).toEqual([]); + expect(clack.select).not.toHaveBeenCalled(); + }); + + it('human mode reaches the prompt', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + vi.mocked(clack.select).mockResolvedValueOnce(false as never); + + const result = await uploadEnvironmentVariablesStep({}, { integration, options }); + + expect(result).toEqual([]); + expect(clack.select).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/steps/upload-environment-variables/index.ts b/src/steps/upload-environment-variables/index.ts index b703e06d..12665664 100644 --- a/src/steps/upload-environment-variables/index.ts +++ b/src/steps/upload-environment-variables/index.ts @@ -3,6 +3,7 @@ import { traceStep } from '../../telemetry.js'; import { analytics } from '../../utils/analytics.js'; import clack from '../../utils/clack.js'; import { abortIfCancelled } from '../../utils/clack-utils.js'; +import { isPromptAllowed } from '../../utils/interaction-mode.js'; import type { InstallerOptions } from '../../utils/types.js'; import { EnvironmentProvider } from './EnvironmentProvider.js'; import { VercelEnvironmentProvider } from './providers/vercel.js'; @@ -37,6 +38,18 @@ export const uploadEnvironmentVariablesStep = async ( return []; } + // Non-interactive mode: default to skipping the upload rather than prompting + // (or failing fast via the abortIfCancelled guard below). + if (!isPromptAllowed()) { + analytics.capture('installer interaction', { + action: 'not uploading environment variables', + reason: 'non-interactive mode', + provider: provider.name, + integration, + }); + return []; + } + const upload: boolean = await abortIfCancelled( clack.select({ message: `It looks like you are using ${provider.name}. Would you like to upload the environment variables?`, diff --git a/src/utils/clack-utils.spec.ts b/src/utils/clack-utils.spec.ts new file mode 100644 index 00000000..003cfe5e --- /dev/null +++ b/src/utils/clack-utils.spec.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +/** + * Locks the Layer-1 invariant of the agent/CI path fix: when prompts are not + * allowed (agent/ci/non-TTY), abortIfCancelled must fail fast with a structured + * CliExit *without awaiting* the input promise — awaiting a never-resolving + * prompt is exactly the hang this guard fixes. + */ + +vi.mock('./clack.js', () => ({ + default: { + isCancel: vi.fn(() => false), + cancel: vi.fn(), + intro: vi.fn(), + outro: vi.fn(), + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + step: vi.fn(), + message: vi.fn(), + }, + }, +})); + +vi.mock('./analytics.js', () => ({ + analytics: { shutdown: vi.fn(), setTag: vi.fn(), capture: vi.fn() }, +})); + +const clack = (await import('./clack.js')).default; +const { setInteractionMode, resetInteractionModeForTests } = await import('./interaction-mode.js'); +const { CliExit } = await import('./cli-exit.js'); +const { setOutputMode } = await import('./output.js'); +const { abortIfCancelled, getOrAskForWorkOSCredentials } = await import('./clack-utils.js'); + +describe('abortIfCancelled — non-interactive guard', () => { + beforeEach(() => { + resetInteractionModeForTests(); + setOutputMode('human'); + vi.clearAllMocks(); + vi.mocked(clack.isCancel).mockReturnValue(false); + }); + + afterEach(() => { + resetInteractionModeForTests(); + setOutputMode('human'); + }); + + it('agent mode throws CliExit without awaiting the input promise', async () => { + setInteractionMode({ mode: 'agent', source: 'env' }); + // A never-resolving input: if the guard awaited it, this test would time out. + await expect(abortIfCancelled(new Promise(() => {}))).rejects.toThrow(CliExit); + }); + + it('ci mode throws CliExit without awaiting the input promise', async () => { + setInteractionMode({ mode: 'ci', source: 'env' }); + await expect(abortIfCancelled(new Promise(() => {}))).rejects.toThrow(CliExit); + }); + + it('emits a structured non_interactive_prompt error in JSON mode', async () => { + setInteractionMode({ mode: 'agent', source: 'env' }); + setOutputMode('json'); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(abortIfCancelled(new Promise(() => {}))).rejects.toThrow(CliExit); + + expect(errorSpy).toHaveBeenCalled(); + const payload = JSON.parse(String(errorSpy.mock.calls[0][0])); + expect(payload.error.code).toBe('non_interactive_prompt'); + + errorSpy.mockRestore(); + }); + + it('human mode passes a resolved value through', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + vi.mocked(clack.isCancel).mockReturnValue(false); + await expect(abortIfCancelled('value')).resolves.toBe('value'); + }); +}); + +describe('getOrAskForWorkOSCredentials — credential-source-aware copy', () => { + const base = { apiKey: 'sk_test', clientId: 'client_x', installDir: '/tmp' } as const; + + beforeEach(() => { + setOutputMode('human'); + vi.clearAllMocks(); + }); + + afterEach(() => { + setOutputMode('human'); + }); + + it('announces "you provided" for cli/manual/undefined sources', async () => { + for (const credentialSource of ['cli', 'manual', undefined] as const) { + vi.clearAllMocks(); + const result = await getOrAskForWorkOSCredentials({ ...base, credentialSource }); + expect(result).toEqual({ apiKey: 'sk_test', clientId: 'client_x' }); + expect(clack.log.info).toHaveBeenCalledTimes(1); + expect(clack.log.info).toHaveBeenCalledWith('Using the WorkOS credentials you provided'); + } + }); + + it('stays silent for device/stored/env sources (machine already announced)', async () => { + for (const credentialSource of ['device', 'stored', 'env'] as const) { + vi.clearAllMocks(); + await getOrAskForWorkOSCredentials({ ...base, credentialSource }); + expect(clack.log.info).not.toHaveBeenCalled(); + } + }); + + it('stays silent in dashboard mode', async () => { + await getOrAskForWorkOSCredentials({ ...base, dashboard: true, credentialSource: 'cli' }); + expect(clack.log.info).not.toHaveBeenCalled(); + }); + + it('stays silent in JSON output mode (no human copy into JSON)', async () => { + setOutputMode('json'); + await getOrAskForWorkOSCredentials({ ...base, credentialSource: 'cli' }); + expect(clack.log.info).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/clack-utils.ts b/src/utils/clack-utils.ts index aca23bf4..ec91a73b 100644 --- a/src/utils/clack-utils.ts +++ b/src/utils/clack-utils.ts @@ -17,6 +17,8 @@ import { analytics } from './analytics.js'; import clack from './clack.js'; import { INTEGRATION_CONFIG } from '../lib/config.js'; import { SPAWN_OPTS } from './platform.js'; +import { isPromptAllowed } from './interaction-mode.js'; +import { exitWithError, isJsonMode } from './output.js'; /** * Redact sensitive info (API keys, client secrets) from a string. @@ -59,6 +61,28 @@ export async function abortIfCancelled( input: T | Promise, integration?: Integration, ): Promise> { + if (!isPromptAllowed()) { + // Never await `input` in non-interactive mode — awaiting a never-resolving + // prompt is exactly the hang this guard fixes. Fail fast with a structured + // error instead. Placed before analytics.shutdown('cancelled') so a + // non-interactive block is not mislabeled as a user cancel. + exitWithError({ + code: 'non_interactive_prompt', + message: + `This step requires interactive input${integration ? ` for ${integration}` : ''}, but the CLI is running ` + + `in a non-interactive mode (agent/CI/non-TTY). Pass the required flags (e.g. --router app|pages for Next.js) ` + + `or run in an interactive terminal.`, + recovery: { + hints: [ + { + description: + 'Re-run in an interactive terminal, or pass the flags that answer this prompt (e.g. --router).', + }, + ], + }, + }); + } + await analytics.shutdown('cancelled'); const resolvedInput = await input; @@ -522,7 +546,7 @@ export function isUsingTypeScript({ installDir }: Pick, + _options: Pick, requireApiKey: boolean = true, ): Promise<{ apiKey: string; @@ -533,9 +557,15 @@ export async function getOrAskForWorkOSCredentials( // If credentials provided via CLI (e.g., CI mode or dashboard mode), use them if ((!requireApiKey || apiKey) && clientId) { - // Only log in non-dashboard mode - if (!_options.dashboard) { - clack.log.info('Using provided WorkOS credentials'); + // Say "you provided" only when the user actually supplied credentials + // (cli/manual, or an unknown source). For device/stored/env the state + // machine already announced the source accurately before this runs, so + // stay silent rather than double-announce. Gate on human output mode so + // this never pollutes JSON/NDJSON. + const source = _options.credentialSource; + const userProvided = source === 'cli' || source === 'manual' || source === undefined; + if (!_options.dashboard && !isJsonMode() && userProvided) { + clack.log.info('Using the WorkOS credentials you provided'); } return { apiKey: apiKey || '', clientId }; } diff --git a/src/utils/command-hints-guard.spec.ts b/src/utils/command-hints-guard.spec.ts new file mode 100644 index 00000000..f006b29b --- /dev/null +++ b/src/utils/command-hints-guard.spec.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { readFile } from 'node:fs/promises'; +import { relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fg from 'fast-glob'; + +// Resolve the src/ root from this file's location (src/utils/*.spec.ts). +const SRC_DIR = fileURLToPath(new URL('..', import.meta.url)); + +// Lowercase `workos` only — excludes prose like "WorkOS AuthKit" / "WorkOS Dashboard". +// NOTE: this alternation must track the top-level command families registered in +// bin.ts. When a new command family is added (PRs 5/6 etc.), add it here so its +// hardcoded hints are caught, and route the hint through formatWorkOSCommand(). +const HINT_RE = + /\bworkos (?:auth|env|config|telemetry|doctor|install|uninstall|vault|api|seed|mcp|skills|debug|organization|org|user|migrations|login|logout|whoami|dev)\b/g; + +// Strip block comments then line comments (the (?` prefix). Every entry is a reviewed decision, not a live hint. +const ALLOWLIST = new Set([ + 'bin.ts :: workos api', // yargs .example() help (static --help documentation) + 'utils/help-json.ts :: workos api', // examples array (mirrors bin.ts) + 'commands/seed.ts :: workos seed', // SEED_TEMPLATE persisted-file comment + 'emulate/workos/index.ts :: workos seed', // prose error label, not a runnable command +]); + +async function discover(): Promise> { + const files = await fg('**/*.ts', { + cwd: SRC_DIR, + absolute: true, + ignore: ['**/*.spec.ts', '**/*.d.ts', 'utils/command-invocation.ts'], + }); + const found = new Set(); + for (const file of files) { + const rel = relative(SRC_DIR, file); + const stripped = stripComments(await readFile(file, 'utf-8')); + for (const line of stripped.split('\n')) { + if (line.includes('formatWorkOSCommand') || line.includes('getWorkOSCommand')) continue; + for (const m of line.matchAll(HINT_RE)) found.add(`${rel} :: ${m[0]}`); + } + } + return found; +} + +describe('command hints route through formatWorkOSCommand', () => { + it('has no hardcoded `workos ` hint outside the allowlist', async () => { + const discovered = await discover(); + const offenders = [...discovered].filter((d) => !ALLOWLIST.has(d)).sort(); + expect( + offenders, + `Hardcoded command hint(s) found. Route through formatWorkOSCommand()/getWorkOSCommand(), ` + + `or add to ALLOWLIST if intentionally static: ${offenders.join(', ')}`, + ).toEqual([]); + }); +}); diff --git a/src/utils/help-json.spec.ts b/src/utils/help-json.spec.ts index 6362eae0..2b099ab0 100644 --- a/src/utils/help-json.spec.ts +++ b/src/utils/help-json.spec.ts @@ -1,8 +1,9 @@ import { describe, it, expect, vi } from 'vitest'; -vi.mock('../lib/settings.js', () => ({ - getVersion: vi.fn(() => '0.7.3'), -})); +vi.mock('../lib/settings.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getVersion: vi.fn(() => '0.7.3') }; +}); const { buildCommandTree, extractHelpJsonCommand } = await import('./help-json.js'); @@ -56,6 +57,7 @@ describe('help-json', () => { 'auth status', 'skills', 'doctor', + 'verify-login', 'env', 'organization', 'user', @@ -100,7 +102,7 @@ describe('help-json', () => { 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'])); + expect(subNames).toEqual(expect.arrayContaining(['add', 'remove', 'switch', 'list', 'claim', 'provision'])); }); it('returns organization subtree with CRUD subcommands', () => { @@ -124,6 +126,36 @@ describe('help-json', () => { }); }); + describe('accurate command copy', () => { + it('migrations description advertises the generic-CSV / Supabase path', () => { + const tree = buildCommandTree('migrations'); + expect(tree.name).toBe('migrations'); + expect(tree.description).toMatch(/CSV/i); + expect(tree.description).toMatch(/Supabase/i); + }); + + it('migrations subcommands include the discoverable export entry point', () => { + const tree = buildCommandTree('migrations'); + const subNames = tree.commands!.map((c) => c.name); + expect(subNames).toContain('export'); + // process-roles is the name the package actually registers (not process-role-definitions) + expect(subNames).toContain('process-roles'); + expect(subNames).not.toContain('process-role-definitions'); + }); + + it('env remove description states it is local-only', () => { + const env = buildCommandTree('env'); + const remove = env.commands!.find((c) => c.name === 'remove'); + expect(remove!.description).toMatch(/local/i); + }); + + it('env claim description states the action is permanent', () => { + const env = buildCommandTree('env'); + const claim = env.commands!.find((c) => c.name === 'claim'); + expect(claim!.description).toMatch(/permanent/i); + }); + }); + describe('positional schemas', () => { it('env add has optional positionals', () => { const env = buildCommandTree('env'); diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index b377a912..40bd89a5 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -8,6 +8,7 @@ import { getVersion } from '../lib/settings.js'; import { COMMAND_ALIASES } from '../lib/command-aliases.js'; +import { MIGRATIONS_DESCRIPTION } from '../lib/constants.js'; export interface OptionSchema { name: string; @@ -283,6 +284,31 @@ const commands: CommandSchema[] = [ }, ], }, + { + name: 'verify-login', + description: + 'Verify the AuthKit login loop end-to-end against the active environment (creates and deletes a throwaway user)', + options: [ + insecureStorageOpt, + apiKeyOpt, + { + name: 'client-id', + type: 'string', + description: 'WorkOS client ID (overrides the active environment)', + required: false, + hidden: false, + }, + { + name: 'method', + type: 'string', + description: 'Authentication method to verify', + required: false, + default: 'password', + choices: ['password'], + hidden: false, + }, + ], + }, { name: 'env', description: 'Manage environment configurations (API keys, endpoints, active environment)', @@ -313,7 +339,8 @@ const commands: CommandSchema[] = [ }, { name: 'remove', - description: 'Remove an environment configuration', + description: + 'Remove an environment from local CLI config (does not delete or unclaim the environment in WorkOS)', positionals: [{ name: 'name', type: 'string', description: 'Environment name to remove', required: true }], }, { @@ -327,7 +354,7 @@ const commands: CommandSchema[] = [ }, { name: 'claim', - description: 'Claim an unclaimed WorkOS environment (link it to your account)', + description: 'Claim an unclaimed WorkOS environment — link it to your account (permanent — cannot be undone)', options: [ { name: 'json', @@ -339,6 +366,20 @@ const commands: CommandSchema[] = [ }, ], }, + { + name: 'provision', + description: 'Provision a new unclaimed WorkOS environment (credentials only, no code changes)', + options: [ + { + name: 'json', + type: 'boolean', + description: 'Output provisioned credentials (apiKey, clientId, claimToken) as JSON', + required: false, + default: false, + hidden: false, + }, + ], + }, ], }, { @@ -1406,16 +1447,34 @@ const commands: CommandSchema[] = [ default: true, hidden: false, }, + { + name: 'router', + type: 'string', + description: 'Next.js router to target when detection is ambiguous (app or pages)', + required: false, + hidden: false, + }, ], }, { name: 'migrations', - description: 'Migrate users from identity providers (Auth0, Cognito, Clerk, Firebase) to WorkOS', + description: MIGRATIONS_DESCRIPTION, options: [insecureStorageOpt, apiKeyOpt], commands: [ + { + name: 'export', + description: + 'Export identity data from a source provider (or any provider via CSV, e.g. Supabase) into a WorkOS migration package', + }, + { name: 'export-template', description: 'Export a blank CSV template with headers and example rows' }, { name: 'import', description: 'Import users from CSV into WorkOS' }, { name: 'import-package', description: 'Import a migration package directory' }, + { + name: 'generate-package-template', + description: 'Generate an empty migration package skeleton for manual or scripted population', + }, { name: 'validate', description: 'Validate a WorkOS migration CSV file' }, + { name: 'validate-package', description: 'Validate a migration package directory against the schema contract' }, { name: 'export-auth0', description: 'Export users from Auth0' }, { name: 'export-cognito', description: 'Export users from AWS Cognito' }, { name: 'merge-passwords', description: 'Merge Auth0 password exports into CSV' }, @@ -1423,7 +1482,7 @@ const commands: CommandSchema[] = [ { name: 'transform-firebase', description: 'Transform Firebase JSON to WorkOS format' }, { name: 'analyze', description: 'Analyze import errors and generate retry plan' }, { name: 'enroll-totp', description: 'Enroll TOTP MFA factors' }, - { name: 'process-role-definitions', description: 'Create roles and assign in WorkOS' }, + { name: 'process-roles', description: 'Create roles and assign permissions in WorkOS' }, { name: 'wizard', description: 'Guided interactive migration wizard' }, ], }, diff --git a/src/utils/summary-box.spec.ts b/src/utils/summary-box.spec.ts index 8cc28f83..36d45e89 100644 --- a/src/utils/summary-box.spec.ts +++ b/src/utils/summary-box.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { renderSummaryBox, type SummaryBoxItem } from './summary-box.js'; +import { renderSummaryBox, renderCompletionSummary, type SummaryBoxItem } from './summary-box.js'; +import type { CompletionData } from '../lib/events.js'; // Simple ANSI code stripper (avoids needing strip-ansi as a dependency) function strip(str: string): string { @@ -129,4 +130,54 @@ describe('summary-box', () => { } }); }); + + describe('renderCompletionSummary', () => { + function makeCompletion(overrides: Partial = {}): CompletionData { + return { + integration: 'nextjs', + devCommand: 'pnpm run dev', + url: 'http://localhost:8080', + files: ['app/auth/route.ts', '.env.local'], + nextSteps: ['Run `pnpm run dev` to start your dev server', 'Open http://localhost:8080 to test authentication'], + docsUrl: 'https://workos.com/docs/user-management/authkit/nextjs', + dashboardUrl: 'https://dashboard.workos.com', + ...overrides, + }; + } + + it('renders structured success data (dev command, files, url)', () => { + const result = strip(renderCompletionSummary(true, 'ignored on success', makeCompletion())); + + expect(result).toContain('WorkOS AuthKit Installed'); + expect(result).toContain('pnpm run dev'); + expect(result).toContain('app/auth/route.ts'); + expect(result).toContain('http://localhost:8080'); + // The old static strings must NOT appear when structured data is present. + expect(result).not.toContain('Start dev server to test authentication'); + expect(result).not.toContain('Visit WorkOS Dashboard to manage users'); + }); + + it('caps the file list at 5 with a "…and N more" line', () => { + const files = Array.from({ length: 7 }, (_, i) => `file-${i + 1}.ts`); + const result = strip(renderCompletionSummary(true, undefined, makeCompletion({ files }))); + + expect(result).toContain('…and 2 more'); + expect(result).not.toContain('file-6.ts'); + expect(result).not.toContain('file-7.ts'); + }); + + it('falls back to the static box when no completion data is present', () => { + const result = strip(renderCompletionSummary(true)); + + expect(result).toContain('WorkOS AuthKit Installed'); + expect(result).toContain('Start dev server to test authentication'); + }); + + it('renders the failure box unchanged', () => { + const result = strip(renderCompletionSummary(false, 'Something went wrong')); + + expect(result).toContain('Installation Failed'); + expect(result).toContain('Something went wrong'); + }); + }); }); diff --git a/src/utils/summary-box.ts b/src/utils/summary-box.ts index dc50b3a8..88f3b932 100644 --- a/src/utils/summary-box.ts +++ b/src/utils/summary-box.ts @@ -2,10 +2,29 @@ import chalk from 'chalk'; import { isUnicodeSupported } from './vendor/is-unicorn-supported.js'; import { type LockExpression, getLockArt, LOCK_WIDTH } from './lock-art.js'; import { symbols } from './cli-symbols.js'; +import type { CompletionData } from '../lib/events.js'; + +/** Max number of changed files listed in the success box before collapsing. */ +const MAX_SUMMARY_FILES = 5; /** Pre-built completion summaries shared by CLI and Dashboard adapters. */ -export function renderCompletionSummary(success: boolean, summary?: string): string { +export function renderCompletionSummary(success: boolean, summary?: string, completion?: CompletionData): string { if (success) { + if (completion) { + const files = completion.files; + const shown: SummaryBoxItem[] = files.slice(0, MAX_SUMMARY_FILES).map((f) => ({ type: 'done', text: f })); + if (files.length > MAX_SUMMARY_FILES) { + shown.push({ type: 'done', text: `…and ${files.length - MAX_SUMMARY_FILES} more` }); + } + const steps: SummaryBoxItem[] = completion.nextSteps.map((s) => ({ type: 'pending', text: s })); + return renderSummaryBox({ + expression: 'success', + title: 'WorkOS AuthKit Installed', + items: [...shown, ...steps], + footer: completion.docsUrl, + }); + } + // Fallback: preserve the original static box when no structured data is present. return renderSummaryBox({ expression: 'success', title: 'WorkOS AuthKit Installed', diff --git a/src/utils/types.ts b/src/utils/types.ts index 4f65d2a9..6ed7f00c 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1,3 +1,5 @@ +import type { CredentialSource } from '../lib/installer-core.types.js'; + export type InstallerOptions = { /** * Whether to enable debug mode. @@ -44,6 +46,14 @@ export type InstallerOptions = { */ clientId?: string; + /** + * How the WorkOS credentials were resolved by the state machine + * (`cli`|`env`|`stored`|`device`|`manual`). Threaded downstream so + * user-facing copy can be source-accurate (e.g. avoid claiming the user + * "provided" credentials on the auto-provisioned path). + */ + credentialSource?: CredentialSource; + /** * App homepage URL for WorkOS dashboard config. * Defaults to http://localhost:{detected_port} @@ -120,6 +130,9 @@ export type InstallerOptions = { * Overrides detection from npm_config_user_agent. */ pm?: string; + + /** Next.js router to target when detection is ambiguous (from --router). */ + router?: 'app' | 'pages'; }; export interface Feature {