diff --git a/src/bin.ts b/src/bin.ts index eb6b88be..1d889a22 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -60,6 +60,8 @@ import { CliExit } from './utils/cli-exit.js'; import { telemetryClient } from './utils/telemetry-client.js'; import { ExitCode } from './utils/exit-codes.js'; import { analytics } from './utils/analytics.js'; +import { formatWorkOSCommand, getWorkOSCommand } from './utils/command-invocation.js'; +import { MIGRATIONS_DESCRIPTION } from './lib/constants.js'; // Enable debug logging for all commands via env var. // Subsumes the installer's --debug flag for non-installer commands. @@ -619,7 +621,7 @@ async function runCli(): Promise { registerSubcommand( yargs, 'remove ', - 'Remove an environment configuration', + 'Remove an environment from local CLI config (does not delete or unclaim the environment in WorkOS)', (y) => y.positional('name', { type: 'string', demandOption: true, describe: 'Environment name' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); @@ -636,7 +638,7 @@ async function runCli(): Promise { if (!argv.name && !isPromptAllowed()) { exitWithError({ code: 'missing_args', - message: 'Environment name required. Usage: workos env switch ', + message: `Environment name required. Usage: ${formatWorkOSCommand('env switch ')}`, }); } await applyInsecureStorage(argv.insecureStorage); @@ -658,7 +660,7 @@ async function runCli(): Promise { registerSubcommand( yargs, 'claim', - 'Claim an unclaimed environment (link it to your account)', + 'Claim an unclaimed environment — link it to your account (permanent — cannot be undone)', (y) => y, async (argv) => { await applyInsecureStorage(argv.insecureStorage); @@ -2476,7 +2478,7 @@ async function runCli(): Promise { // Alias — canonical command is `workos env claim` .command( 'claim', - 'Claim an unclaimed WorkOS environment (link it to your account)', + 'Claim an unclaimed WorkOS environment — link it to your account (permanent — cannot be undone)', (yargs) => yargs.options({ ...insecureStorageOption, @@ -2641,11 +2643,11 @@ async function runCli(): Promise { await runDebugToken(); }, ); - return yargs.demandCommand(1, 'Run "workos debug " for debug tools.').strict(); + return yargs.demandCommand(1, `Run "${getWorkOSCommand()} debug " for debug tools.`).strict(); }) .command( 'migrations', - 'Migrate users from identity providers (Auth0, Cognito, Clerk, Firebase) to WorkOS', + MIGRATIONS_DESCRIPTION, (yargs) => yargs .strictCommands(false) diff --git a/src/commands/api/index.spec.ts b/src/commands/api/index.spec.ts index f438483d..3b52830f 100644 --- a/src/commands/api/index.spec.ts +++ b/src/commands/api/index.spec.ts @@ -160,6 +160,56 @@ describe('runApiInteractive', () => { }); expect(errorLine).toBeDefined(); }); + + describe('command hints route through formatWorkOSCommand', () => { + const NPM_KEYS = ['npm_command', 'npm_execpath', 'npm_config_user_agent'] as const; + let saved: Record; + + beforeEach(() => { + saved = {}; + for (const k of NPM_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + }); + + afterEach(() => { + for (const k of NPM_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + }); + + function parseTtyError(): { message?: string; details?: { usage?: string[] } } { + const errorLine = stderrOutput.find((line) => { + try { + return (JSON.parse(line) as { error?: { code?: string } }).error?.code === 'tty_required'; + } catch { + return false; + } + }); + expect(errorLine).toBeDefined(); + return (JSON.parse(errorLine!) as { error: { message?: string; details?: { usage?: string[] } } }).error; + } + + it('JSON refusal carries the bare form when not launched via npx', async () => { + setOutputMode('json'); + await expectExit(runApiInteractive(), 1); + const error = parseTtyError(); + expect(error.message).toContain('workos api ls'); + expect(error.details?.usage).toContain('workos api '); + expect(error.details?.usage?.some((u) => u.includes('npx'))).toBe(false); + }); + + it('JSON refusal carries the npx form when launched via npm exec', async () => { + process.env.npm_command = 'exec'; + setOutputMode('json'); + await expectExit(runApiInteractive(), 1); + const error = parseTtyError(); + expect(error.message).toContain('npx workos@latest api ls'); + expect(error.details?.usage).toContain('npx workos@latest api '); + }); + }); }); describe('runApiLs', () => { diff --git a/src/commands/api/index.ts b/src/commands/api/index.ts index 8799cddf..2f133f11 100644 --- a/src/commands/api/index.ts +++ b/src/commands/api/index.ts @@ -7,7 +7,7 @@ import { exitWithError, isJsonMode, outputJson } from '../../utils/output.js'; import { ExitCode, exitWithCode } from '../../utils/exit-codes.js'; import { isCiMode, isPromptAllowed } from '../../utils/interaction-mode.js'; import { confirmationRecovery } from '../../utils/recovery-hints.js'; -import { formatWorkOSCommandArgs } from '../../utils/command-invocation.js'; +import { formatWorkOSCommand, formatWorkOSCommandArgs } from '../../utils/command-invocation.js'; import { colorMethod, printResponse } from './format.js'; export { colorMethod } from './format.js'; @@ -31,9 +31,9 @@ export async function runApiInteractive(options?: { apiKey?: string }): Promise< if (isJsonMode()) { exitWithError({ code: 'tty_required', - message: 'Interactive mode is not available with --json. Provide an endpoint or use `workos api ls`.', + message: `Interactive mode is not available with --json. Provide an endpoint or use \`${formatWorkOSCommand('api ls')}\`.`, details: { - usage: ['workos api ', 'workos api ls [filter]'], + usage: [formatWorkOSCommand('api '), formatWorkOSCommand('api ls [filter]')], }, }); } @@ -41,8 +41,7 @@ export async function runApiInteractive(options?: { apiKey?: string }): Promise< if (!isPromptAllowed()) { exitWithError({ code: 'tty_required', - message: - 'Interactive API mode requires human mode. Usage: workos api or workos api ls [filter]. Example: workos api /user_management/users', + message: `Interactive API mode requires human mode. Usage: ${formatWorkOSCommand('api ')} or ${formatWorkOSCommand('api ls [filter]')}. Example: ${formatWorkOSCommand('api /user_management/users')}`, }); } diff --git a/src/commands/claim.spec.ts b/src/commands/claim.spec.ts index 7c3ab64c..e0603b1d 100644 --- a/src/commands/claim.spec.ts +++ b/src/commands/claim.spec.ts @@ -192,16 +192,46 @@ describe('claim command', () => { await runClaim(); - expect(mockOutputJson).toHaveBeenCalledWith({ - status: 'claim_url', - claimUrl: 'https://dashboard.workos.com/claim?nonce=nonce_abc123', - nonce: 'nonce_abc123', - }); + expect(mockOutputJson).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'claim_url', + claimUrl: 'https://dashboard.workos.com/claim?nonce=nonce_abc123', + nonce: 'nonce_abc123', + permanent: true, + }), + ); // Should NOT open browser or start polling in JSON mode expect(mockOpen).not.toHaveBeenCalled(); expect(mockSpinner.start).not.toHaveBeenCalled(); }); + it('warns that claiming is permanent before opening the browser (human mode)', async () => { + mockGetActiveEnvironment.mockReturnValue({ + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }); + mockIsUnclaimedEnvironment.mockReturnValue(true); + mockCreateClaimNonce.mockResolvedValueOnce({ nonce: 'nonce_abc123', alreadyClaimed: false }); + // Poll returns claimed immediately so the flow completes. + mockCreateClaimNonce.mockResolvedValueOnce({ alreadyClaimed: true }); + + const claimPromise = runClaim(); + await vi.advanceTimersByTimeAsync(6_000); + await claimPromise; + + const warnCall = vi + .mocked(mockClack.log.warn) + .mock.calls.find((c) => /permanent|cannot be undone/i.test(String(c[0]))); + expect(warnCall).toBeDefined(); + // The permanence warning must fire before the browser is opened. + const warnOrder = vi.mocked(mockClack.log.warn).mock.invocationCallOrder[0]; + const openOrder = vi.mocked(mockOpen).mock.invocationCallOrder[0]; + expect(warnOrder).toBeLessThan(openOrder); + }); + it('refuses claim browser flow in CI mode', async () => { mockGetActiveEnvironment.mockReturnValue({ name: 'unclaimed', @@ -436,5 +466,38 @@ describe('claim command', () => { expect(mockClack.log.info).toHaveBeenCalledWith(expect.stringContaining('Could not open browser')); }); + + it('timeout hint uses the npx form when launched via npm exec', async () => { + const NPM_KEYS = ['npm_command', 'npm_execpath', 'npm_config_user_agent'] as const; + const saved: Record = {}; + for (const k of NPM_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + process.env.npm_command = 'exec'; + try { + mockGetActiveEnvironment.mockReturnValue({ + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }); + mockIsUnclaimedEnvironment.mockReturnValue(true); + mockCreateClaimNonce.mockResolvedValueOnce({ nonce: 'nonce_abc123', alreadyClaimed: false }); + mockCreateClaimNonce.mockResolvedValue({ nonce: 'nonce_abc123', alreadyClaimed: false }); + + const claimPromise = runClaim(); + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 5_000); + await claimPromise; + + expect(mockClack.log.info).toHaveBeenCalledWith(expect.stringContaining('npx workos@latest env list')); + } finally { + for (const k of NPM_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + } + }); }); }); diff --git a/src/commands/claim.ts b/src/commands/claim.ts index bff7da13..fa476bf4 100644 --- a/src/commands/claim.ts +++ b/src/commands/claim.ts @@ -59,7 +59,13 @@ export async function runClaim(): Promise { const claimUrl = `https://dashboard.workos.com/claim?nonce=${result.nonce}`; if (isJsonMode()) { - outputJson({ status: 'claim_url', claimUrl, nonce: result.nonce }); + outputJson({ + status: 'claim_url', + claimUrl, + nonce: result.nonce, + permanent: true, + note: 'Claiming permanently links this environment to your account and cannot be undone.', + }); return; } @@ -71,6 +77,7 @@ export async function runClaim(): Promise { }); } + clack.log.warn('Claiming permanently links this environment to your account and cannot be undone.'); clack.log.info(`Open this URL to claim your environment:\n\n ${claimUrl}`); try { @@ -135,7 +142,7 @@ export async function runClaim(): Promise { } spinner.stop('Claim timed out'); - clack.log.info('Complete the claim in your browser, then run `workos env list` to verify.'); + clack.log.info(`Complete the claim in your browser, then run \`${formatWorkOSCommand('env list')}\` to verify.`); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; logError('[claim] Error:', message); diff --git a/src/commands/dev.ts b/src/commands/dev.ts index 4a940f39..449bf55e 100644 --- a/src/commands/dev.ts +++ b/src/commands/dev.ts @@ -8,6 +8,7 @@ import chalk from 'chalk'; import { IS_WINDOWS, SPAWN_OPTS } from '../utils/platform.js'; import { ExitCode, exitWithCode } from '../utils/exit-codes.js'; import { exitWithError } from '../utils/output.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; export interface DevArgs { port: number; @@ -128,7 +129,7 @@ export async function runDev(argv: DevArgs): Promise { }); } catch { console.error(chalk.red(`Failed to start: ${devCmd.command} ${devCmd.args.join(' ')}`)); - console.error(chalk.dim('Try specifying the command explicitly: workos dev -- ')); + console.error(chalk.dim(`Try specifying the command explicitly: ${formatWorkOSCommand('dev -- ')}`)); await emulator.close(); exitWithCode(ExitCode.GENERAL_ERROR); } @@ -137,7 +138,7 @@ export async function runDev(argv: DevArgs): Promise { console.error(chalk.red(`Failed to start: ${devCmd.command}`)); if ((err as NodeJS.ErrnoException).code === 'ENOENT') { console.error(chalk.dim(`Command not found: ${devCmd.command}`)); - console.error(chalk.dim('Try specifying the command explicitly: workos dev -- ')); + console.error(chalk.dim(`Try specifying the command explicitly: ${formatWorkOSCommand('dev -- ')}`)); } else { console.error(chalk.dim(err.message)); } diff --git a/src/commands/env.spec.ts b/src/commands/env.spec.ts index 20dcf8dd..1234d06a 100644 --- a/src/commands/env.spec.ts +++ b/src/commands/env.spec.ts @@ -39,7 +39,7 @@ vi.mock('node:os', async (importOriginal) => { }; }); -const { getConfig, setInsecureConfigStorage, clearConfig } = await import('../lib/config-store.js'); +const { getConfig, saveConfig, setInsecureConfigStorage, clearConfig } = await import('../lib/config-store.js'); const { runEnvAdd, runEnvRemove, runEnvSwitch, runEnvList } = await import('./env.js'); const { setOutputMode } = await import('../utils/output.js'); const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); @@ -157,6 +157,34 @@ describe('env commands', () => { it('errors when no environments configured', async () => { await expect(runEnvRemove('anything')).rejects.toThrow(CliExit); }); + + it('warns that removal is local-only for an ordinary environment', async () => { + await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' }); + await runEnvRemove('prod'); + const warnMsg = vi.mocked(clack.log.warn).mock.calls.map((c) => String(c[0])).join('\n'); + expect(warnMsg).toMatch(/local/i); + // Ordinary env: must NOT claim the claim token was lost. + expect(warnMsg).not.toMatch(/claim token/i); + }); + + it('warns that an unclaimed environment loses its claim token when removed', async () => { + saveConfig({ + activeEnvironment: 'unclaimed', + environments: { + unclaimed: { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_abc', + clientId: 'client_abc', + claimToken: 'tok_abc', + }, + }, + }); + await runEnvRemove('unclaimed'); + const warnMsg = vi.mocked(clack.log.warn).mock.calls.map((c) => String(c[0])).join('\n'); + expect(warnMsg).toMatch(/local/i); + expect(warnMsg).toMatch(/claim/i); + }); }); describe('runEnvSwitch', () => { @@ -259,6 +287,28 @@ describe('env commands', () => { expect(output.status).toBe('ok'); expect(output.message).toBe('Environment removed'); expect(output.data.name).toBe('prod'); + expect(output.data.localOnly).toBe(true); + expect(output.data.wasUnclaimed).toBe(false); + }); + + it('runEnvRemove reports wasUnclaimed for an unclaimed env in JSON', async () => { + saveConfig({ + activeEnvironment: 'unclaimed', + environments: { + unclaimed: { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_abc', + clientId: 'client_abc', + claimToken: 'tok_abc', + }, + }, + }); + consoleOutput = []; + await runEnvRemove('unclaimed'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data.localOnly).toBe(true); + expect(output.data.wasUnclaimed).toBe(true); }); it('runEnvSwitch outputs JSON success', async () => { @@ -309,4 +359,74 @@ describe('env commands', () => { expect(output.data).toEqual([]); }); }); + + describe('command hints route through formatWorkOSCommand (npx vs bare)', () => { + // getWorkOSCommand reads all three of these; clear/set them deterministically. + const NPM_KEYS = ['npm_command', 'npm_execpath', 'npm_config_user_agent'] as const; + let saved: Record; + + beforeEach(() => { + saved = {}; + for (const k of NPM_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + }); + + afterEach(() => { + for (const k of NPM_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + }); + + it('runEnvList empty hint uses the bare command when not launched via npx', async () => { + await runEnvList(); + expect(clack.log.info).toHaveBeenCalledWith(expect.stringContaining('workos env add')); + expect(clack.log.info).not.toHaveBeenCalledWith(expect.stringContaining('npx workos@latest')); + }); + + it('runEnvList empty hint uses npx form when launched via npm exec', async () => { + process.env.npm_command = 'exec'; + await runEnvList(); + expect(clack.log.info).toHaveBeenCalledWith(expect.stringContaining('npx workos@latest env add')); + }); + + it('unclaimed-table footer uses npx form when launched via npm exec', async () => { + process.env.npm_command = 'exec'; + saveConfig({ + activeEnvironment: 'unclaimed', + environments: { + unclaimed: { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_abc', + clientId: 'client_abc', + claimToken: 'tok_abc', + }, + }, + }); + const out: string[] = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + out.push(args.map(String).join(' ')); + }); + await runEnvList(); + expect(out.join('\n')).toContain('npx workos@latest env claim'); + }); + + it('runEnvSwitch no-envs JSON error carries npx form', async () => { + process.env.npm_command = 'exec'; + setOutputMode('json'); + setInteractionMode({ mode: 'agent', source: 'env' }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await expect(runEnvSwitch('anything')).rejects.toThrow(CliExit); + const parsed = JSON.parse(String(errorSpy.mock.calls[0][0])); + expect(parsed.error.message).toContain('npx workos@latest env add'); + } finally { + errorSpy.mockRestore(); + setOutputMode('human'); + } + }); + }); }); diff --git a/src/commands/env.ts b/src/commands/env.ts index 9613b5a4..0da28d69 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -124,7 +124,7 @@ export async function runEnvRemove(name: string): Promise { if (!config || Object.keys(config.environments).length === 0) { exitWithError({ code: 'no_environments', - message: 'No environments configured. Run `workos env add` to get started.', + message: `No environments configured. Run \`${formatWorkOSCommand('env add')}\` to get started.`, }); } @@ -133,8 +133,20 @@ export async function runEnvRemove(name: string): Promise { exitWithError({ code: 'not_found', message: `Environment "${name}" not found. Available: ${available}` }); } + // Capture the claim risk BEFORE deleting — an unclaimed env's claim token lives + // only in this local config, so removing it permanently loses the ability to claim. + const wasUnclaimed = isUnclaimedEnvironment(config.environments[name]); + delete config.environments[name]; + if (!isJsonMode()) { + clack.log.warn( + wasUnclaimed + ? `Removed only the local CLI config for "${name}". This environment was unclaimed — its claim token lived only here, so it can no longer be claimed.` + : `Removed only the local CLI config for "${name}". The environment still exists in WorkOS.`, + ); + } + if (config.activeEnvironment === name) { const remaining = Object.keys(config.environments); config.activeEnvironment = remaining.length > 0 ? remaining[0] : undefined; @@ -144,7 +156,12 @@ export async function runEnvRemove(name: string): Promise { } saveConfig(config); - outputSuccess('Environment removed', { name, newActive: config.activeEnvironment ?? null }); + outputSuccess('Environment removed', { + name, + newActive: config.activeEnvironment ?? null, + localOnly: true, + wasUnclaimed, + }); } export async function runEnvSwitch(name?: string): Promise { @@ -152,7 +169,7 @@ export async function runEnvSwitch(name?: string): Promise { if (!config || Object.keys(config.environments).length === 0) { exitWithError({ code: 'no_environments', - message: 'No environments configured. Run `workos env add` to get started.', + message: `No environments configured. Run \`${formatWorkOSCommand('env add')}\` to get started.`, }); } @@ -201,7 +218,7 @@ export async function runEnvList(): Promise { if (isJsonMode()) { outputJson({ data: [] }); } else { - clack.log.info('No environments configured. Run `workos env add` to get started.'); + clack.log.info(`No environments configured. Run \`${formatWorkOSCommand('env add')}\` to get started.`); } return; } @@ -253,6 +270,6 @@ export async function runEnvList(): Promise { if (hasUnclaimed) { console.log(''); - console.log(chalk.dim(' Run `workos env claim` to keep this environment.')); + console.log(chalk.dim(` Run \`${formatWorkOSCommand('env claim')}\` to keep this environment.`)); } } diff --git a/src/commands/login.ts b/src/commands/login.ts index 998d8406..d90e2e4b 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -180,7 +180,7 @@ export async function runLogin(): Promise { if (provisioned) { clack.log.success('Staging environment configured automatically'); } else { - clack.log.info(chalk.dim('Run `workos env add` to configure an environment manually')); + clack.log.info(chalk.dim(`Run \`${formatWorkOSCommand('env add')}\` to configure an environment manually`)); } await installSkillsAfterLogin(); diff --git a/src/commands/migrations.spec.ts b/src/commands/migrations.spec.ts index 1dbc873f..9e064dc2 100644 --- a/src/commands/migrations.spec.ts +++ b/src/commands/migrations.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; const mockParseAsync = vi.fn(); const mockName = vi.fn(); @@ -43,6 +43,37 @@ describe('runMigrations', () => { expect(mockParseAsync).toHaveBeenCalledWith(args, { from: 'user' }); }); + describe('program name routes through getWorkOSCommand', () => { + const NPM_KEYS = ['npm_command', 'npm_execpath', 'npm_config_user_agent'] as const; + let saved: Record; + + beforeEach(() => { + saved = {}; + for (const k of NPM_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + }); + + afterEach(() => { + for (const k of NPM_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + }); + + it('uses the bare command name when not launched via npx', async () => { + await runMigrations(['wizard']); + expect(mockName).toHaveBeenCalledWith('workos migrations'); + }); + + it('uses the npx command name when launched via npm exec', async () => { + process.env.npm_command = 'exec'; + await runMigrations(['wizard']); + expect(mockName).toHaveBeenCalledWith('npx workos@latest migrations'); + }); + }); + it('sets WORKOS_API_URL when apiBaseUrl is provided', async () => { await runMigrations(['import', '--csv', 'users.csv'], 'sk_test_123', 'https://api.staging.workos.com'); expect(process.env.WORKOS_API_URL).toBe('https://api.staging.workos.com'); diff --git a/src/commands/migrations.ts b/src/commands/migrations.ts index 9e39e58b..e84742c5 100644 --- a/src/commands/migrations.ts +++ b/src/commands/migrations.ts @@ -1,3 +1,5 @@ +import { getWorkOSCommand } from '../utils/command-invocation.js'; + const workosOnlyMigrationsFlags = new Map([ ['--api-key', true], ['--insecure-storage', false], @@ -58,6 +60,6 @@ export async function runMigrations(args: string[], apiKey?: string, apiBaseUrl? }; }; - program.name('workos migrations'); + program.name(`${getWorkOSCommand()} migrations`); await program.parseAsync(args, { from: 'user' }); } diff --git a/src/commands/seed.spec.ts b/src/commands/seed.spec.ts index 0fc8fca2..59110916 100644 --- a/src/commands/seed.spec.ts +++ b/src/commands/seed.spec.ts @@ -285,6 +285,64 @@ config: }); }); + describe('command hints route through formatWorkOSCommand', () => { + const NPM_KEYS = ['npm_command', 'npm_execpath', 'npm_config_user_agent'] as const; + let saved: Record; + + beforeEach(() => { + saved = {}; + for (const k of NPM_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + process.env.npm_command = 'exec'; + setOutputMode('json'); + }); + + afterEach(() => { + setOutputMode('human'); + for (const k of NPM_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + }); + + function lastErrorMessage(): string { + const line = [...consoleErrors].reverse().find((l) => { + try { + return typeof (JSON.parse(l) as { error?: { message?: string } }).error?.message === 'string'; + } catch { + return false; + } + }); + expect(line).toBeDefined(); + return (JSON.parse(line!) as { error: { message: string } }).error.message; + } + + it('missing --file error carries the npx seed hints', async () => { + await expect(runSeed({}, 'sk_test')).rejects.toThrow(CliExit); + const message = lastErrorMessage(); + expect(message).toContain('npx workos@latest seed --file=workos-seed.yml'); + expect(message).toContain('npx workos@latest seed --init'); + }); + + it('file-not-found error carries the npx seed hint', async () => { + mockExistsSync.mockReturnValue(false); + await expect(runSeed({ file: 'missing.yml' }, 'sk_test')).rejects.toThrow(CliExit); + expect(lastErrorMessage()).toContain('npx workos@latest seed'); + }); + + it('seed-failed error carries the npx seed --clean hint', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(FULL_SEED_YAML); + mockSdk.authorization.createPermission.mockResolvedValue({ slug: 'read-users' }); + mockSdk.authorization.createEnvironmentRole.mockRejectedValue(new Error('Server exploded')); + + await expect(runSeed({ file: 'workos-seed.yml' }, 'sk_test')).rejects.toThrow(CliExit); + expect(lastErrorMessage()).toContain('npx workos@latest seed --clean'); + }); + }); + describe('runSeed --clean', () => { it('deletes resources in reverse order: orgs → permissions', async () => { mockExistsSync.mockReturnValue(true); diff --git a/src/commands/seed.ts b/src/commands/seed.ts index fc71599e..f0030155 100644 --- a/src/commands/seed.ts +++ b/src/commands/seed.ts @@ -4,6 +4,7 @@ import { parse as parseYaml } from 'yaml'; import type { DomainData } from '@workos-inc/node'; import { createWorkOSClient, type WorkOSCLIClient } from '../lib/workos-client.js'; import { outputJson, outputSuccess, isJsonMode, exitWithError } from '../utils/output.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; const STATE_FILE = '.workos-seed-state.json'; @@ -92,7 +93,7 @@ export function runSeedInit(): void { outputJson({ status: 'ok', message: `Created ${DEFAULT_SEED_FILE}`, file: DEFAULT_SEED_FILE }); } else { console.log(chalk.green(`Created ${DEFAULT_SEED_FILE}`)); - console.log(chalk.dim('Edit the file, then run: workos seed --file=workos-seed.yml')); + console.log(chalk.dim(`Edit the file, then run: ${formatWorkOSCommand('seed --file=workos-seed.yml')}`)); } } @@ -114,15 +115,14 @@ export async function runSeed( if (!options.file) { return exitWithError({ code: 'missing_args', - message: - 'Provide a seed file: workos seed --file=workos-seed.yml\nRun workos seed --init to create an example seed file.', + message: `Provide a seed file: ${formatWorkOSCommand('seed --file=workos-seed.yml')}\nRun ${formatWorkOSCommand('seed --init')} to create an example seed file.`, }); } if (!existsSync(options.file)) { return exitWithError({ code: 'file_not_found', - message: `Seed file not found: ${options.file}. Create workos-seed.yml or run \`workos seed\` without --file for interactive mode.`, + message: `Seed file not found: ${options.file}. Create workos-seed.yml or run \`${formatWorkOSCommand('seed')}\` without --file for interactive mode.`, }); } @@ -235,7 +235,7 @@ export async function runSeed( saveState(state); exitWithError({ code: 'seed_failed', - message: `Seed failed: ${error instanceof Error ? error.message : 'Unknown error'}. Partial state saved to ${STATE_FILE}. Run \`workos seed --clean\` to tear down.`, + message: `Seed failed: ${error instanceof Error ? error.message : 'Unknown error'}. Partial state saved to ${STATE_FILE}. Run \`${formatWorkOSCommand('seed --clean')}\` to tear down.`, details: state, }); } diff --git a/src/commands/vault-run.spec.ts b/src/commands/vault-run.spec.ts index 6edd7921..65c909a5 100644 --- a/src/commands/vault-run.spec.ts +++ b/src/commands/vault-run.spec.ts @@ -352,6 +352,39 @@ describe('vault-run', () => { expect(exitErrors[0].message).toMatch(/no-such-env/); expect(mockSpawn).not.toHaveBeenCalled(); }); + + describe('command hints route through formatWorkOSCommand', () => { + const NPM_KEYS = ['npm_command', 'npm_execpath', 'npm_config_user_agent'] as const; + let saved: Record; + + beforeEach(() => { + saved = {}; + for (const k of NPM_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + process.env.npm_command = 'exec'; + }); + + afterEach(() => { + for (const k of NPM_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + }); + + it('missing-command usage error carries the npx form', async () => { + await expect(runVaultRun({ secrets: ['DB_URL=db'], command: [] })).rejects.toThrow(/__EXIT__/); + expect(exitErrors[0].message).toContain('npx workos@latest vault run --secret ENV=name -- command'); + }); + + it('unknown-env error carries the npx env-list hint', async () => { + await expect( + runVaultRun({ secrets: ['DB_URL=db'], command: ['echo'], env: 'no-such-env' }), + ).rejects.toThrow(/__EXIT__/); + expect(exitErrors[0].message).toContain('npx workos@latest env list'); + }); + }); }); describe('JSON mode metadata on execution', () => { diff --git a/src/commands/vault-run.ts b/src/commands/vault-run.ts index 9cc890b9..d5ede416 100644 --- a/src/commands/vault-run.ts +++ b/src/commands/vault-run.ts @@ -5,6 +5,7 @@ import { createApiErrorHandler } from '../lib/api-error-handler.js'; import { isJsonMode, outputJson, exitWithError } from '../utils/output.js'; import { formatTable } from '../utils/table.js'; import { SPAWN_OPTS, IS_WINDOWS } from '../utils/platform.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; const handleApiError = createApiErrorHandler('Vault'); @@ -97,7 +98,7 @@ async function resolveRunApiKey(envName: string | undefined, flagApiKey?: string if (!env || !env.apiKey) { exitWithError({ code: 'env_not_found', - message: `Environment '${envName}' not found or has no API key. Run 'workos env list' to see available environments.`, + message: `Environment '${envName}' not found or has no API key. Run '${formatWorkOSCommand('env list')}' to see available environments.`, }); } return env.apiKey; @@ -205,7 +206,7 @@ export async function runVaultRun(options: VaultRunOptions, flagApiKey?: string) if (!options.command || options.command.length === 0) { exitWithError({ code: 'missing_command', - message: 'No command specified. Usage: workos vault run --secret ENV=name -- command', + message: `No command specified. Usage: ${formatWorkOSCommand('vault run --secret ENV=name -- command')}`, }); } diff --git a/src/doctor/issues.spec.ts b/src/doctor/issues.spec.ts index f3a9f498..33dd3b8e 100644 --- a/src/doctor/issues.spec.ts +++ b/src/doctor/issues.spec.ts @@ -1,7 +1,25 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { detectIssues } from './issues.js'; import type { DoctorReport } from './types.js'; +// getWorkOSCommand reads these; clear them so remediation copy is deterministic +// regardless of how the test runner itself was launched. +const NPM_KEYS = ['npm_command', 'npm_execpath', 'npm_config_user_agent'] as const; +let savedNpmEnv: Record; +function clearNpmEnv(): void { + savedNpmEnv = {}; + for (const k of NPM_KEYS) { + savedNpmEnv[k] = process.env[k]; + delete process.env[k]; + } +} +function restoreNpmEnv(): void { + for (const k of NPM_KEYS) { + if (savedNpmEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedNpmEnv[k]; + } +} + function baseReport(): Omit { return { version: '1.0.0', @@ -62,7 +80,51 @@ describe('detectIssues', () => { ); }); + describe('command hints in remediation route through formatWorkOSCommand', () => { + beforeEach(clearNpmEnv); + afterEach(restoreNpmEnv); + + function reportWithStaleSkills(): DoctorReport { + const report = baseReport() as DoctorReport; + report.skills = { + bundledVersion: '2.0.0', + agents: [{ agent: 'Claude Code', installedVersion: '1.0.0', stale: true }], + }; + return report; + } + + function reportWithMisconfiguredMcp(): DoctorReport { + const report = baseReport() as DoctorReport; + report.mcp = { + serverUrl: 'https://mcp.workos.com/mcp', + agents: [{ agent: 'Cursor', available: true, installed: true, misconfigured: true }], + }; + return report; + } + + it('uses bare commands when not launched via npx', () => { + const skills = detectIssues(reportWithStaleSkills()).find((i) => i.code === 'SKILLS_OUTDATED'); + expect(skills?.remediation).toBe('Run: workos skills install'); + + const mcp = detectIssues(reportWithMisconfiguredMcp()).find((i) => i.code === 'MCP_MISCONFIGURED'); + expect(mcp?.remediation).toBe('Run: workos mcp install'); + }); + + it('uses npx form when launched via npm exec', () => { + process.env.npm_command = 'exec'; + + const skills = detectIssues(reportWithStaleSkills()).find((i) => i.code === 'SKILLS_OUTDATED'); + expect(skills?.remediation).toBe('Run: npx workos@latest skills install'); + + const mcp = detectIssues(reportWithMisconfiguredMcp()).find((i) => i.code === 'MCP_MISCONFIGURED'); + expect(mcp?.remediation).toBe('Run: npx workos@latest mcp install'); + }); + }); + describe('MCP', () => { + beforeEach(clearNpmEnv); + afterEach(restoreNpmEnv); + it('adds no issue when MCP is absent or merely not installed', () => { const report = baseReport(); // No mcp field at all. diff --git a/src/doctor/issues.ts b/src/doctor/issues.ts index 6cce23be..a8b9fa3d 100644 --- a/src/doctor/issues.ts +++ b/src/doctor/issues.ts @@ -1,5 +1,6 @@ import type { Issue, DoctorReport } from './types.js'; import { getInstallHint, languageToSdkLanguage } from './checks/language.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; export const ISSUE_DEFINITIONS = { MISSING_API_KEY: { @@ -172,7 +173,7 @@ export function detectIssues(report: Omit): code: 'SKILLS_OUTDATED', severity: 'warning', message: `WorkOS skills outdated for ${agentList} — bundled: ${report.skills.bundledVersion}`, - remediation: 'Run: workos skills install', + remediation: `Run: ${formatWorkOSCommand('skills install')}`, details: { bundledVersion: report.skills.bundledVersion, stale: stale.map((a) => a.agent) }, }); } @@ -189,7 +190,7 @@ export function detectIssues(report: Omit): code: 'MCP_MISCONFIGURED', severity: 'warning', message: `WorkOS MCP server configured with an unexpected URL for ${agentList} — expected ${report.mcp.serverUrl}`, - remediation: 'Run: workos mcp install', + remediation: `Run: ${formatWorkOSCommand('mcp install')}`, details: { agents: misconfigured.map((a) => a.agent), expectedUrl: report.mcp.serverUrl }, }); } diff --git a/src/doctor/output.ts b/src/doctor/output.ts index 973b5820..296dcc8d 100644 --- a/src/doctor/output.ts +++ b/src/doctor/output.ts @@ -5,6 +5,7 @@ import type { InteractionModeSource } from '../utils/interaction-mode.js'; import type { DoctorReport, Issue } from './types.js'; import { renderSummaryBox, type SummaryBoxItem } from '../utils/summary-box.js'; import type { LockExpression } from '../utils/lock-art.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; export interface FormatOptions { verbose?: boolean; @@ -270,7 +271,7 @@ export function formatReport(report: DoctorReport, options?: FormatOptions): voi expression, title, items, - footer: 'workos doctor --copy | https://workos.com/docs', + footer: `${formatWorkOSCommand('doctor --copy')} | https://workos.com/docs`, }), ); console.log(''); diff --git a/src/lib/adapters/cli-adapter.spec.ts b/src/lib/adapters/cli-adapter.spec.ts index 7fdee71e..f863a666 100644 --- a/src/lib/adapters/cli-adapter.spec.ts +++ b/src/lib/adapters/cli-adapter.spec.ts @@ -355,4 +355,30 @@ describe('CLIAdapter', () => { } }); }); + + describe('staging success copy', () => { + it('device path announces a fresh environment without "retrieved"', async () => { + await adapter.start(); + const clack = await import('../../utils/clack.js'); + + emitter.emit('staging:fetching', {}); + emitter.emit('staging:success', { source: 'device' }); + + const calls = vi.mocked(clack.default.log.success).mock.calls.map((c) => String(c[0])); + expect(calls).toContain('Set up a WorkOS environment for this install'); + expect(calls.join('\n')).not.toMatch(/retrieved/i); + }); + + it('stored path announces reuse of the active environment', async () => { + await adapter.start(); + const clack = await import('../../utils/clack.js'); + + emitter.emit('staging:fetching', {}); + emitter.emit('staging:success', { source: 'stored' }); + + const calls = vi.mocked(clack.default.log.success).mock.calls.map((c) => String(c[0])); + expect(calls).toContain('Using your active WorkOS environment'); + expect(calls.join('\n')).not.toMatch(/retrieved/i); + }); + }); }); diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index 52a916c1..a4fd9c67 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -278,9 +278,17 @@ export class CLIAdapter implements InstallerAdapter { this.spinner.start('Fetching your WorkOS credentials...'); }; - private handleStagingSuccess = (): void => { - this.stopSpinner('Credentials fetched'); - clack.log.success('WorkOS credentials retrieved automatically'); + private handleStagingSuccess = ({ source }: InstallerEvents['staging:success']): void => { + if (source === 'device') { + this.stopSpinner('Environment ready'); + clack.log.success('Set up a WorkOS environment for this install'); + } else if (source === 'stored') { + this.stopSpinner('Using active environment'); + clack.log.success('Using your active WorkOS environment'); + } else { + this.stopSpinner('Environment ready'); + clack.log.success('Using your WorkOS environment'); + } }; private handleEnvCredentialsFound = ({ sourcePath }: InstallerEvents['credentials:env:found']): void => { diff --git a/src/lib/adapters/headless-adapter.spec.ts b/src/lib/adapters/headless-adapter.spec.ts index b8340898..15f74b35 100644 --- a/src/lib/adapters/headless-adapter.spec.ts +++ b/src/lib/adapters/headless-adapter.spec.ts @@ -441,4 +441,16 @@ describe('HeadlessAdapter', () => { await adapter.stop(); }); }); + + describe('staging events', () => { + it('forwards the staging:success source onto the NDJSON stream', async () => { + const adapter = createAdapter(); + await adapter.start(); + + emitter.emit('staging:success', { source: 'stored' }); + + expect(mockWriteNDJSON).toHaveBeenCalledWith({ type: 'staging:success', source: 'stored' }); + await adapter.stop(); + }); + }); }); diff --git a/src/lib/adapters/headless-adapter.ts b/src/lib/adapters/headless-adapter.ts index b40c6bbf..5641124c 100644 --- a/src/lib/adapters/headless-adapter.ts +++ b/src/lib/adapters/headless-adapter.ts @@ -264,8 +264,8 @@ export class HeadlessAdapter implements InstallerAdapter { writeNDJSON({ type: 'staging:fetching' }); }; - private handleStagingSuccess = (): void => { - writeNDJSON({ type: 'staging:success' }); + private handleStagingSuccess = ({ source }: InstallerEvents['staging:success']): void => { + writeNDJSON({ type: 'staging:success', source }); }; // ===== Config ===== diff --git a/src/lib/api-key.ts b/src/lib/api-key.ts index d8135b86..decdebb4 100644 --- a/src/lib/api-key.ts +++ b/src/lib/api-key.ts @@ -9,6 +9,7 @@ import { getActiveEnvironment } from './config-store.js'; import { exitWithError } from '../utils/output.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; const DEFAULT_BASE_URL = 'https://api.workos.com'; @@ -22,7 +23,7 @@ export function resolveApiKey(options?: ApiKeyOptions): string { exitWithError({ code: 'no_api_key', - message: 'No API key configured. Run `workos env add` to configure an environment, or set WORKOS_API_KEY.', + message: `No API key configured. Run \`${formatWorkOSCommand('env add')}\` to configure an environment, or set WORKOS_API_KEY.`, }); } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 0163dee4..12efb8bc 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -46,6 +46,16 @@ export const OAUTH_PORT = settings.legacy.oauthPort; export const MCP_SERVER_NAME = 'workos'; export const MCP_SERVER_URL = 'https://mcp.workos.com/mcp'; +/** + * Shared description for the `migrations` command, referenced by both the yargs + * registration (`bin.ts`) and the JSON help registry (`help-json.ts`) so the two + * surfaces cannot drift. Advertises the generic-CSV path (e.g. Supabase) without + * implying a dedicated exporter exists for it. Hand-maintained — not derived from + * `@workos/migrations`. + */ +export const MIGRATIONS_DESCRIPTION = + 'Migrate users to WorkOS from Auth0, Cognito, Clerk, Firebase, or any provider via CSV (e.g. Supabase)'; + /** * Common glob patterns to ignore when searching for files. * Used by multiple integrations. diff --git a/src/lib/events.ts b/src/lib/events.ts index 9b438e77..86358e17 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -71,7 +71,7 @@ export interface InstallerEvents { 'device:error': { message: string }; // Staging API events 'staging:fetching': Record; - 'staging:success': Record; + 'staging:success': { source?: 'device' | 'stored' }; 'staging:error': { message: string; statusCode?: number }; 'config:start': Record; 'config:complete': Record; diff --git a/src/lib/installer-core.ts b/src/lib/installer-core.ts index 7bc41430..59d97d34 100644 --- a/src/lib/installer-core.ts +++ b/src/lib/installer-core.ts @@ -18,6 +18,7 @@ import type { DeviceAuthResult, DeviceAuthResponse } from './device-auth.js'; import type { StagingCredentials } from './staging-api.js'; import { getManualPrInstructions } from './post-install.js'; import { hasGhCli } from '../utils/git-utils.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; export const installerMachine = setup({ types: { @@ -165,7 +166,7 @@ export const installerMachine = setup({ context.emitter.emit('staging:fetching', {}); }, emitStagingSuccess: ({ context }) => { - context.emitter.emit('staging:success', {}); + context.emitter.emit('staging:success', { source: context.deviceAuth ? 'device' : 'stored' }); }, emitStagingError: ({ context }) => { const message = context.error?.message ?? 'Failed to fetch staging credentials'; @@ -711,7 +712,7 @@ export const installerMachine = setup({ `Because this directory isn't empty, a new app wasn't scaffolded, and no recognized ` + `framework (such as a package.json with Next.js) was found to install into.\n\n` + `Next steps:\n` + - ` - New app: run \`workos install\` in an empty directory to scaffold Next.js + AuthKit.\n` + + ` - New app: run \`${formatWorkOSCommand('install')}\` in an empty directory to scaffold Next.js + AuthKit.\n` + ` - Existing project: run from the directory that contains your package.json, or pass --install-dir .`, ); }, diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 5d2f33b6..478a4052 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -339,6 +339,7 @@ export async function runWithCore(options: InstallerOptions): Promise { ...installerOptions, apiKey: credentials?.apiKey, clientId: credentials?.clientId, + credentialSource: context.credentialSource, emitter: context.emitter, }; const summary = await runIntegrationInstallerFn(integration, agentOptions); diff --git a/src/lib/unclaimed-env-provision.ts b/src/lib/unclaimed-env-provision.ts index e3149e6e..e1460b0f 100644 --- a/src/lib/unclaimed-env-provision.ts +++ b/src/lib/unclaimed-env-provision.ts @@ -14,6 +14,7 @@ import { writeCredentialsEnv } from './env-writer.js'; import { logInfo, logError } from '../utils/debug.js'; import { renderStderrBox } from '../utils/box.js'; import clack from '../utils/clack.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; export interface UnclaimedEnvProvisionOptions { installDir: string; @@ -73,7 +74,7 @@ export async function tryProvisionUnclaimedEnv(options: UnclaimedEnvProvisionOpt } logInfo('[unclaimed-env-provision] Unclaimed environment provisioned and saved'); - const inner = ` ✓ ${chalk.green('Environment provisioned')} — Run ${chalk.cyan('workos env claim')} to keep it. `; + const inner = ` ✓ ${chalk.green('Environment provisioned')} — Run ${chalk.cyan(formatWorkOSCommand('env claim'))} to keep it. `; renderStderrBox(inner, chalk.green); return true; diff --git a/src/lib/unclaimed-warning.ts b/src/lib/unclaimed-warning.ts index 9ce9c033..bb48f40f 100644 --- a/src/lib/unclaimed-warning.ts +++ b/src/lib/unclaimed-warning.ts @@ -14,6 +14,7 @@ import { logError, logInfo } from '../utils/debug.js'; import { isJsonMode } from '../utils/output.js'; import { renderStderrBox } from '../utils/box.js'; import { markStartupNoticeShown } from './startup-notice-gate.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; let warningShownThisSession = false; let claimCheckDoneThisSession = false; @@ -60,7 +61,7 @@ export async function warnIfUnclaimed(): Promise { warningShownThisSession = true; if (!isJsonMode()) { - const inner = ` ${chalk.yellow('⚠ Unclaimed environment')} — Run ${chalk.cyan('workos env claim')} to keep your data. `; + const inner = ` ${chalk.yellow('⚠ Unclaimed environment')} — Run ${chalk.cyan(formatWorkOSCommand('env claim'))} to keep your data. `; renderStderrBox(inner, chalk.yellow); // Claim the one-notice-per-run slot so the lower-priority MCP banner defers. markStartupNoticeShown(); diff --git a/src/lib/validation/security-checks.ts b/src/lib/validation/security-checks.ts index 5cef486a..321812f4 100644 --- a/src/lib/validation/security-checks.ts +++ b/src/lib/validation/security-checks.ts @@ -1,6 +1,7 @@ import { checkAuthPatterns } from '../../doctor/checks/auth-patterns.js'; import type { AuthPatternFinding, FrameworkInfo, EnvironmentInfo, SdkInfo } from '../../doctor/types.js'; import type { ValidationIssue } from './types.js'; +import { formatWorkOSCommand } from '../../utils/command-invocation.js'; /** * The "security subset" of `workos doctor`'s auth-pattern checks that the @@ -117,6 +118,6 @@ export function formatBlockingSecurityError(blocking: AuthPatternFinding[]): str '', ...lines, '', - 'Fix the issues above (or run `workos doctor` for details and remediation) and re-run the installer.', + `Fix the issues above (or run \`${formatWorkOSCommand('doctor')}\` for details and remediation) and re-run the installer.`, ].join('\n'); } diff --git a/src/utils/clack-utils.spec.ts b/src/utils/clack-utils.spec.ts index aca1187a..003cfe5e 100644 --- a/src/utils/clack-utils.spec.ts +++ b/src/utils/clack-utils.spec.ts @@ -32,7 +32,7 @@ const clack = (await import('./clack.js')).default; const { setInteractionMode, resetInteractionModeForTests } = await import('./interaction-mode.js'); const { CliExit } = await import('./cli-exit.js'); const { setOutputMode } = await import('./output.js'); -const { abortIfCancelled } = await import('./clack-utils.js'); +const { abortIfCancelled, getOrAskForWorkOSCredentials } = await import('./clack-utils.js'); describe('abortIfCancelled — non-interactive guard', () => { beforeEach(() => { @@ -78,3 +78,45 @@ describe('abortIfCancelled — non-interactive guard', () => { await expect(abortIfCancelled('value')).resolves.toBe('value'); }); }); + +describe('getOrAskForWorkOSCredentials — credential-source-aware copy', () => { + const base = { apiKey: 'sk_test', clientId: 'client_x', installDir: '/tmp' } as const; + + beforeEach(() => { + setOutputMode('human'); + vi.clearAllMocks(); + }); + + afterEach(() => { + setOutputMode('human'); + }); + + it('announces "you provided" for cli/manual/undefined sources', async () => { + for (const credentialSource of ['cli', 'manual', undefined] as const) { + vi.clearAllMocks(); + const result = await getOrAskForWorkOSCredentials({ ...base, credentialSource }); + expect(result).toEqual({ apiKey: 'sk_test', clientId: 'client_x' }); + expect(clack.log.info).toHaveBeenCalledTimes(1); + expect(clack.log.info).toHaveBeenCalledWith('Using the WorkOS credentials you provided'); + } + }); + + it('stays silent for device/stored/env sources (machine already announced)', async () => { + for (const credentialSource of ['device', 'stored', 'env'] as const) { + vi.clearAllMocks(); + await getOrAskForWorkOSCredentials({ ...base, credentialSource }); + expect(clack.log.info).not.toHaveBeenCalled(); + } + }); + + it('stays silent in dashboard mode', async () => { + await getOrAskForWorkOSCredentials({ ...base, dashboard: true, credentialSource: 'cli' }); + expect(clack.log.info).not.toHaveBeenCalled(); + }); + + it('stays silent in JSON output mode (no human copy into JSON)', async () => { + setOutputMode('json'); + await getOrAskForWorkOSCredentials({ ...base, credentialSource: 'cli' }); + expect(clack.log.info).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/clack-utils.ts b/src/utils/clack-utils.ts index 6cb1d3a8..ec91a73b 100644 --- a/src/utils/clack-utils.ts +++ b/src/utils/clack-utils.ts @@ -18,7 +18,7 @@ import clack from './clack.js'; import { INTEGRATION_CONFIG } from '../lib/config.js'; import { SPAWN_OPTS } from './platform.js'; import { isPromptAllowed } from './interaction-mode.js'; -import { exitWithError } from './output.js'; +import { exitWithError, isJsonMode } from './output.js'; /** * Redact sensitive info (API keys, client secrets) from a string. @@ -546,7 +546,7 @@ export function isUsingTypeScript({ installDir }: Pick, + _options: Pick, requireApiKey: boolean = true, ): Promise<{ apiKey: string; @@ -557,9 +557,15 @@ export async function getOrAskForWorkOSCredentials( // If credentials provided via CLI (e.g., CI mode or dashboard mode), use them if ((!requireApiKey || apiKey) && clientId) { - // Only log in non-dashboard mode - if (!_options.dashboard) { - clack.log.info('Using provided WorkOS credentials'); + // Say "you provided" only when the user actually supplied credentials + // (cli/manual, or an unknown source). For device/stored/env the state + // machine already announced the source accurately before this runs, so + // stay silent rather than double-announce. Gate on human output mode so + // this never pollutes JSON/NDJSON. + const source = _options.credentialSource; + const userProvided = source === 'cli' || source === 'manual' || source === undefined; + if (!_options.dashboard && !isJsonMode() && userProvided) { + clack.log.info('Using the WorkOS credentials you provided'); } return { apiKey: apiKey || '', clientId }; } diff --git a/src/utils/command-hints-guard.spec.ts b/src/utils/command-hints-guard.spec.ts new file mode 100644 index 00000000..f006b29b --- /dev/null +++ b/src/utils/command-hints-guard.spec.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { readFile } from 'node:fs/promises'; +import { relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fg from 'fast-glob'; + +// Resolve the src/ root from this file's location (src/utils/*.spec.ts). +const SRC_DIR = fileURLToPath(new URL('..', import.meta.url)); + +// Lowercase `workos` only — excludes prose like "WorkOS AuthKit" / "WorkOS Dashboard". +// NOTE: this alternation must track the top-level command families registered in +// bin.ts. When a new command family is added (PRs 5/6 etc.), add it here so its +// hardcoded hints are caught, and route the hint through formatWorkOSCommand(). +const HINT_RE = + /\bworkos (?:auth|env|config|telemetry|doctor|install|uninstall|vault|api|seed|mcp|skills|debug|organization|org|user|migrations|login|logout|whoami|dev)\b/g; + +// Strip block comments then line comments (the (?` prefix). Every entry is a reviewed decision, not a live hint. +const ALLOWLIST = new Set([ + 'bin.ts :: workos api', // yargs .example() help (static --help documentation) + 'utils/help-json.ts :: workos api', // examples array (mirrors bin.ts) + 'commands/seed.ts :: workos seed', // SEED_TEMPLATE persisted-file comment + 'emulate/workos/index.ts :: workos seed', // prose error label, not a runnable command +]); + +async function discover(): Promise> { + const files = await fg('**/*.ts', { + cwd: SRC_DIR, + absolute: true, + ignore: ['**/*.spec.ts', '**/*.d.ts', 'utils/command-invocation.ts'], + }); + const found = new Set(); + for (const file of files) { + const rel = relative(SRC_DIR, file); + const stripped = stripComments(await readFile(file, 'utf-8')); + for (const line of stripped.split('\n')) { + if (line.includes('formatWorkOSCommand') || line.includes('getWorkOSCommand')) continue; + for (const m of line.matchAll(HINT_RE)) found.add(`${rel} :: ${m[0]}`); + } + } + return found; +} + +describe('command hints route through formatWorkOSCommand', () => { + it('has no hardcoded `workos ` hint outside the allowlist', async () => { + const discovered = await discover(); + const offenders = [...discovered].filter((d) => !ALLOWLIST.has(d)).sort(); + expect( + offenders, + `Hardcoded command hint(s) found. Route through formatWorkOSCommand()/getWorkOSCommand(), ` + + `or add to ALLOWLIST if intentionally static: ${offenders.join(', ')}`, + ).toEqual([]); + }); +}); diff --git a/src/utils/help-json.spec.ts b/src/utils/help-json.spec.ts index 6362eae0..6a58c81a 100644 --- a/src/utils/help-json.spec.ts +++ b/src/utils/help-json.spec.ts @@ -1,8 +1,9 @@ import { describe, it, expect, vi } from 'vitest'; -vi.mock('../lib/settings.js', () => ({ - getVersion: vi.fn(() => '0.7.3'), -})); +vi.mock('../lib/settings.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getVersion: vi.fn(() => '0.7.3') }; +}); const { buildCommandTree, extractHelpJsonCommand } = await import('./help-json.js'); @@ -124,6 +125,36 @@ describe('help-json', () => { }); }); + describe('accurate command copy', () => { + it('migrations description advertises the generic-CSV / Supabase path', () => { + const tree = buildCommandTree('migrations'); + expect(tree.name).toBe('migrations'); + expect(tree.description).toMatch(/CSV/i); + expect(tree.description).toMatch(/Supabase/i); + }); + + it('migrations subcommands include the discoverable export entry point', () => { + const tree = buildCommandTree('migrations'); + const subNames = tree.commands!.map((c) => c.name); + expect(subNames).toContain('export'); + // process-roles is the name the package actually registers (not process-role-definitions) + expect(subNames).toContain('process-roles'); + expect(subNames).not.toContain('process-role-definitions'); + }); + + it('env remove description states it is local-only', () => { + const env = buildCommandTree('env'); + const remove = env.commands!.find((c) => c.name === 'remove'); + expect(remove!.description).toMatch(/local/i); + }); + + it('env claim description states the action is permanent', () => { + const env = buildCommandTree('env'); + const claim = env.commands!.find((c) => c.name === 'claim'); + expect(claim!.description).toMatch(/permanent/i); + }); + }); + describe('positional schemas', () => { it('env add has optional positionals', () => { const env = buildCommandTree('env'); diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index bd962670..b349d052 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -8,6 +8,7 @@ import { getVersion } from '../lib/settings.js'; import { COMMAND_ALIASES } from '../lib/command-aliases.js'; +import { MIGRATIONS_DESCRIPTION } from '../lib/constants.js'; export interface OptionSchema { name: string; @@ -313,7 +314,7 @@ const commands: CommandSchema[] = [ }, { name: 'remove', - description: 'Remove an environment configuration', + description: 'Remove an environment from local CLI config (does not delete or unclaim the environment in WorkOS)', positionals: [{ name: 'name', type: 'string', description: 'Environment name to remove', required: true }], }, { @@ -327,7 +328,7 @@ const commands: CommandSchema[] = [ }, { name: 'claim', - description: 'Claim an unclaimed WorkOS environment (link it to your account)', + description: 'Claim an unclaimed WorkOS environment — link it to your account (permanent — cannot be undone)', options: [ { name: 'json', @@ -1417,12 +1418,19 @@ const commands: CommandSchema[] = [ }, { name: 'migrations', - description: 'Migrate users from identity providers (Auth0, Cognito, Clerk, Firebase) to WorkOS', + description: MIGRATIONS_DESCRIPTION, options: [insecureStorageOpt, apiKeyOpt], commands: [ + { + name: 'export', + description: 'Export identity data from a source provider (or any provider via CSV, e.g. Supabase) into a WorkOS migration package', + }, + { name: 'export-template', description: 'Export a blank CSV template with headers and example rows' }, { name: 'import', description: 'Import users from CSV into WorkOS' }, { name: 'import-package', description: 'Import a migration package directory' }, + { name: 'generate-package-template', description: 'Generate an empty migration package skeleton for manual or scripted population' }, { name: 'validate', description: 'Validate a WorkOS migration CSV file' }, + { name: 'validate-package', description: 'Validate a migration package directory against the schema contract' }, { name: 'export-auth0', description: 'Export users from Auth0' }, { name: 'export-cognito', description: 'Export users from AWS Cognito' }, { name: 'merge-passwords', description: 'Merge Auth0 password exports into CSV' }, @@ -1430,7 +1438,7 @@ const commands: CommandSchema[] = [ { name: 'transform-firebase', description: 'Transform Firebase JSON to WorkOS format' }, { name: 'analyze', description: 'Analyze import errors and generate retry plan' }, { name: 'enroll-totp', description: 'Enroll TOTP MFA factors' }, - { name: 'process-role-definitions', description: 'Create roles and assign in WorkOS' }, + { name: 'process-roles', description: 'Create roles and assign permissions in WorkOS' }, { name: 'wizard', description: 'Guided interactive migration wizard' }, ], }, diff --git a/src/utils/types.ts b/src/utils/types.ts index b4c1099b..6ed7f00c 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1,3 +1,5 @@ +import type { CredentialSource } from '../lib/installer-core.types.js'; + export type InstallerOptions = { /** * Whether to enable debug mode. @@ -44,6 +46,14 @@ export type InstallerOptions = { */ clientId?: string; + /** + * How the WorkOS credentials were resolved by the state machine + * (`cli`|`env`|`stored`|`device`|`manual`). Threaded downstream so + * user-facing copy can be source-accurate (e.g. avoid claiming the user + * "provided" credentials on the auto-provisioned path). + */ + credentialSource?: CredentialSource; + /** * App homepage URL for WorkOS dashboard config. * Defaults to http://localhost:{detected_port}