diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8c79e9..da23dee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: run: pnpm lint - name: Format - run: pnpm format + run: pnpm format:check - name: Typecheck run: pnpm typecheck diff --git a/package.json b/package.json index 3286362..5636200 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,8 @@ "build": "pnpm tsc", "postbuild": "chmod +x ./dist/bin.js && cp -r scripts/** dist", "lint": "oxlint", - "format": "oxfmt --check .", + "format": "oxfmt .", + "format:check": "oxfmt --check .", "try": "tsx dev.ts", "dev": "pnpm build && pnpm link --global && pnpm build:watch", "test": "vitest run", diff --git a/src/bin.ts b/src/bin.ts index dc633f6..f2fa849 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -28,8 +28,22 @@ if (!satisfies(process.version, NODE_VERSION_RANGE)) { } import { isNonInteractiveEnvironment } from './utils/environment.js'; +import { resolveOutputMode, setOutputMode, outputJson, exitWithError } from './utils/output.js'; import clack from './utils/clack.js'; +// Resolve output mode early from raw argv (before yargs parses) +const rawArgs = hideBin(process.argv); +const hasJsonFlag = rawArgs.includes('--json'); +setOutputMode(resolveOutputMode(hasJsonFlag)); + +// Intercept --help --json before yargs parses (yargs exits on --help) +if (hasJsonFlag && (rawArgs.includes('--help') || rawArgs.includes('-h'))) { + const { buildCommandTree } = await import('./utils/help-json.js'); + const command = rawArgs.find((a) => !a.startsWith('-')); + outputJson(buildCommandTree(command)); + process.exit(0); +} + /** Apply insecure storage flag if set */ async function applyInsecureStorage(insecureStorage?: boolean): Promise { if (insecureStorage) { @@ -94,11 +108,11 @@ const installerOptions = { }, 'api-key': { type: 'string' as const, - hidden: true, + describe: 'WorkOS API key (required in non-interactive mode)', }, 'client-id': { type: 'string' as const, - hidden: true, + describe: 'WorkOS client ID (required in non-interactive mode)', }, inspect: { default: false, @@ -114,9 +128,9 @@ const installerOptions = { describe: 'Redirect URI for WorkOS callback (defaults to framework convention)', type: 'string' as const, }, - 'no-validate': { - default: false, - describe: 'Skip post-installation validation (includes build check)', + validate: { + default: true, + describe: 'Run post-installation validation (use --no-validate to skip)', type: 'boolean' as const, }, 'install-dir': { @@ -138,27 +152,53 @@ const installerOptions = { describe: 'Run with visual dashboard mode', type: 'boolean' as const, }, + branch: { + default: true, + describe: 'Create a new branch for changes (use --no-branch to skip)', + type: 'boolean' as const, + }, + commit: { + default: true, + describe: 'Auto-commit after installation (use --no-commit to skip)', + type: 'boolean' as const, + }, + 'create-pr': { + default: false, + describe: 'Auto-create pull request after installation', + type: 'boolean' as const, + }, + 'git-check': { + default: true, + describe: 'Check for dirty working tree (use --no-git-check to skip)', + type: 'boolean' as const, + }, }; // Check for updates (blocks up to 500ms) await checkForUpdates(); -yargs(hideBin(process.argv)) +yargs(rawArgs) .env('WORKOS_INSTALLER') - .command('login', 'Authenticate with WorkOS', insecureStorageOption, async (argv) => { + .option('json', { + type: 'boolean', + default: false, + describe: 'Output results as JSON (auto-enabled in non-TTY)', + global: true, + }) + .command('login', 'Authenticate with WorkOS via browser-based OAuth', insecureStorageOption, async (argv) => { await applyInsecureStorage(argv.insecureStorage); const { runLogin } = await import('./commands/login.js'); await runLogin(); process.exit(0); }) - .command('logout', 'Remove stored credentials', insecureStorageOption, async (argv) => { + .command('logout', 'Remove stored WorkOS credentials and tokens', insecureStorageOption, async (argv) => { await applyInsecureStorage(argv.insecureStorage); const { runLogout } = await import('./commands/logout.js'); await runLogout(); }) .command( 'install-skill', - 'Install bundled AuthKit skills to coding agents', + 'Install bundled AuthKit skills to coding agents (Claude Code, Codex, Cursor, Goose)', (yargs) => { return yargs .option('list', { @@ -190,7 +230,7 @@ yargs(hideBin(process.argv)) ) .command( 'doctor', - 'Diagnose WorkOS integration issues', + 'Diagnose WorkOS AuthKit integration issues in the current project', (yargs) => yargs.options({ verbose: { @@ -229,7 +269,8 @@ yargs(hideBin(process.argv)) await handleDoctor(argv); }, ) - .command('env', 'Manage environment configurations', (yargs) => + // NOTE: When adding commands here, also update src/utils/help-json.ts + .command('env', 'Manage environment configurations (API keys, endpoints, active environment)', (yargs) => yargs .options(insecureStorageOption) .command( @@ -267,6 +308,12 @@ yargs(hideBin(process.argv)) 'Switch active environment', (yargs) => yargs.positional('name', { type: 'string', describe: 'Environment name' }), async (argv) => { + if (!argv.name && isNonInteractiveEnvironment()) { + exitWithError({ + code: 'missing_args', + message: 'Environment name required. Usage: workos env switch ', + }); + } await applyInsecureStorage(argv.insecureStorage); const { runEnvSwitch } = await import('./commands/env.js'); await runEnvSwitch(argv.name); @@ -285,19 +332,26 @@ yargs(hideBin(process.argv)) .demandCommand(1, 'Please specify an env subcommand') .strict(), ) - .command('organization', 'Manage organizations', (yargs) => + .command(['organization', 'org'], 'Manage WorkOS organizations (create, update, get, list, delete)', (yargs) => yargs .options({ ...insecureStorageOption, - 'api-key': { type: 'string' as const, describe: 'WorkOS API key (overrides environment config)' }, + 'api-key': { + type: 'string' as const, + describe: 'WorkOS API key (overrides environment config). Format: sk_live_* or sk_test_*', + }, }) .command( 'create [domains..]', - 'Create a new organization', + 'Create a new organization with optional verified domains', (yargs) => yargs .positional('name', { type: 'string', demandOption: true, describe: 'Organization name' }) - .positional('domains', { type: 'string', array: true, describe: 'Domains as domain:state' }), + .positional('domains', { + type: 'string', + array: true, + describe: 'Domains in format domain:state (state defaults to verified)', + }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); @@ -373,11 +427,14 @@ yargs(hideBin(process.argv)) .demandCommand(1, 'Please specify an organization subcommand') .strict(), ) - .command('user', 'Manage users', (yargs) => + .command('user', 'Manage WorkOS users (get, list, update, delete)', (yargs) => yargs .options({ ...insecureStorageOption, - 'api-key': { type: 'string' as const, describe: 'WorkOS API key (overrides environment config)' }, + 'api-key': { + type: 'string' as const, + describe: 'WorkOS API key (overrides environment config). Format: sk_live_* or sk_test_*', + }, }) .command( 'get ', @@ -465,7 +522,7 @@ yargs(hideBin(process.argv)) ) .command( 'install', - 'Install WorkOS AuthKit into your project', + 'Install WorkOS AuthKit into your project (interactive framework detection and setup)', (yargs) => yargs.options(installerOptions), withAuth(async (argv) => { const { handleInstall } = await import('./commands/install.js'); @@ -488,7 +545,7 @@ yargs(hideBin(process.argv)) async (argv) => { // Non-TTY: show help if (isNonInteractiveEnvironment()) { - yargs(hideBin(process.argv)).showHelp(); + yargs(rawArgs).showHelp(); return; } diff --git a/src/commands/env.spec.ts b/src/commands/env.spec.ts index b64f138..fcb8f31 100644 --- a/src/commands/env.spec.ts +++ b/src/commands/env.spec.ts @@ -40,6 +40,7 @@ vi.mock('node:os', async (importOriginal) => { const { getConfig, setInsecureConfigStorage, clearConfig } = await import('../lib/config-store.js'); const { runEnvAdd, runEnvRemove, runEnvSwitch, runEnvList } = await import('./env.js'); +const { setOutputMode } = await import('../utils/output.js'); const clack = (await import('../utils/clack.js')).default; // Spy on process.exit @@ -160,4 +161,70 @@ describe('env commands', () => { await expect(runEnvList()).resolves.not.toThrow(); }); }); + + describe('JSON output mode', () => { + let consoleOutput: string[]; + + beforeEach(() => { + setOutputMode('json'); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + setOutputMode('human'); + }); + + it('runEnvAdd outputs JSON success', async () => { + await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' }); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Environment added'); + expect(output.name).toBe('prod'); + expect(output.type).toBe('production'); + expect(output.active).toBe(true); + }); + + it('runEnvRemove outputs JSON success', async () => { + await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' }); + consoleOutput = []; + await runEnvRemove('prod'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Environment removed'); + expect(output.name).toBe('prod'); + }); + + it('runEnvSwitch outputs JSON success', async () => { + await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' }); + await runEnvAdd({ name: 'sandbox', apiKey: 'sk_test_abc' }); + consoleOutput = []; + await runEnvSwitch('sandbox'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Switched environment'); + expect(output.name).toBe('sandbox'); + }); + + it('runEnvList outputs JSON with data array', async () => { + await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' }); + await runEnvAdd({ name: 'sandbox', apiKey: 'sk_test_abc' }); + consoleOutput = []; + await runEnvList(); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toHaveLength(2); + expect(output.data[0].name).toBe('prod'); + expect(output.data[0].active).toBe(true); + expect(output.data[1].name).toBe('sandbox'); + expect(output.data[1].active).toBe(false); + }); + + it('runEnvList outputs empty data array when no environments', async () => { + await runEnvList(); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toEqual([]); + }); + }); }); diff --git a/src/commands/env.ts b/src/commands/env.ts index 85e6d68..a7ea781 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -2,6 +2,8 @@ import chalk from 'chalk'; import clack from '../utils/clack.js'; import { getConfig, saveConfig } from '../lib/config-store.js'; import type { CliConfig } from '../lib/config-store.js'; +import { outputSuccess, outputJson, exitWithError, isJsonMode } from '../utils/output.js'; +import { isNonInteractiveEnvironment } from '../utils/environment.js'; const ENV_NAME_REGEX = /^[a-z0-9\-_]+$/; @@ -29,9 +31,10 @@ export async function runEnvAdd(options: { // Non-interactive mode const nameError = validateEnvName(name); if (nameError) { - clack.log.error(nameError); - process.exit(1); + exitWithError({ code: 'invalid_args', message: nameError }); } + } else if (isNonInteractiveEnvironment()) { + exitWithError({ code: 'missing_args', message: 'Name and API key required in non-interactive mode' }); } else { // Interactive mode const nameResult = await clack.text({ @@ -71,7 +74,6 @@ export async function runEnvAdd(options: { ...(endpoint && { endpoint }), }; - // Auto-set active environment if it's the first one if (isFirst) { config.activeEnvironment = name; } @@ -88,7 +90,6 @@ export async function runEnvAdd(options: { const config = getOrCreateConfig(); const isFirst = Object.keys(config.environments).length === 0; - // Detect type from API key prefix const type: 'production' | 'sandbox' = apiKey.startsWith('sk_test_') ? 'sandbox' : 'production'; config.environments[name!] = { @@ -104,55 +105,53 @@ export async function runEnvAdd(options: { } saveConfig(config); - clack.log.success(`Environment ${chalk.bold(name)} added`); - if (isFirst) { - clack.log.info(`Set as active environment`); - } + outputSuccess('Environment added', { name: name!, type, active: isFirst }); } export async function runEnvRemove(name: string): Promise { const config = getConfig(); if (!config || Object.keys(config.environments).length === 0) { - clack.log.error('No environments configured. Run `workos env add` to get started.'); - process.exit(1); + exitWithError({ + code: 'no_environments', + message: 'No environments configured. Run `workos env add` to get started.', + }); } if (!config.environments[name]) { const available = Object.keys(config.environments).join(', '); - clack.log.error(`Environment "${name}" not found. Available: ${available}`); - process.exit(1); + exitWithError({ code: 'not_found', message: `Environment "${name}" not found. Available: ${available}` }); } delete config.environments[name]; - // Clear active environment if it was the removed one if (config.activeEnvironment === name) { const remaining = Object.keys(config.environments); config.activeEnvironment = remaining.length > 0 ? remaining[0] : undefined; - if (config.activeEnvironment) { + if (config.activeEnvironment && !isJsonMode()) { clack.log.info(`Active environment switched to ${chalk.bold(config.activeEnvironment)}`); } } saveConfig(config); - clack.log.success(`Environment ${chalk.bold(name)} removed`); + outputSuccess('Environment removed', { name, newActive: config.activeEnvironment ?? null }); } export async function runEnvSwitch(name?: string): Promise { const config = getConfig(); if (!config || Object.keys(config.environments).length === 0) { - clack.log.error('No environments configured. Run `workos env add` to get started.'); - process.exit(1); + exitWithError({ + code: 'no_environments', + message: 'No environments configured. Run `workos env add` to get started.', + }); } if (name) { if (!config.environments[name]) { const available = Object.keys(config.environments).join(', '); - clack.log.error(`Environment "${name}" not found. Available: ${available}`); - process.exit(1); + exitWithError({ code: 'not_found', message: `Environment "${name}" not found. Available: ${available}` }); } } else { - // Interactive selection + // Interactive selection (TTY only — non-TTY guard is in bin.ts) const options = Object.entries(config.environments).map(([key, env]) => { let label = key; if (env.type === 'sandbox') label += ` [Sandbox]`; @@ -173,21 +172,36 @@ export async function runEnvSwitch(name?: string): Promise { saveConfig(config); const env = config.environments[name]; - let label = chalk.bold(name); - if (env.type === 'sandbox') label += ` [Sandbox]`; - if (env.endpoint) label += ` [${env.endpoint}]`; - clack.log.success(`Switched to environment ${label}`); + outputSuccess('Switched environment', { name, type: env.type }); } export async function runEnvList(): Promise { const config = getConfig(); if (!config || Object.keys(config.environments).length === 0) { - clack.log.info('No environments configured. Run `workos env add` to get started.'); + if (isJsonMode()) { + outputJson({ data: [] }); + } else { + clack.log.info('No environments configured. Run `workos env add` to get started.'); + } return; } const entries = Object.entries(config.environments); + if (isJsonMode()) { + const data = entries.map(([key, env]) => ({ + name: key, + type: env.type, + active: key === config.activeEnvironment, + endpoint: env.endpoint ?? null, + hasApiKey: !!env.apiKey, + hasClientId: !!env.clientId, + })); + outputJson({ data }); + return; + } + + // Human-mode table const nameW = Math.max(6, ...entries.map(([k]) => k.length)) + 2; const typeW = 12; diff --git a/src/commands/install.ts b/src/commands/install.ts index 4cd15b2..348dd8d 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -1,6 +1,5 @@ import { runInstaller } from '../run.js'; import type { InstallerArgs } from '../run.js'; -import { isNonInteractiveEnvironment } from '../utils/environment.js'; import clack from '../utils/clack.js'; import chalk from 'chalk'; import type { ArgumentsCamelCase } from 'yargs'; @@ -28,16 +27,6 @@ export async function handleInstall(argv: ArgumentsCamelCase): Pr clack.log.error('CI mode requires --install-dir (directory to install WorkOS AuthKit in)'); process.exit(1); } - } else if (isNonInteractiveEnvironment()) { - clack.intro(chalk.inverse('WorkOS AuthKit Installer')); - clack.log.error( - 'This installer requires an interactive terminal (TTY) to run.\n' + - 'It appears you are running in a non-interactive environment.\n' + - 'Please run the installer in an interactive terminal.\n\n' + - 'For CI/CD environments, use --ci mode:\n' + - ' workos install --ci --api-key sk_xxx --client-id client_xxx', - ); - process.exit(1); } try { diff --git a/src/commands/organization.spec.ts b/src/commands/organization.spec.ts index 858b9e0..803518d 100644 --- a/src/commands/organization.spec.ts +++ b/src/commands/organization.spec.ts @@ -18,6 +18,7 @@ vi.mock('../lib/workos-api.js', () => ({ const { workosRequest } = await import('../lib/workos-api.js'); const mockRequest = vi.mocked(workosRequest); +const { setOutputMode } = await import('../utils/output.js'); const { runOrgCreate, runOrgUpdate, runOrgGet, runOrgList, runOrgDelete, parseDomainArgs } = await import('./organization.js'); @@ -181,7 +182,64 @@ describe('organization commands', () => { expect(mockRequest).toHaveBeenCalledWith( expect.objectContaining({ method: 'DELETE', path: '/organizations/org_123' }), ); - expect(consoleOutput.some((l) => l.includes('Deleted') && l.includes('org_123'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('org_123'))).toBe(true); + }); + }); + + describe('JSON output mode', () => { + beforeEach(() => { + setOutputMode('json'); + }); + + afterEach(() => { + setOutputMode('human'); + }); + + it('runOrgCreate outputs JSON success', async () => { + mockRequest.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); + await runOrgCreate('Test', [], 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Created organization'); + expect(output.id).toBe('org_123'); + }); + + it('runOrgGet outputs raw JSON', async () => { + mockRequest.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); + await runOrgGet('org_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.id).toBe('org_123'); + expect(output.name).toBe('Test'); + expect(output).not.toHaveProperty('status'); + }); + + it('runOrgList outputs JSON with data and list_metadata', async () => { + mockRequest.mockResolvedValue({ + data: [{ id: 'org_123', name: 'FooCorp', domains: [] }], + list_metadata: { before: null, after: 'cursor_a' }, + }); + await runOrgList({}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toHaveLength(1); + expect(output.data[0].id).toBe('org_123'); + expect(output.list_metadata.after).toBe('cursor_a'); + }); + + it('runOrgList outputs empty data array for no results', async () => { + mockRequest.mockResolvedValue({ data: [], list_metadata: { before: null, after: null } }); + await runOrgList({}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toEqual([]); + expect(output.list_metadata).toBeDefined(); + }); + + it('runOrgDelete outputs JSON success', async () => { + mockRequest.mockResolvedValue(null); + await runOrgDelete('org_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.id).toBe('org_123'); }); }); }); diff --git a/src/commands/organization.ts b/src/commands/organization.ts index 2648e3c..672f7ed 100644 --- a/src/commands/organization.ts +++ b/src/commands/organization.ts @@ -1,7 +1,9 @@ import chalk from 'chalk'; -import { workosRequest, WorkOSApiError } from '../lib/workos-api.js'; +import { workosRequest } from '../lib/workos-api.js'; import type { WorkOSListResponse } from '../lib/workos-api.js'; import { formatTable } from '../utils/table.js'; +import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; interface OrganizationDomain { id: string; @@ -32,22 +34,7 @@ export function parseDomainArgs(args: string[]): DomainData[] { }); } -function handleApiError(error: unknown): never { - if (error instanceof WorkOSApiError) { - if (error.statusCode === 401) { - console.error(chalk.red('Invalid API key. Check your environment configuration.')); - } else if (error.statusCode === 404) { - console.error(chalk.red(`Organization not found.`)); - } else if (error.statusCode === 422 && error.errors?.length) { - console.error(chalk.red(error.errors.map((e) => e.message).join(', '))); - } else { - console.error(chalk.red(error.message)); - } - } else { - console.error(chalk.red(error instanceof Error ? error.message : 'Unknown error')); - } - process.exit(1); -} +const handleApiError = createApiErrorHandler('Organization'); export async function runOrgCreate( name: string, @@ -69,8 +56,7 @@ export async function runOrgCreate( baseUrl, body, }); - console.log(chalk.green('Created organization')); - console.log(JSON.stringify(org, null, 2)); + outputSuccess('Created organization', org); } catch (error) { handleApiError(error); } @@ -97,8 +83,7 @@ export async function runOrgUpdate( baseUrl, body, }); - console.log(chalk.green('Updated organization')); - console.log(JSON.stringify(org, null, 2)); + outputSuccess('Updated organization', org); } catch (error) { handleApiError(error); } @@ -112,7 +97,7 @@ export async function runOrgGet(orgId: string, apiKey: string, baseUrl?: string) apiKey, baseUrl, }); - console.log(JSON.stringify(org, null, 2)); + outputJson(org); } catch (error) { handleApiError(error); } @@ -142,6 +127,11 @@ export async function runOrgList(options: OrgListOptions, apiKey: string, baseUr }, }); + if (isJsonMode()) { + outputJson({ data: result.data, list_metadata: result.list_metadata }); + return; + } + if (result.data.length === 0) { console.log('No organizations found.'); return; @@ -176,7 +166,7 @@ export async function runOrgDelete(orgId: string, apiKey: string, baseUrl?: stri apiKey, baseUrl, }); - console.log(chalk.green(`Deleted organization ${orgId}`)); + outputSuccess('Deleted organization', { id: orgId }); } catch (error) { handleApiError(error); } diff --git a/src/commands/user.spec.ts b/src/commands/user.spec.ts index 24fecb4..4df065d 100644 --- a/src/commands/user.spec.ts +++ b/src/commands/user.spec.ts @@ -17,6 +17,7 @@ vi.mock('../lib/workos-api.js', () => ({ const { workosRequest } = await import('../lib/workos-api.js'); const mockRequest = vi.mocked(workosRequest); +const { setOutputMode } = await import('../utils/output.js'); const { runUserGet, runUserList, runUserUpdate, runUserDelete } = await import('./user.js'); @@ -111,7 +112,66 @@ describe('user commands', () => { expect(mockRequest).toHaveBeenCalledWith( expect.objectContaining({ method: 'DELETE', path: '/user_management/users/user_123' }), ); - expect(consoleOutput.some((l) => l.includes('Deleted') && l.includes('user_123'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('user_123'))).toBe(true); + }); + }); + + describe('JSON output mode', () => { + beforeEach(() => { + setOutputMode('json'); + }); + + afterEach(() => { + setOutputMode('human'); + }); + + it('runUserGet outputs raw JSON', async () => { + mockRequest.mockResolvedValue({ id: 'user_123', email: 'test@example.com' }); + await runUserGet('user_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.id).toBe('user_123'); + expect(output.email).toBe('test@example.com'); + expect(output).not.toHaveProperty('status'); + }); + + it('runUserList outputs JSON with data and list_metadata', async () => { + mockRequest.mockResolvedValue({ + data: [ + { id: 'user_123', email: 'test@example.com', first_name: 'Test', last_name: 'User', email_verified: true }, + ], + list_metadata: { before: null, after: 'cursor_a' }, + }); + await runUserList({}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toHaveLength(1); + expect(output.data[0].email).toBe('test@example.com'); + expect(output.list_metadata.after).toBe('cursor_a'); + }); + + it('runUserList outputs empty data array for no results', async () => { + mockRequest.mockResolvedValue({ data: [], list_metadata: { before: null, after: null } }); + await runUserList({}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toEqual([]); + expect(output.list_metadata).toBeDefined(); + }); + + it('runUserUpdate outputs JSON success', async () => { + mockRequest.mockResolvedValue({ id: 'user_123', email: 'test@example.com' }); + await runUserUpdate('user_123', 'sk_test', { firstName: 'John' }); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Updated user'); + expect(output.id).toBe('user_123'); + }); + + it('runUserDelete outputs JSON success', async () => { + mockRequest.mockResolvedValue(null); + await runUserDelete('user_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.id).toBe('user_123'); }); }); }); diff --git a/src/commands/user.ts b/src/commands/user.ts index c88c8dc..262891c 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -1,7 +1,9 @@ import chalk from 'chalk'; -import { workosRequest, WorkOSApiError } from '../lib/workos-api.js'; +import { workosRequest } from '../lib/workos-api.js'; import type { WorkOSListResponse } from '../lib/workos-api.js'; import { formatTable } from '../utils/table.js'; +import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; interface User { id: string; @@ -13,22 +15,7 @@ interface User { updated_at: string; } -function handleApiError(error: unknown): never { - if (error instanceof WorkOSApiError) { - if (error.statusCode === 401) { - console.error(chalk.red('Invalid API key. Check your environment configuration.')); - } else if (error.statusCode === 404) { - console.error(chalk.red('User not found.')); - } else if (error.statusCode === 422 && error.errors?.length) { - console.error(chalk.red(error.errors.map((e) => e.message).join(', '))); - } else { - console.error(chalk.red(error.message)); - } - } else { - console.error(chalk.red(error instanceof Error ? error.message : 'Unknown error')); - } - process.exit(1); -} +const handleApiError = createApiErrorHandler('User'); export async function runUserGet(userId: string, apiKey: string, baseUrl?: string): Promise { try { @@ -38,7 +25,7 @@ export async function runUserGet(userId: string, apiKey: string, baseUrl?: strin apiKey, baseUrl, }); - console.log(JSON.stringify(user, null, 2)); + outputJson(user); } catch (error) { handleApiError(error); } @@ -70,6 +57,11 @@ export async function runUserList(options: UserListOptions, apiKey: string, base }, }); + if (isJsonMode()) { + outputJson({ data: result.data, list_metadata: result.list_metadata }); + return; + } + if (result.data.length === 0) { console.log('No users found.'); return; @@ -138,8 +130,7 @@ export async function runUserUpdate( baseUrl, body, }); - console.log(chalk.green('Updated user')); - console.log(JSON.stringify(user, null, 2)); + outputSuccess('Updated user', user); } catch (error) { handleApiError(error); } @@ -153,7 +144,7 @@ export async function runUserDelete(userId: string, apiKey: string, baseUrl?: st apiKey, baseUrl, }); - console.log(chalk.green(`Deleted user ${userId}`)); + outputSuccess('Deleted user', { id: userId }); } catch (error) { handleApiError(error); } diff --git a/src/lib/adapters/headless-adapter.spec.ts b/src/lib/adapters/headless-adapter.spec.ts new file mode 100644 index 0000000..7ff83d1 --- /dev/null +++ b/src/lib/adapters/headless-adapter.spec.ts @@ -0,0 +1,312 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { HeadlessAdapter } from './headless-adapter.js'; +import { createInstallerEventEmitter } from '../events.js'; +import type { InstallerEventEmitter } from '../events.js'; +import type { HeadlessOptions } from './headless-adapter.js'; + +// Mock ndjson writer to capture events +const mockWriteNDJSON = vi.fn(); +vi.mock('../../utils/ndjson.js', () => ({ + writeNDJSON: (...args: unknown[]) => mockWriteNDJSON(...args), +})); + +// Mock process.exit +const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + +describe('HeadlessAdapter', () => { + let emitter: InstallerEventEmitter; + let sendEvent: ReturnType; + + function createAdapter(options: HeadlessOptions = {}) { + return new HeadlessAdapter({ emitter, sendEvent, options }); + } + + beforeEach(() => { + emitter = createInstallerEventEmitter(); + sendEvent = vi.fn(); + mockWriteNDJSON.mockClear(); + mockExit.mockClear(); + }); + + afterEach(async () => { + vi.clearAllMocks(); + }); + + describe('start/stop', () => { + it('is idempotent on start', async () => { + const adapter = createAdapter(); + await adapter.start(); + await adapter.start(); // no-op + + emitter.emit('auth:success', {}); + expect(mockWriteNDJSON).toHaveBeenCalledTimes(1); + await adapter.stop(); + }); + + it('is idempotent on stop', async () => { + const adapter = createAdapter(); + await adapter.start(); + await adapter.stop(); + await adapter.stop(); // no-op — should not throw + }); + + it('unsubscribes from events on stop', async () => { + const adapter = createAdapter(); + await adapter.start(); + await adapter.stop(); + + mockWriteNDJSON.mockClear(); + emitter.emit('auth:success', {}); + expect(mockWriteNDJSON).not.toHaveBeenCalled(); + }); + }); + + describe('auth events', () => { + it('writes NDJSON on auth:success', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('auth:success', {}); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'auth:success' }); + await adapter.stop(); + }); + + it('exits with code 4 on auth:failure', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('auth:failure', { message: 'Token expired' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ + type: 'auth:required', + message: 'Token expired', + }); + expect(mockExit).toHaveBeenCalledWith(4); + await adapter.stop(); + }); + }); + + describe('detection events', () => { + it('writes detection:complete', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('detection:complete', { integration: 'nextjs' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ + type: 'detection:complete', + integration: 'nextjs', + }); + await adapter.stop(); + }); + + it('writes detection:none', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('detection:none', {}); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'detection:none' }); + await adapter.stop(); + }); + }); + + describe('git:dirty auto-resolution', () => { + it('auto-confirms and continues', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('git:dirty', { files: ['package.json'] }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ + type: 'git:status', + dirty: true, + files: ['package.json'], + }); + expect(mockWriteNDJSON).toHaveBeenCalledWith({ + type: 'git:decision', + action: 'continue', + }); + expect(sendEvent).toHaveBeenCalledWith({ type: 'GIT_CONFIRMED' }); + await adapter.stop(); + }); + }); + + describe('credentials auto-resolution', () => { + it('submits credentials from flags', async () => { + const adapter = createAdapter({ apiKey: 'sk_test_123', clientId: 'client_abc' }); + await adapter.start(); + + emitter.emit('credentials:request', { requiresApiKey: true }); + + expect(sendEvent).toHaveBeenCalledWith({ + type: 'CREDENTIALS_SUBMITTED', + apiKey: 'sk_test_123', + clientId: 'client_abc', + }); + await adapter.stop(); + }); + + it('errors when clientId missing', async () => { + const adapter = createAdapter({ apiKey: 'sk_test_123' }); + await adapter.start(); + + emitter.emit('credentials:request', { requiresApiKey: false }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error', code: 'missing_credentials' }), + ); + expect(mockExit).toHaveBeenCalledWith(1); + await adapter.stop(); + }); + + it('errors when apiKey missing but required', async () => { + const adapter = createAdapter({ clientId: 'client_abc' }); + await adapter.start(); + + emitter.emit('credentials:request', { requiresApiKey: true }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error', code: 'missing_credentials' }), + ); + expect(mockExit).toHaveBeenCalledWith(1); + await adapter.stop(); + }); + + it('submits without apiKey when not required', async () => { + const adapter = createAdapter({ clientId: 'client_abc' }); + await adapter.start(); + + emitter.emit('credentials:request', { requiresApiKey: false }); + + expect(sendEvent).toHaveBeenCalledWith({ + type: 'CREDENTIALS_SUBMITTED', + apiKey: '', + clientId: 'client_abc', + }); + await adapter.stop(); + }); + + it('auto-approves env scan', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('credentials:env:prompt', { files: ['.env.local'] }); + + expect(sendEvent).toHaveBeenCalledWith({ type: 'ENV_SCAN_APPROVED' }); + await adapter.stop(); + }); + }); + + describe('branch auto-resolution', () => { + it('auto-creates branch by default', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('branch:prompt', { branch: 'main' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'branch:creating' }); + expect(sendEvent).toHaveBeenCalledWith({ type: 'BRANCH_CREATE' }); + await adapter.stop(); + }); + + it('skips branch with --no-branch flag', async () => { + const adapter = createAdapter({ noBranch: true }); + await adapter.start(); + + emitter.emit('branch:prompt', { branch: 'main' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ + type: 'branch:skipped', + reason: '--no-branch flag', + }); + expect(sendEvent).toHaveBeenCalledWith({ type: 'BRANCH_CONTINUE' }); + await adapter.stop(); + }); + }); + + describe('commit auto-resolution', () => { + it('auto-commits by default', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('postinstall:commit:prompt', {}); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'commit:auto' }); + expect(sendEvent).toHaveBeenCalledWith({ type: 'COMMIT_APPROVED' }); + await adapter.stop(); + }); + + it('skips commit with --no-commit flag', async () => { + const adapter = createAdapter({ noCommit: true }); + await adapter.start(); + + emitter.emit('postinstall:commit:prompt', {}); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ + type: 'commit:skipped', + reason: '--no-commit flag', + }); + expect(sendEvent).toHaveBeenCalledWith({ type: 'COMMIT_DECLINED' }); + await adapter.stop(); + }); + }); + + describe('PR auto-resolution', () => { + it('skips PR by default', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('postinstall:pr:prompt', {}); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ + type: 'pr:skipped', + reason: '--create-pr not set', + }); + expect(sendEvent).toHaveBeenCalledWith({ type: 'PR_DECLINED' }); + await adapter.stop(); + }); + + it('creates PR with --create-pr flag', async () => { + const adapter = createAdapter({ createPr: true }); + await adapter.start(); + + emitter.emit('postinstall:pr:prompt', {}); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'pr:creating' }); + expect(sendEvent).toHaveBeenCalledWith({ type: 'PR_APPROVED' }); + await adapter.stop(); + }); + }); + + describe('terminal events', () => { + it('writes complete event', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('complete', { success: true, summary: 'All done' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ + type: 'complete', + success: true, + summary: 'All done', + }); + await adapter.stop(); + }); + + it('writes error event', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('error', { message: 'Something broke', stack: 'stack trace' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ + type: 'error', + code: 'installer_error', + message: 'Something broke', + }); + await adapter.stop(); + }); + }); +}); diff --git a/src/lib/adapters/headless-adapter.ts b/src/lib/adapters/headless-adapter.ts new file mode 100644 index 0000000..cea532e --- /dev/null +++ b/src/lib/adapters/headless-adapter.ts @@ -0,0 +1,327 @@ +import type { InstallerAdapter, AdapterConfig } from './types.js'; +import type { InstallerEventEmitter, InstallerEvents } from '../events.js'; +import { writeNDJSON } from '../../utils/ndjson.js'; +import { ExitCode } from '../../utils/exit-codes.js'; + +/** + * Options controlling headless adapter behavior. + * Corresponds to CLI flags passed in non-interactive mode. + */ +export interface HeadlessOptions { + apiKey?: string; + clientId?: string; + noBranch?: boolean; + noCommit?: boolean; + createPr?: boolean; + noGitCheck?: boolean; +} + +/** + * Non-interactive adapter for CI/CD and agent consumption. + * + * Subscribes to the same installer events as CLIAdapter but never prompts. + * All decisions are auto-resolved with sensible defaults (overridable via flags). + * Progress is streamed as NDJSON to stdout. + */ +export class HeadlessAdapter implements InstallerAdapter { + readonly emitter: InstallerEventEmitter; + private sendEvent: AdapterConfig['sendEvent']; + private debug: boolean; + private options: HeadlessOptions; + private isStarted = false; + private handlers = new Map void>(); + + constructor(config: AdapterConfig & { options: HeadlessOptions }) { + this.emitter = config.emitter; + this.sendEvent = config.sendEvent; + this.debug = config.debug ?? false; + this.options = config.options; + } + + async start(): Promise { + if (this.isStarted) return; + this.isStarted = true; + + // Auth events + this.subscribe('auth:success', this.handleAuthSuccess); + this.subscribe('auth:failure', this.handleAuthFailure); + + // Detection events + this.subscribe('detection:complete', this.handleDetectionComplete); + this.subscribe('detection:none', this.handleDetectionNone); + + // Git events — auto-resolve + this.subscribe('git:dirty', this.handleGitDirty); + + // Credential events — auto-resolve + this.subscribe('credentials:found', this.handleCredentialsFound); + this.subscribe('credentials:request', this.handleCredentialsRequest); + this.subscribe('credentials:env:prompt', this.handleEnvScanPrompt); + this.subscribe('credentials:env:found', this.handleEnvCredentialsFound); + + // Device auth (should not happen in headless, but log if it does) + this.subscribe('device:started', this.handleDeviceStarted); + + // Staging + this.subscribe('staging:fetching', this.handleStagingFetching); + this.subscribe('staging:success', this.handleStagingSuccess); + + // Config + this.subscribe('config:complete', this.handleConfigComplete); + + // Agent progress + this.subscribe('agent:start', this.handleAgentStart); + this.subscribe('agent:progress', this.handleAgentProgress); + + // Validation + this.subscribe('validation:start', this.handleValidationStart); + this.subscribe('validation:issues', this.handleValidationIssues); + this.subscribe('validation:complete', this.handleValidationComplete); + + // Branch — auto-resolve + this.subscribe('branch:prompt', this.handleBranchPrompt); + this.subscribe('branch:created', this.handleBranchCreated); + + // Post-install — auto-resolve + this.subscribe('postinstall:changes', this.handlePostInstallChanges); + this.subscribe('postinstall:commit:prompt', this.handleCommitPrompt); + this.subscribe('postinstall:commit:success', this.handleCommitSuccess); + this.subscribe('postinstall:commit:failed', this.handleCommitFailed); + this.subscribe('postinstall:pr:prompt', this.handlePrPrompt); + this.subscribe('postinstall:pr:success', this.handlePrSuccess); + this.subscribe('postinstall:pr:failed', this.handlePrFailed); + this.subscribe('postinstall:push:failed', this.handlePushFailed); + this.subscribe('postinstall:manual', this.handleManualInstructions); + + // Terminal events + this.subscribe('complete', this.handleComplete); + this.subscribe('error', this.handleError); + } + + async stop(): Promise { + if (!this.isStarted) return; + + for (const [event, handler] of this.handlers) { + this.emitter.off(event as keyof InstallerEvents, handler as never); + } + this.handlers.clear(); + this.isStarted = false; + } + + private subscribe( + event: K, + handler: (payload: InstallerEvents[K]) => void | Promise, + ): void { + const boundHandler = handler.bind(this); + this.handlers.set(event, boundHandler as (...args: unknown[]) => void); + this.emitter.on(event, boundHandler); + } + + private debugLog(message: string): void { + if (this.debug) { + writeNDJSON({ type: 'debug', message }); + } + } + + // ===== Auth Handlers ===== + + private handleAuthSuccess = (): void => { + writeNDJSON({ type: 'auth:success' }); + }; + + private handleAuthFailure = ({ message }: InstallerEvents['auth:failure']): void => { + writeNDJSON({ type: 'auth:required', message }); + process.exit(ExitCode.AUTH_REQUIRED); + }; + + // ===== Detection Handlers ===== + + private handleDetectionComplete = ({ integration }: InstallerEvents['detection:complete']): void => { + writeNDJSON({ type: 'detection:complete', integration }); + }; + + private handleDetectionNone = (): void => { + writeNDJSON({ type: 'detection:none' }); + }; + + // ===== Git Handlers (auto-resolve) ===== + + private handleGitDirty = ({ files }: InstallerEvents['git:dirty']): void => { + writeNDJSON({ type: 'git:status', dirty: true, files }); + writeNDJSON({ type: 'git:decision', action: 'continue' }); + this.sendEvent({ type: 'GIT_CONFIRMED' }); + }; + + // ===== Credential Handlers (auto-resolve) ===== + + private handleCredentialsFound = (): void => { + writeNDJSON({ type: 'credentials:found', source: 'env' }); + }; + + private handleCredentialsRequest = ({ requiresApiKey }: InstallerEvents['credentials:request']): void => { + if (!this.options.clientId) { + writeNDJSON({ + type: 'error', + code: 'missing_credentials', + message: 'Client ID required in non-interactive mode. Pass --client-id flag.', + }); + process.exit(ExitCode.GENERAL_ERROR); + } + + if (requiresApiKey && !this.options.apiKey) { + writeNDJSON({ + type: 'error', + code: 'missing_credentials', + message: 'API key required for this framework. Pass --api-key flag.', + }); + process.exit(ExitCode.GENERAL_ERROR); + } + + writeNDJSON({ type: 'credentials:provided', source: 'flag' }); + this.sendEvent({ + type: 'CREDENTIALS_SUBMITTED', + apiKey: this.options.apiKey ?? '', + clientId: this.options.clientId, + }); + }; + + private handleEnvScanPrompt = (): void => { + writeNDJSON({ type: 'credentials:env:scanning' }); + this.sendEvent({ type: 'ENV_SCAN_APPROVED' }); + }; + + private handleEnvCredentialsFound = ({ sourcePath }: InstallerEvents['credentials:env:found']): void => { + writeNDJSON({ type: 'credentials:found', source: 'env', sourcePath }); + }; + + // ===== Device Auth (should not occur in headless) ===== + + private handleDeviceStarted = ({ verificationUri, userCode }: InstallerEvents['device:started']): void => { + writeNDJSON({ + type: 'auth:device_required', + verificationUri, + userCode, + message: 'Device auth cannot proceed in non-interactive mode', + }); + }; + + // ===== Staging ===== + + private handleStagingFetching = (): void => { + writeNDJSON({ type: 'staging:fetching' }); + }; + + private handleStagingSuccess = (): void => { + writeNDJSON({ type: 'staging:success' }); + }; + + // ===== Config ===== + + private handleConfigComplete = (): void => { + writeNDJSON({ type: 'config:complete' }); + }; + + // ===== Agent Progress ===== + + private handleAgentStart = (): void => { + writeNDJSON({ type: 'agent:start' }); + }; + + private handleAgentProgress = ({ step, detail }: InstallerEvents['agent:progress']): void => { + const message = detail ? `${step}: ${detail}` : step; + writeNDJSON({ type: 'agent:progress', message }); + }; + + // ===== Validation ===== + + private handleValidationStart = ({ framework }: InstallerEvents['validation:start']): void => { + writeNDJSON({ type: 'validation:start', framework }); + }; + + private handleValidationIssues = ({ issues }: InstallerEvents['validation:issues']): void => { + for (const issue of issues) { + writeNDJSON({ type: 'validation:issue', severity: issue.severity, message: issue.message }); + } + }; + + private handleValidationComplete = ({ passed, issueCount }: InstallerEvents['validation:complete']): void => { + writeNDJSON({ type: 'validation:complete', passed, issues: issueCount }); + }; + + // ===== Branch (auto-resolve) ===== + + private handleBranchPrompt = (): void => { + if (this.options.noBranch) { + writeNDJSON({ type: 'branch:skipped', reason: '--no-branch flag' }); + this.sendEvent({ type: 'BRANCH_CONTINUE' }); + } else { + writeNDJSON({ type: 'branch:creating' }); + this.sendEvent({ type: 'BRANCH_CREATE' }); + } + }; + + private handleBranchCreated = ({ branch }: InstallerEvents['branch:created']): void => { + writeNDJSON({ type: 'branch:created', name: branch }); + }; + + // ===== Post-install (auto-resolve) ===== + + private handlePostInstallChanges = ({ files }: InstallerEvents['postinstall:changes']): void => { + writeNDJSON({ type: 'postinstall:changes', files, count: files.length }); + }; + + private handleCommitPrompt = (): void => { + if (this.options.noCommit) { + writeNDJSON({ type: 'commit:skipped', reason: '--no-commit flag' }); + this.sendEvent({ type: 'COMMIT_DECLINED' }); + } else { + writeNDJSON({ type: 'commit:auto' }); + this.sendEvent({ type: 'COMMIT_APPROVED' }); + } + }; + + private handleCommitSuccess = ({ message }: InstallerEvents['postinstall:commit:success']): void => { + writeNDJSON({ type: 'commit:created', message }); + }; + + private handleCommitFailed = ({ error }: InstallerEvents['postinstall:commit:failed']): void => { + writeNDJSON({ type: 'commit:failed', error }); + }; + + private handlePrPrompt = (): void => { + if (this.options.createPr) { + writeNDJSON({ type: 'pr:creating' }); + this.sendEvent({ type: 'PR_APPROVED' }); + } else { + writeNDJSON({ type: 'pr:skipped', reason: '--create-pr not set' }); + this.sendEvent({ type: 'PR_DECLINED' }); + } + }; + + private handlePrSuccess = ({ url }: InstallerEvents['postinstall:pr:success']): void => { + writeNDJSON({ type: 'pr:created', url }); + }; + + private handlePrFailed = ({ error }: InstallerEvents['postinstall:pr:failed']): void => { + writeNDJSON({ type: 'pr:failed', error }); + }; + + private handlePushFailed = ({ error }: InstallerEvents['postinstall:push:failed']): void => { + writeNDJSON({ type: 'push:failed', error }); + }; + + private handleManualInstructions = ({ instructions }: InstallerEvents['postinstall:manual']): void => { + writeNDJSON({ type: 'postinstall:manual', instructions }); + }; + + // ===== Terminal Events ===== + + private handleComplete = ({ success, summary }: InstallerEvents['complete']): void => { + writeNDJSON({ type: 'complete', success, summary }); + }; + + private handleError = ({ message, stack }: InstallerEvents['error']): void => { + writeNDJSON({ type: 'error', code: 'installer_error', message }); + this.debugLog(stack ?? ''); + }; +} diff --git a/src/lib/adapters/index.ts b/src/lib/adapters/index.ts index 3b4621a..e88217d 100644 --- a/src/lib/adapters/index.ts +++ b/src/lib/adapters/index.ts @@ -1,3 +1,4 @@ export { CLIAdapter } from './cli-adapter.js'; export { DashboardAdapter } from './dashboard-adapter.js'; +export { HeadlessAdapter } from './headless-adapter.js'; export type { InstallerAdapter, AdapterConfig } from './types.js'; diff --git a/src/lib/api-error-handler.ts b/src/lib/api-error-handler.ts new file mode 100644 index 0000000..bff4c26 --- /dev/null +++ b/src/lib/api-error-handler.ts @@ -0,0 +1,29 @@ +import { WorkOSApiError } from './workos-api.js'; +import { exitWithError } from '../utils/output.js'; + +/** + * Create a resource-specific API error handler. + * Returns a `never` function that writes structured errors and exits. + */ +export function createApiErrorHandler(resourceName: string) { + return (error: unknown): never => { + if (error instanceof WorkOSApiError) { + exitWithError({ + code: error.code ?? `http_${error.statusCode}`, + message: + error.statusCode === 401 + ? 'Invalid API key. Check your environment configuration.' + : error.statusCode === 404 + ? `${resourceName} not found.` + : error.statusCode === 422 && error.errors?.length + ? error.errors.map((e) => e.message).join(', ') + : error.message, + details: error.errors, + }); + } + exitWithError({ + code: 'unknown_error', + message: error instanceof Error ? error.message : 'Unknown error', + }); + }; +} diff --git a/src/lib/api-key.spec.ts b/src/lib/api-key.spec.ts index 71df759..0215a61 100644 --- a/src/lib/api-key.spec.ts +++ b/src/lib/api-key.spec.ts @@ -8,6 +8,21 @@ vi.mock('../utils/debug.js', () => ({ logWarn: vi.fn(), })); +// Mock exitWithError — must throw to halt execution like process.exit +class ExitError extends Error { + code: string; + constructor(error: { code: string; message: string }) { + super(error.message); + this.code = error.code; + } +} +const mockExitWithError = vi.fn((error: { code: string; message: string }) => { + throw new ExitError(error); +}); +vi.mock('../utils/output.js', () => ({ + exitWithError: (...args: unknown[]) => mockExitWithError(...(args as [{ code: string; message: string }])), +})); + let testDir: string; // Mock os.homedir for config-store @@ -73,13 +88,14 @@ describe('api-key', () => { expect(resolveApiKey()).toBe('sk_stored'); }); - it('throws when no API key available', () => { - expect(() => resolveApiKey()).toThrow(/No API key/); + it('exits with error when no API key available', () => { + expect(() => resolveApiKey()).toThrow(ExitError); + expect(mockExitWithError).toHaveBeenCalledWith(expect.objectContaining({ code: 'no_api_key' })); }); - it('throws when config exists but no active environment', () => { + it('exits with error when config exists but no active environment', () => { saveConfig({ environments: {} }); - expect(() => resolveApiKey()).toThrow(/No API key/); + expect(() => resolveApiKey()).toThrow(ExitError); }); it('ignores empty string env var', () => { diff --git a/src/lib/api-key.ts b/src/lib/api-key.ts index 13c2c8a..309aff6 100644 --- a/src/lib/api-key.ts +++ b/src/lib/api-key.ts @@ -8,6 +8,7 @@ */ import { getActiveEnvironment } from './config-store.js'; +import { exitWithError } from '../utils/output.js'; const DEFAULT_BASE_URL = 'https://api.workos.com'; @@ -24,7 +25,10 @@ export function resolveApiKey(options?: ApiKeyOptions): string { const activeEnv = getActiveEnvironment(); if (activeEnv?.apiKey) return activeEnv.apiKey; - throw new Error('No API key configured. Run `workos env add` to configure an environment, or set WORKOS_API_KEY.'); + exitWithError({ + code: 'no_api_key', + message: 'No API key configured. Run `workos env add` to configure an environment, or set WORKOS_API_KEY.', + }); } export function resolveApiBaseUrl(): string { diff --git a/src/lib/ensure-auth.spec.ts b/src/lib/ensure-auth.spec.ts index 152ab42..76a5da2 100644 --- a/src/lib/ensure-auth.spec.ts +++ b/src/lib/ensure-auth.spec.ts @@ -29,10 +29,36 @@ vi.mock('../utils/debug.js', () => ({ logWarn: vi.fn(), })); -// Mock settings +// Mock settings (getConfig needed by constants.ts via environment.ts import chain) vi.mock('./settings.js', () => ({ getCliAuthClientId: vi.fn(() => 'test_client_id'), getAuthkitDomain: vi.fn(() => 'https://auth.test.com'), + getConfig: vi.fn(() => ({ + nodeVersion: '>=20', + logging: { debugMode: false }, + documentation: { workosDocsUrl: '', dashboardUrl: '', issuesUrl: '' }, + telemetry: { enabled: false, eventName: '' }, + legacy: { oauthPort: 0 }, + })), +})); + +// Mock environment detection +const mockIsNonInteractive = vi.fn(() => false); +vi.mock('../utils/environment.js', () => ({ + isNonInteractiveEnvironment: () => mockIsNonInteractive(), +})); + +// Mock exit codes — must throw to halt execution like the real process.exit() +class AuthRequiredExit extends Error { + constructor() { + super('auth_required_exit'); + } +} +const mockExitWithAuthRequired = vi.fn(() => { + throw new AuthRequiredExit(); +}); +vi.mock('../utils/exit-codes.js', () => ({ + exitWithAuthRequired: (...args: unknown[]) => mockExitWithAuthRequired(...args), })); // Mock runLogin @@ -48,7 +74,7 @@ vi.mock('./token-refresh-client.js', () => ({ })); // Import after mocks are set up -const { saveCredentials, getCredentials, setInsecureStorage } = await import('./credentials.js'); +const { saveCredentials, getCredentials, setInsecureStorage, hasCredentials } = await import('./credentials.js'); const { ensureAuthenticated } = await import('./ensure-auth.js'); describe('ensure-auth', () => { @@ -256,5 +282,124 @@ describe('ensure-auth', () => { expect(mockRefreshAccessToken).toHaveBeenCalledWith('https://auth.test.com', 'test_client_id'); }); + + describe('credential clearing on failure', () => { + it('clears stale credentials when refresh fails with invalid_grant', async () => { + saveCredentials(expiredAccessCreds); + expect(hasCredentials()).toBe(true); + + mockRefreshAccessToken.mockResolvedValue({ + success: false, + errorType: 'invalid_grant', + error: 'Refresh token expired', + }); + + mockRunLogin.mockImplementation(() => { + saveCredentials(validCreds); + }); + + await ensureAuthenticated(); + + // Credentials were cleared before runLogin, then runLogin saved new ones + expect(mockRunLogin).toHaveBeenCalledOnce(); + }); + + it('clears credentials when refresh fails with network error', async () => { + saveCredentials(expiredAccessCreds); + + mockRefreshAccessToken.mockResolvedValue({ + success: false, + errorType: 'network', + error: 'Network error', + }); + + // Don't save new creds in login — verify old ones were cleared + mockRunLogin.mockImplementation(() => {}); + + await ensureAuthenticated(); + + // Old stale credentials should be gone + expect(hasCredentials()).toBe(false); + }); + + it('clears credentials when no refresh token available', async () => { + saveCredentials(expiredCredsNoRefresh); + + mockRunLogin.mockImplementation(() => {}); + + await ensureAuthenticated(); + + expect(hasCredentials()).toBe(false); + }); + + it('does NOT clear credentials on successful refresh', async () => { + saveCredentials(expiredAccessCreds); + + mockRefreshAccessToken.mockResolvedValue({ + success: true, + accessToken: 'new_access_token', + expiresAt: Date.now() + 3600000, + refreshToken: 'new_refresh_token', + }); + + const result = await ensureAuthenticated(); + + expect(result.authenticated).toBe(true); + expect(hasCredentials()).toBe(true); + const creds = getCredentials(); + expect(creds?.accessToken).toBe('new_access_token'); + }); + }); + + describe('non-TTY mode', () => { + beforeEach(() => { + mockIsNonInteractive.mockReturnValue(true); + }); + + afterEach(() => { + mockIsNonInteractive.mockReturnValue(false); + }); + + it('exits with auth required when no credentials in non-TTY', async () => { + // No credentials saved, non-TTY mode + await expect(ensureAuthenticated()).rejects.toThrow(AuthRequiredExit); + + expect(mockExitWithAuthRequired).toHaveBeenCalled(); + expect(mockRunLogin).not.toHaveBeenCalled(); + }); + + it('still refreshes tokens silently in non-TTY', async () => { + saveCredentials(expiredAccessCreds); + + mockRefreshAccessToken.mockResolvedValue({ + success: true, + accessToken: 'new_token', + expiresAt: Date.now() + 3600000, + refreshToken: 'new_refresh', + }); + + const result = await ensureAuthenticated(); + + expect(result.tokenRefreshed).toBe(true); + expect(result.authenticated).toBe(true); + expect(mockExitWithAuthRequired).not.toHaveBeenCalled(); + expect(mockRunLogin).not.toHaveBeenCalled(); + }); + + it('exits with auth required when refresh fails in non-TTY', async () => { + saveCredentials(expiredAccessCreds); + + mockRefreshAccessToken.mockResolvedValue({ + success: false, + errorType: 'invalid_grant', + error: 'Refresh token expired', + }); + + await expect(ensureAuthenticated()).rejects.toThrow(AuthRequiredExit); + + expect(mockExitWithAuthRequired).toHaveBeenCalled(); + expect(mockRunLogin).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/src/lib/ensure-auth.ts b/src/lib/ensure-auth.ts index 402a6fd..f54ced4 100644 --- a/src/lib/ensure-auth.ts +++ b/src/lib/ensure-auth.ts @@ -2,11 +2,13 @@ * Startup auth guard - ensures valid authentication before command execution. */ -import { getCredentials, updateTokens, hasCredentials, isTokenExpired } from './credentials.js'; +import { getCredentials, updateTokens, hasCredentials, isTokenExpired, clearCredentials } from './credentials.js'; import { refreshAccessToken } from './token-refresh-client.js'; import { getCliAuthClientId, getAuthkitDomain } from './settings.js'; import { runLogin } from '../commands/login.js'; import { logInfo } from '../utils/debug.js'; +import { isNonInteractiveEnvironment } from '../utils/environment.js'; +import { exitWithAuthRequired } from '../utils/exit-codes.js'; export interface EnsureAuthResult { /** Whether auth is now valid */ @@ -36,6 +38,9 @@ export async function ensureAuthenticated(): Promise { // Case 1: No credentials at all if (!hasCredentials()) { + if (isNonInteractiveEnvironment()) { + exitWithAuthRequired(); + } logInfo('[ensure-auth] No credentials found, triggering login'); await runLogin(); result.loginTriggered = true; @@ -45,7 +50,11 @@ export async function ensureAuthenticated(): Promise { const creds = getCredentials(); if (!creds) { - // Credentials file exists but is invalid/empty + // Credentials file exists but is invalid/empty — clear stale data + clearCredentials(); + if (isNonInteractiveEnvironment()) { + exitWithAuthRequired(); + } logInfo('[ensure-auth] Invalid credentials file, triggering login'); await runLogin(); result.loginTriggered = true; @@ -78,6 +87,10 @@ export async function ensureAuthenticated(): Promise { // Refresh failed - check if it's recoverable if (refreshResult.errorType === 'invalid_grant') { + clearCredentials(); + if (isNonInteractiveEnvironment()) { + exitWithAuthRequired('Session expired. Run `workos login` in an interactive terminal to re-authenticate.'); + } logInfo('[ensure-auth] Refresh token expired, triggering login'); await runLogin(); result.loginTriggered = true; @@ -85,7 +98,13 @@ export async function ensureAuthenticated(): Promise { return result; } - // Network or server error - try login as fallback + // Network or server error - clear stale creds and try login as fallback + clearCredentials(); + if (isNonInteractiveEnvironment()) { + exitWithAuthRequired( + `Authentication refresh failed (${refreshResult.errorType}). Run \`workos login\` in an interactive terminal.`, + ); + } logInfo(`[ensure-auth] Refresh failed (${refreshResult.errorType}), triggering login`); await runLogin(); result.loginTriggered = true; @@ -94,7 +113,11 @@ export async function ensureAuthenticated(): Promise { } } - // Case 4: No refresh token available, must login + // Case 4: No refresh token available — clear stale creds, must login + clearCredentials(); + if (isNonInteractiveEnvironment()) { + exitWithAuthRequired('Session expired. Run `workos login` in an interactive terminal to re-authenticate.'); + } logInfo('[ensure-auth] No refresh token, triggering login'); await runLogin(); result.loginTriggered = true; diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 057ebe2..57bcbc8 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -8,6 +8,7 @@ import { CLIAdapter } from './adapters/cli-adapter.js'; import { DashboardAdapter } from './adapters/dashboard-adapter.js'; import type { InstallerAdapter } from './adapters/types.js'; import type { InstallerOptions } from '../utils/types.js'; +import { isNonInteractiveEnvironment } from '../utils/environment.js'; import type { InstallerMachineContext, DetectionOutput, @@ -193,9 +194,27 @@ export async function runWithCore(options: InstallerOptions): Promise { } }; - const adapter: InstallerAdapter = options.dashboard - ? new DashboardAdapter({ emitter, sendEvent, debug: augmentedOptions.debug }) - : new CLIAdapter({ emitter, sendEvent, debug: augmentedOptions.debug }); + let adapter: InstallerAdapter; + if (isNonInteractiveEnvironment()) { + const { HeadlessAdapter } = await import('./adapters/headless-adapter.js'); + adapter = new HeadlessAdapter({ + emitter, + sendEvent, + debug: augmentedOptions.debug, + options: { + apiKey: augmentedOptions.apiKey, + clientId: augmentedOptions.clientId, + noBranch: augmentedOptions.noBranch, + noCommit: augmentedOptions.noCommit, + createPr: augmentedOptions.createPr, + noGitCheck: augmentedOptions.noGitCheck, + }, + }); + } else if (options.dashboard) { + adapter = new DashboardAdapter({ emitter, sendEvent, debug: augmentedOptions.debug }); + } else { + adapter = new CLIAdapter({ emitter, sendEvent, debug: augmentedOptions.debug }); + } const machineWithActors = installerMachine.provide({ actors: { @@ -467,7 +486,7 @@ export async function runWithCore(options: InstallerOptions): Promise { await adapter.start(); // Start telemetry session - const mode = augmentedOptions.dashboard ? 'tui' : 'cli'; + const mode = isNonInteractiveEnvironment() ? 'headless' : augmentedOptions.dashboard ? 'tui' : 'cli'; analytics.sessionStart(mode, getVersion()); let installerStatus: 'success' | 'error' | 'cancelled' = 'success'; diff --git a/src/run.ts b/src/run.ts index 4d5cebd..fcb0dc4 100644 --- a/src/run.ts +++ b/src/run.ts @@ -24,7 +24,14 @@ export type InstallerArgs = { dashboard?: boolean; inspect?: boolean; noValidate?: boolean; + validate?: boolean; noCommit?: boolean; + commit?: boolean; + noBranch?: boolean; + branch?: boolean; + createPr?: boolean; + noGitCheck?: boolean; + gitCheck?: boolean; direct?: boolean; }; @@ -60,8 +67,11 @@ function buildOptions(argv: InstallerArgs): InstallerOptions { dashboard: merged.dashboard ?? false, integration: merged.integration, inspect: merged.inspect ?? false, - noValidate: merged.noValidate ?? false, - noCommit: merged.noCommit ?? false, + noValidate: merged.noValidate ?? merged.validate === false, + noCommit: merged.noCommit ?? merged.commit === false, + noBranch: merged.noBranch ?? merged.branch === false, + createPr: merged.createPr ?? false, + noGitCheck: merged.noGitCheck ?? merged.gitCheck === false, direct: merged.direct ?? false, emitter: createInstallerEventEmitter(), // Will be replaced in runWithCore }; diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index 875d9c5..c0f348e 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -71,7 +71,7 @@ export class Analytics { return undefined; } - sessionStart(mode: 'cli' | 'tui', version: string) { + sessionStart(mode: 'cli' | 'tui' | 'headless', version: string) { if (!WORKOS_TELEMETRY_ENABLED) return; const event: SessionStartEvent = { diff --git a/src/utils/environment.ts b/src/utils/environment.ts index 3008a2c..bab2488 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -4,6 +4,16 @@ import fg from 'fast-glob'; import { IS_DEV } from '../lib/constants.js'; export function isNonInteractiveEnvironment(): boolean { + // WORKOS_NO_PROMPT forces non-interactive regardless of TTY + if (process.env.WORKOS_NO_PROMPT === '1' || process.env.WORKOS_NO_PROMPT === 'true') { + return true; + } + + // WORKOS_FORCE_TTY forces interactive regardless of TTY + if (process.env.WORKOS_FORCE_TTY === '1' || process.env.WORKOS_FORCE_TTY === 'true') { + return false; + } + if (IS_DEV) { return false; } diff --git a/src/utils/exit-codes.spec.ts b/src/utils/exit-codes.spec.ts new file mode 100644 index 0000000..1178f1b --- /dev/null +++ b/src/utils/exit-codes.spec.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('./output.js', () => ({ + outputError: vi.fn(), +})); + +const { outputError } = await import('./output.js'); +const { ExitCode, exitWithCode, exitWithAuthRequired } = await import('./exit-codes.js'); + +describe('exit-codes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('ExitCode constants', () => { + it('has correct values', () => { + expect(ExitCode.SUCCESS).toBe(0); + expect(ExitCode.GENERAL_ERROR).toBe(1); + expect(ExitCode.CANCELLED).toBe(2); + expect(ExitCode.AUTH_REQUIRED).toBe(4); + }); + }); + + describe('exitWithCode', () => { + it('exits with the given code', () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + exitWithCode(ExitCode.GENERAL_ERROR); + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + }); + + it('writes error before exiting when error provided', () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + exitWithCode(ExitCode.AUTH_REQUIRED, { code: 'auth_required', message: 'Not logged in' }); + expect(outputError).toHaveBeenCalledWith({ code: 'auth_required', message: 'Not logged in' }); + expect(exitSpy).toHaveBeenCalledWith(4); + exitSpy.mockRestore(); + }); + + it('does not write error when none provided', () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + exitWithCode(ExitCode.SUCCESS); + expect(outputError).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(0); + exitSpy.mockRestore(); + }); + }); + + describe('exitWithAuthRequired', () => { + it('exits with code 4 and auth_required error', () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + exitWithAuthRequired(); + expect(outputError).toHaveBeenCalledWith(expect.objectContaining({ code: 'auth_required' })); + expect(exitSpy).toHaveBeenCalledWith(4); + exitSpy.mockRestore(); + }); + + it('uses custom message when provided', () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + exitWithAuthRequired('Custom auth message'); + expect(outputError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'auth_required', message: 'Custom auth message' }), + ); + exitSpy.mockRestore(); + }); + }); +}); diff --git a/src/utils/exit-codes.ts b/src/utils/exit-codes.ts new file mode 100644 index 0000000..3cfc111 --- /dev/null +++ b/src/utils/exit-codes.ts @@ -0,0 +1,35 @@ +/** + * Standardized exit codes following gh CLI convention. + * + * 0 = Success + * 1 = General error + * 2 = Cancelled (e.g., Ctrl+C, user cancelled prompt) + * 4 = Authentication required + */ + +import { outputError } from './output.js'; + +export const ExitCode = { + SUCCESS: 0, + GENERAL_ERROR: 1, + CANCELLED: 2, + AUTH_REQUIRED: 4, +} as const; + +export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode]; + +/** Exit with a specific code, optionally writing a structured error first. */ +export function exitWithCode(code: ExitCodeValue, error?: { code: string; message: string }): never { + if (error) { + outputError(error); + } + process.exit(code); +} + +/** Convenience: exit with code 4 and auth-required error. */ +export function exitWithAuthRequired(message?: string): never { + exitWithCode(ExitCode.AUTH_REQUIRED, { + code: 'auth_required', + message: message ?? 'Not authenticated. Run `workos login` in an interactive terminal, or set WORKOS_API_KEY.', + }); +} diff --git a/src/utils/help-json.spec.ts b/src/utils/help-json.spec.ts new file mode 100644 index 0000000..08a9bf2 --- /dev/null +++ b/src/utils/help-json.spec.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('../lib/settings.js', () => ({ + getVersion: vi.fn(() => '0.7.3'), +})); + +const { buildCommandTree } = await import('./help-json.js'); + +describe('help-json', () => { + describe('buildCommandTree() — full tree', () => { + it('returns root with name "workos"', () => { + const tree = buildCommandTree(); + expect(tree).toHaveProperty('name', 'workos'); + }); + + it('includes version string', () => { + const tree = buildCommandTree(); + expect(tree).toHaveProperty('version', '0.7.3'); + }); + + it('includes top-level description', () => { + const tree = buildCommandTree(); + expect(tree).toHaveProperty('description'); + expect((tree as { description: string }).description.length).toBeGreaterThan(0); + }); + + it('includes all public commands', () => { + const tree = buildCommandTree(); + const names = (tree as { commands: { name: string }[] }).commands.map((c) => c.name); + expect(names).toEqual( + expect.arrayContaining([ + 'login', + 'logout', + 'install-skill', + 'doctor', + 'env', + 'organization', + 'user', + 'install', + ]), + ); + }); + + it('does not include hidden dashboard command', () => { + const tree = buildCommandTree(); + const names = (tree as { commands: { name: string }[] }).commands.map((c) => c.name); + expect(names).not.toContain('dashboard'); + }); + + it('includes global options with types and defaults', () => { + const tree = buildCommandTree(); + const opts = (tree as { options: { name: string; type: string; default?: unknown }[] }).options; + const jsonOpt = opts.find((o) => o.name === 'json'); + expect(jsonOpt).toBeDefined(); + expect(jsonOpt!.type).toBe('boolean'); + expect(jsonOpt!.default).toBe(false); + }); + + it('output is valid JSON-serializable', () => { + const tree = buildCommandTree(); + const json = JSON.stringify(tree); + expect(() => JSON.parse(json)).not.toThrow(); + }); + }); + + describe('buildCommandTree() — subcommand subtrees', () => { + it('returns env subtree with subcommands', () => { + const tree = buildCommandTree('env'); + expect(tree.name).toBe('env'); + const subNames = tree.commands!.map((c) => c.name); + expect(subNames).toEqual(expect.arrayContaining(['add', 'remove', 'switch', 'list'])); + }); + + it('returns organization subtree with CRUD subcommands', () => { + const tree = buildCommandTree('organization'); + expect(tree.name).toBe('organization'); + const subNames = tree.commands!.map((c) => c.name); + expect(subNames).toEqual(expect.arrayContaining(['create', 'update', 'get', 'list', 'delete'])); + }); + + it('returns user subtree with subcommands', () => { + const tree = buildCommandTree('user'); + expect(tree.name).toBe('user'); + const subNames = tree.commands!.map((c) => c.name); + expect(subNames).toEqual(expect.arrayContaining(['get', 'list', 'update', 'delete'])); + }); + + it('returns full tree for unknown subcommand', () => { + const tree = buildCommandTree('nonexistent'); + expect(tree).toHaveProperty('name', 'workos'); + expect(tree).toHaveProperty('version'); + }); + }); + + describe('positional schemas', () => { + it('env add has optional positionals', () => { + const env = buildCommandTree('env'); + const add = env.commands!.find((c) => c.name === 'add'); + expect(add).toBeDefined(); + expect(add!.positionals).toBeDefined(); + const namePos = add!.positionals!.find((p) => p.name === 'name'); + expect(namePos).toBeDefined(); + expect(namePos!.required).toBe(false); + }); + + it('env remove has required positional', () => { + const env = buildCommandTree('env'); + const remove = env.commands!.find((c) => c.name === 'remove'); + expect(remove!.positionals![0].required).toBe(true); + }); + + it('organization create has required name positional', () => { + const org = buildCommandTree('organization'); + const create = org.commands!.find((c) => c.name === 'create'); + const namePos = create!.positionals!.find((p) => p.name === 'name'); + expect(namePos!.required).toBe(true); + }); + + it('organization delete has required orgId positional', () => { + const org = buildCommandTree('organization'); + const del = org.commands!.find((c) => c.name === 'delete'); + const orgId = del!.positionals!.find((p) => p.name === 'orgId'); + expect(orgId).toBeDefined(); + expect(orgId!.required).toBe(true); + }); + + it('user update has required userId positional', () => { + const user = buildCommandTree('user'); + const update = user.commands!.find((c) => c.name === 'update'); + const userId = update!.positionals!.find((p) => p.name === 'userId'); + expect(userId!.required).toBe(true); + }); + }); + + describe('option schemas', () => { + it('install command has direct option with alias', () => { + const install = buildCommandTree('install'); + const direct = install.options!.find((o) => o.name === 'direct'); + expect(direct).toBeDefined(); + expect(direct!.alias).toBe('D'); + expect(direct!.type).toBe('boolean'); + expect(direct!.default).toBe(false); + }); + + it('organization list has pagination options', () => { + const org = buildCommandTree('organization'); + const list = org.commands!.find((c) => c.name === 'list'); + const optNames = list!.options!.map((o) => o.name); + expect(optNames).toEqual(expect.arrayContaining(['limit', 'before', 'after', 'order'])); + }); + + it('user list has email and organization filters', () => { + const user = buildCommandTree('user'); + const list = user.commands!.find((c) => c.name === 'list'); + const optNames = list!.options!.map((o) => o.name); + expect(optNames).toEqual(expect.arrayContaining(['email', 'organization'])); + }); + }); +}); diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts new file mode 100644 index 0000000..ebfa602 --- /dev/null +++ b/src/utils/help-json.ts @@ -0,0 +1,466 @@ +/** + * Agent-discoverable help: machine-readable command tree for --help --json. + * + * Static command registry mirroring bin.ts yargs definitions. + * yargs v18 doesn't expose public APIs for command introspection, + * so we maintain a parallel typed registry. + */ + +import { getVersion } from '../lib/settings.js'; + +export interface OptionSchema { + name: string; + type: 'string' | 'boolean' | 'number' | 'array'; + description: string; + required: boolean; + default?: unknown; + alias?: string; + choices?: string[]; + hidden: boolean; +} + +export interface PositionalSchema { + name: string; + type: string; + description: string; + required: boolean; +} + +export interface CommandSchema { + name: string; + description: string; + commands?: CommandSchema[]; + options?: OptionSchema[]; + positionals?: PositionalSchema[]; + examples?: string[]; +} + +export interface HelpOutput { + name: string; + version: string; + description: string; + commands: CommandSchema[]; + options: OptionSchema[]; +} + +// --------------------------------------------------------------------------- +// Shared option fragments (mirrors bin.ts shared option objects) +// --------------------------------------------------------------------------- + +const insecureStorageOpt: OptionSchema = { + name: 'insecure-storage', + type: 'boolean', + description: 'Store credentials in plaintext file instead of system keyring', + required: false, + default: false, + hidden: false, +}; + +const apiKeyOpt: OptionSchema = { + name: 'api-key', + type: 'string', + description: 'WorkOS API key (overrides environment config). Format: sk_live_* or sk_test_*', + required: false, + hidden: false, +}; + +const paginationOpts: OptionSchema[] = [ + { name: 'limit', type: 'number', description: 'Maximum number of results to return', required: false, hidden: false }, + { + name: 'before', + type: 'string', + description: 'Pagination cursor for results before a specific item', + required: false, + hidden: false, + }, + { + name: 'after', + type: 'string', + description: 'Pagination cursor for results after a specific item', + required: false, + hidden: false, + }, + { + name: 'order', + type: 'string', + description: 'Sort order (asc or desc)', + required: false, + choices: ['asc', 'desc'], + hidden: false, + }, +]; + +// --------------------------------------------------------------------------- +// Command registry +// --------------------------------------------------------------------------- + +const commands: CommandSchema[] = [ + { + name: 'login', + description: 'Authenticate with WorkOS via browser-based OAuth', + options: [insecureStorageOpt], + }, + { + name: 'logout', + description: 'Remove stored WorkOS credentials and tokens', + options: [insecureStorageOpt], + }, + { + name: 'install-skill', + description: 'Install bundled AuthKit skills to coding agents (Claude Code, Codex, Cursor, Goose)', + options: [ + { + name: 'list', + type: 'boolean', + description: 'List available skills without installing', + required: false, + alias: 'l', + hidden: false, + }, + { + name: 'skill', + type: 'array', + description: 'Install specific skill(s) by name', + required: false, + alias: 's', + hidden: false, + }, + { + name: 'agent', + type: 'array', + description: 'Target specific agent(s): claude-code, codex, cursor, goose', + required: false, + alias: 'a', + hidden: false, + }, + ], + }, + { + name: 'doctor', + description: 'Diagnose WorkOS AuthKit integration issues in the current project', + options: [ + { + name: 'verbose', + type: 'boolean', + description: 'Include additional diagnostic information', + required: false, + default: false, + hidden: false, + }, + { + name: 'skip-api', + type: 'boolean', + description: 'Skip API calls (offline mode)', + required: false, + default: false, + hidden: false, + }, + { + name: 'skip-ai', + type: 'boolean', + description: 'Skip AI-powered analysis', + required: false, + default: false, + hidden: false, + }, + { + name: 'install-dir', + type: 'string', + description: 'Project directory to analyze (defaults to cwd)', + required: false, + hidden: false, + }, + { + name: 'json', + type: 'boolean', + description: 'Output diagnostic report as JSON', + required: false, + default: false, + hidden: false, + }, + { + name: 'copy', + type: 'boolean', + description: 'Copy diagnostic report to clipboard', + required: false, + default: false, + hidden: false, + }, + ], + }, + { + name: 'env', + description: 'Manage environment configurations (API keys, endpoints, active environment)', + options: [insecureStorageOpt], + commands: [ + { + name: 'add', + description: 'Add a new environment configuration with API key and optional settings', + positionals: [ + { + name: 'name', + type: 'string', + description: 'Environment name (lowercase, hyphens, underscores)', + required: false, + }, + { name: 'apiKey', type: 'string', description: 'WorkOS API key (sk_live_* or sk_test_*)', required: false }, + ], + options: [ + { + name: 'client-id', + type: 'string', + description: 'WorkOS client ID for this environment', + required: false, + hidden: false, + }, + { name: 'endpoint', type: 'string', description: 'Custom API endpoint URL', required: false, hidden: false }, + ], + }, + { + name: 'remove', + description: 'Remove an environment configuration', + positionals: [{ name: 'name', type: 'string', description: 'Environment name to remove', required: true }], + }, + { + name: 'switch', + description: 'Switch the active environment (determines which API key is used)', + positionals: [{ name: 'name', type: 'string', description: 'Environment name to activate', required: false }], + }, + { + name: 'list', + description: 'List all configured environments and show which is active', + }, + ], + }, + { + name: 'organization', + description: 'Manage WorkOS organizations (create, update, get, list, delete)', + options: [insecureStorageOpt, apiKeyOpt], + commands: [ + { + name: 'create', + description: 'Create a new organization with optional verified domains', + positionals: [ + { name: 'name', type: 'string', description: 'Organization name', required: true }, + { + name: 'domains', + type: 'string', + description: 'Domains in format domain:state (state defaults to verified)', + required: false, + }, + ], + }, + { + name: 'update', + description: 'Update an existing organization name or domain', + positionals: [ + { name: 'orgId', type: 'string', description: 'Organization ID (org_*)', required: true }, + { name: 'name', type: 'string', description: 'New organization name', required: true }, + { name: 'domain', type: 'string', description: 'Domain to add or update', required: false }, + { name: 'state', type: 'string', description: 'Domain state (verified or pending)', required: false }, + ], + }, + { + name: 'get', + description: 'Get an organization by its ID', + positionals: [{ name: 'orgId', type: 'string', description: 'Organization ID (org_*)', required: true }], + }, + { + name: 'list', + description: 'List organizations with optional filters and pagination', + options: [ + { + name: 'domain', + type: 'string', + description: 'Filter organizations by domain', + required: false, + hidden: false, + }, + ...paginationOpts, + ], + }, + { + name: 'delete', + description: 'Delete an organization by its ID', + positionals: [{ name: 'orgId', type: 'string', description: 'Organization ID (org_*)', required: true }], + }, + ], + }, + { + name: 'user', + description: 'Manage WorkOS user management users (get, list, update, delete)', + options: [insecureStorageOpt, apiKeyOpt], + commands: [ + { + name: 'get', + description: 'Get a user by their ID', + positionals: [{ name: 'userId', type: 'string', description: 'User ID (user_*)', required: true }], + }, + { + name: 'list', + description: 'List users with optional filters and pagination', + options: [ + { + name: 'email', + type: 'string', + description: 'Filter users by email address', + required: false, + hidden: false, + }, + { + name: 'organization', + type: 'string', + description: 'Filter users by organization ID', + required: false, + hidden: false, + }, + ...paginationOpts, + ], + }, + { + name: 'update', + description: 'Update user properties (name, email verification, password, external ID)', + positionals: [{ name: 'userId', type: 'string', description: 'User ID (user_*)', required: true }], + options: [ + { name: 'first-name', type: 'string', description: 'First name', required: false, hidden: false }, + { name: 'last-name', type: 'string', description: 'Last name', required: false, hidden: false }, + { + name: 'email-verified', + type: 'boolean', + description: 'Email verification status', + required: false, + hidden: false, + }, + { name: 'password', type: 'string', description: 'New password', required: false, hidden: false }, + { + name: 'external-id', + type: 'string', + description: 'External ID for cross-system mapping', + required: false, + hidden: false, + }, + ], + }, + { + name: 'delete', + description: 'Delete a user by their ID', + positionals: [{ name: 'userId', type: 'string', description: 'User ID (user_*)', required: true }], + }, + ], + }, + { + name: 'install', + description: 'Install WorkOS AuthKit into your project (interactive framework detection and setup)', + options: [ + { + name: 'direct', + type: 'boolean', + description: 'Use your own Anthropic API key (bypass llm-gateway)', + required: false, + default: false, + alias: 'D', + hidden: false, + }, + { + name: 'debug', + type: 'boolean', + description: 'Enable verbose logging', + required: false, + default: false, + hidden: false, + }, + insecureStorageOpt, + { + name: 'homepage-url', + type: 'string', + description: 'App homepage URL for WorkOS (defaults to http://localhost:{port})', + required: false, + hidden: false, + }, + { + name: 'redirect-uri', + type: 'string', + description: 'Redirect URI for WorkOS callback (defaults to framework convention)', + required: false, + hidden: false, + }, + { + name: 'no-validate', + type: 'boolean', + description: 'Skip post-installation validation (includes build check)', + required: false, + default: false, + hidden: false, + }, + { + name: 'install-dir', + type: 'string', + description: 'Directory to install WorkOS AuthKit in (defaults to cwd)', + required: false, + hidden: false, + }, + { + name: 'integration', + type: 'string', + description: 'Framework integration to set up (auto-detected if omitted)', + required: false, + hidden: false, + }, + { + name: 'force-install', + type: 'boolean', + description: 'Force install packages even if peer dependency checks fail', + required: false, + default: false, + hidden: false, + }, + { + name: 'dashboard', + type: 'boolean', + description: 'Run with visual dashboard mode', + required: false, + default: false, + alias: 'd', + hidden: false, + }, + ], + }, +]; + +const globalOptions: OptionSchema[] = [ + { + name: 'json', + type: 'boolean', + description: 'Output results as JSON (auto-enabled in non-TTY environments)', + required: false, + default: false, + hidden: false, + }, + { name: 'help', type: 'boolean', description: 'Show help', required: false, alias: 'h', hidden: false }, + { name: 'version', type: 'boolean', description: 'Show version number', required: false, alias: 'v', hidden: false }, +]; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Build a machine-readable command tree for --help --json output. + * + * @param subcommand - Optional command name to return a subtree for (e.g. "env"). + * Returns full tree if omitted or if command not found. + */ +export function buildCommandTree(subcommand?: string): HelpOutput | CommandSchema { + if (subcommand) { + const match = commands.find((c) => c.name === subcommand); + if (match) return match; + } + + return { + name: 'workos', + version: getVersion(), + description: 'WorkOS CLI for AuthKit integration and resource management', + commands, + options: globalOptions, + }; +} diff --git a/src/utils/ndjson.spec.ts b/src/utils/ndjson.spec.ts new file mode 100644 index 0000000..9c9594a --- /dev/null +++ b/src/utils/ndjson.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { writeNDJSON } from './ndjson.js'; + +describe('writeNDJSON', () => { + let writeSpy: ReturnType; + + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-15T12:00:00.000Z')); + }); + + afterEach(() => { + writeSpy.mockRestore(); + vi.useRealTimers(); + }); + + it('writes valid JSON followed by newline', () => { + writeNDJSON({ type: 'test:event' }); + + expect(writeSpy).toHaveBeenCalledTimes(1); + const output = writeSpy.mock.calls[0][0] as string; + expect(output.endsWith('\n')).toBe(true); + + const parsed = JSON.parse(output.trim()); + expect(parsed.type).toBe('test:event'); + }); + + it('includes ISO timestamp', () => { + writeNDJSON({ type: 'test:event' }); + + const output = writeSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output.trim()); + expect(parsed.timestamp).toBe('2026-01-15T12:00:00.000Z'); + }); + + it('passes through additional payload fields', () => { + writeNDJSON({ type: 'detection:complete', integration: 'nextjs' }); + + const output = writeSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output.trim()); + expect(parsed.integration).toBe('nextjs'); + }); + + it('outputs exactly one line per call', () => { + writeNDJSON({ type: 'event1' }); + writeNDJSON({ type: 'event2' }); + + expect(writeSpy).toHaveBeenCalledTimes(2); + for (const call of writeSpy.mock.calls) { + const output = call[0] as string; + const lines = output.split('\n').filter(Boolean); + expect(lines).toHaveLength(1); + } + }); + + it('produces parseable NDJSON stream', () => { + writeNDJSON({ type: 'start' }); + writeNDJSON({ type: 'progress', step: 'installing' }); + writeNDJSON({ type: 'complete', success: true }); + + const allOutput = writeSpy.mock.calls.map((c) => c[0] as string).join(''); + const lines = allOutput.trim().split('\n'); + expect(lines).toHaveLength(3); + + for (const line of lines) { + const parsed = JSON.parse(line); + expect(parsed).toHaveProperty('type'); + expect(parsed).toHaveProperty('timestamp'); + } + }); +}); diff --git a/src/utils/ndjson.ts b/src/utils/ndjson.ts new file mode 100644 index 0000000..feed2fd --- /dev/null +++ b/src/utils/ndjson.ts @@ -0,0 +1,24 @@ +/** + * NDJSON (Newline-Delimited JSON) writer for headless mode. + * + * Each line is a self-contained JSON object with a `type` discriminator + * and an ISO-8601 `timestamp`. Consumers can parse line-by-line. + */ + +export interface NDJSONEvent { + type: string; + timestamp: string; + [key: string]: unknown; +} + +/** + * Write a single NDJSON event to stdout. + * Automatically adds an ISO timestamp. + */ +export function writeNDJSON(event: Omit): void { + const line = { + ...event, + timestamp: new Date().toISOString(), + }; + process.stdout.write(JSON.stringify(line) + '\n'); +} diff --git a/src/utils/output.spec.ts b/src/utils/output.spec.ts new file mode 100644 index 0000000..5173e6c --- /dev/null +++ b/src/utils/output.spec.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const { + resolveOutputMode, + setOutputMode, + getOutputMode, + isJsonMode, + outputJson, + outputError, + outputSuccess, + exitWithError, +} = await import('./output.js'); + +describe('output', () => { + const originalIsTTY = process.stdout.isTTY; + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.WORKOS_FORCE_TTY; + setOutputMode('human'); + }); + + afterEach(() => { + Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, writable: true }); + process.env = originalEnv; + }); + + describe('resolveOutputMode', () => { + it('returns json when --json flag passed', () => { + expect(resolveOutputMode(true)).toBe('json'); + }); + + it('returns human when WORKOS_FORCE_TTY is set even without TTY', () => { + Object.defineProperty(process.stdout, 'isTTY', { value: undefined, writable: true }); + process.env.WORKOS_FORCE_TTY = '1'; + expect(resolveOutputMode()).toBe('human'); + }); + + it('returns json when stdout is not a TTY', () => { + Object.defineProperty(process.stdout, 'isTTY', { value: undefined, writable: true }); + expect(resolveOutputMode()).toBe('json'); + }); + + it('returns human when stdout is a TTY and no flags', () => { + Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); + expect(resolveOutputMode()).toBe('human'); + }); + + it('--json flag overrides WORKOS_FORCE_TTY', () => { + process.env.WORKOS_FORCE_TTY = '1'; + expect(resolveOutputMode(true)).toBe('json'); + }); + }); + + describe('setOutputMode / getOutputMode / isJsonMode', () => { + it('sets and gets output mode', () => { + setOutputMode('json'); + expect(getOutputMode()).toBe('json'); + expect(isJsonMode()).toBe(true); + }); + + it('defaults to human', () => { + setOutputMode('human'); + expect(getOutputMode()).toBe('human'); + expect(isJsonMode()).toBe(false); + }); + }); + + describe('outputJson', () => { + it('writes valid JSON to stdout', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + outputJson({ foo: 'bar', count: 42 }); + expect(spy).toHaveBeenCalledWith('{"foo":"bar","count":42}'); + spy.mockRestore(); + }); + + it('handles arrays', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + outputJson([1, 2, 3]); + expect(spy).toHaveBeenCalledWith('[1,2,3]'); + spy.mockRestore(); + }); + }); + + describe('outputError', () => { + it('writes JSON to stderr in json mode', () => { + setOutputMode('json'); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + outputError({ code: 'test_error', message: 'something failed' }); + const output = spy.mock.calls[0][0]; + expect(JSON.parse(output)).toEqual({ + error: { code: 'test_error', message: 'something failed' }, + }); + spy.mockRestore(); + }); + + it('writes plain text to stderr in human mode', () => { + setOutputMode('human'); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + outputError({ code: 'test_error', message: 'something failed' }); + expect(spy.mock.calls[0][0]).toContain('something failed'); + spy.mockRestore(); + }); + }); + + describe('outputSuccess', () => { + it('writes JSON in json mode', () => { + setOutputMode('json'); + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + outputSuccess('Created', { id: '123' }); + const output = JSON.parse(spy.mock.calls[0][0]); + expect(output).toEqual({ status: 'ok', message: 'Created', id: '123' }); + spy.mockRestore(); + }); + + it('writes chalk-formatted text in human mode', () => { + setOutputMode('human'); + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + outputSuccess('Created'); + expect(spy.mock.calls[0][0]).toContain('Created'); + spy.mockRestore(); + }); + }); + + describe('exitWithError', () => { + it('writes error and exits with code 1', () => { + setOutputMode('json'); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + exitWithError({ code: 'bad', message: 'something broke' }); + + const output = JSON.parse(errorSpy.mock.calls[0][0]); + expect(output.error.code).toBe('bad'); + expect(exitSpy).toHaveBeenCalledWith(1); + + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + }); +}); diff --git a/src/utils/output.ts b/src/utils/output.ts new file mode 100644 index 0000000..1bf6dcc --- /dev/null +++ b/src/utils/output.ts @@ -0,0 +1,99 @@ +/** + * Output mode system for non-TTY / JSON support. + * + * Resolves once at startup, drives all output formatting. + * In JSON mode: structured JSON to stdout, structured errors to stderr. + * In human mode: chalk-formatted output (existing behavior). + */ + +import chalk from 'chalk'; +import { formatTable, type TableColumn } from './table.js'; + +export type OutputMode = 'human' | 'json'; + +let currentMode: OutputMode = 'human'; + +/** + * Resolve the output mode based on flags and environment. + * + * Priority: + * 1. Explicit --json flag + * 2. WORKOS_FORCE_TTY env var → human + * 3. Non-TTY auto-detection → json + * 4. Default → human + */ +export function resolveOutputMode(jsonFlag?: boolean): OutputMode { + if (jsonFlag) return 'json'; + if (process.env.WORKOS_FORCE_TTY === '1' || process.env.WORKOS_FORCE_TTY === 'true') return 'human'; + if (process.env.WORKOS_NO_PROMPT === '1' || process.env.WORKOS_NO_PROMPT === 'true') return 'json'; + if (!process.stdout.isTTY) return 'json'; + return 'human'; +} + +export function setOutputMode(mode: OutputMode): void { + currentMode = mode; + if (mode === 'json') { + chalk.level = 0; + } +} + +export function getOutputMode(): OutputMode { + return currentMode; +} + +export function isJsonMode(): boolean { + return currentMode === 'json'; +} + +/** Write structured JSON to stdout (one line, no pretty-print). */ +export function outputJson(data: unknown): void { + console.log(JSON.stringify(data)); +} + +/** Write a success result — chalk in human mode, JSON in json mode. */ +export function outputSuccess(message: string, data?: object): void { + if (currentMode === 'json') { + console.log(JSON.stringify({ status: 'ok', message, ...data })); + } else { + console.log(chalk.green(message)); + if (data) { + console.log(JSON.stringify(data, null, 2)); + } + } +} + +/** Write a structured error to stderr. */ +export function outputError(error: { code: string; message: string; details?: unknown }): void { + if (currentMode === 'json') { + console.error(JSON.stringify({ error })); + } else { + console.error(chalk.red(error.message)); + } +} + +/** Write tabular data — chalk table in human mode, JSON array in json mode. */ +export function outputTable(columns: TableColumn[], rows: string[][], rawData?: unknown[]): void { + if (currentMode === 'json') { + if (rawData) { + console.log(JSON.stringify(rawData)); + } else { + const headers = columns.map((c) => c.header); + const jsonRows = rows.map((row) => { + const obj: Record = {}; + headers.forEach((h, i) => { + obj[h] = row[i] ?? ''; + }); + return obj; + }); + console.log(JSON.stringify(jsonRows)); + } + } else { + console.log(formatTable(columns, rows)); + } +} + +/** Exit with a structured error. Writes error then exits with code 1. */ +export function exitWithError(error: { code: string; message: string; details?: unknown }): never { + outputError(error); + process.exit(1); +} diff --git a/src/utils/telemetry-types.ts b/src/utils/telemetry-types.ts index a8bd57f..b9a6679 100644 --- a/src/utils/telemetry-types.ts +++ b/src/utils/telemetry-types.ts @@ -14,7 +14,7 @@ export interface SessionStartEvent extends TelemetryEvent { type: 'session.start'; attributes: { 'installer.version': string; - 'installer.mode': 'cli' | 'tui'; + 'installer.mode': 'cli' | 'tui' | 'headless'; 'workos.user_id'?: string; 'workos.org_id'?: string; }; diff --git a/src/utils/types.ts b/src/utils/types.ts index 901a05c..fbc29ad 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -86,6 +86,21 @@ export type InstallerOptions = { */ noCommit?: boolean; + /** + * Skip branch creation (continue on current branch) + */ + noBranch?: boolean; + + /** + * Auto-create pull request after installation + */ + createPr?: boolean; + + /** + * Skip git dirty working tree check + */ + noGitCheck?: boolean; + /** * Direct mode - bypass llm-gateway and use user's own Anthropic API key. * Requires ANTHROPIC_API_KEY environment variable.