Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -619,7 +621,7 @@ async function runCli(): Promise<void> {
registerSubcommand(
yargs,
'remove <name>',
'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);
Expand All @@ -636,7 +638,7 @@ async function runCli(): Promise<void> {
if (!argv.name && !isPromptAllowed()) {
exitWithError({
code: 'missing_args',
message: 'Environment name required. Usage: workos env switch <name>',
message: `Environment name required. Usage: ${formatWorkOSCommand('env switch <name>')}`,
});
}
await applyInsecureStorage(argv.insecureStorage);
Expand All @@ -658,7 +660,7 @@ async function runCli(): Promise<void> {
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);
Expand Down Expand Up @@ -2476,7 +2478,7 @@ async function runCli(): Promise<void> {
// 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,
Expand Down Expand Up @@ -2641,11 +2643,11 @@ async function runCli(): Promise<void> {
await runDebugToken();
},
);
return yargs.demandCommand(1, 'Run "workos debug <command>" for debug tools.').strict();
return yargs.demandCommand(1, `Run "${getWorkOSCommand()} debug <command>" for debug tools.`).strict();
})
.command(
'migrations',
'Migrate users from identity providers (Auth0, Cognito, Clerk, Firebase) to WorkOS',
MIGRATIONS_DESCRIPTION,
(yargs) =>
yargs
.strictCommands(false)
Expand Down
50 changes: 50 additions & 0 deletions src/commands/api/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined>;

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 <endpoint>');
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 <endpoint>');
});
});
});

describe('runApiLs', () => {
Expand Down
9 changes: 4 additions & 5 deletions src/commands/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -31,18 +31,17 @@ 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 <endpoint>', 'workos api ls [filter]'],
usage: [formatWorkOSCommand('api <endpoint>'), formatWorkOSCommand('api ls [filter]')],
},
});
}

if (!isPromptAllowed()) {
exitWithError({
code: 'tty_required',
message:
'Interactive API mode requires human mode. Usage: workos api <endpoint> or workos api ls [filter]. Example: workos api /user_management/users',
message: `Interactive API mode requires human mode. Usage: ${formatWorkOSCommand('api <endpoint>')} or ${formatWorkOSCommand('api ls [filter]')}. Example: ${formatWorkOSCommand('api /user_management/users')}`,
});
}

Expand Down
73 changes: 68 additions & 5 deletions src/commands/claim.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<string, string | undefined> = {};
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];
}
}
});
});
});
11 changes: 9 additions & 2 deletions src/commands/claim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,13 @@ export async function runClaim(): Promise<void> {
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;
}

Expand All @@ -71,6 +77,7 @@ export async function runClaim(): Promise<void> {
});
}

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 {
Expand Down Expand Up @@ -135,7 +142,7 @@ export async function runClaim(): Promise<void> {
}

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);
Expand Down
5 changes: 3 additions & 2 deletions src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -128,7 +129,7 @@ export async function runDev(argv: DevArgs): Promise<void> {
});
} catch {
console.error(chalk.red(`Failed to start: ${devCmd.command} ${devCmd.args.join(' ')}`));
console.error(chalk.dim('Try specifying the command explicitly: workos dev -- <your-command>'));
console.error(chalk.dim(`Try specifying the command explicitly: ${formatWorkOSCommand('dev -- <your-command>')}`));
await emulator.close();
exitWithCode(ExitCode.GENERAL_ERROR);
}
Expand All @@ -137,7 +138,7 @@ export async function runDev(argv: DevArgs): Promise<void> {
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 -- <your-command>'));
console.error(chalk.dim(`Try specifying the command explicitly: ${formatWorkOSCommand('dev -- <your-command>')}`));
} else {
console.error(chalk.dim(err.message));
}
Expand Down
Loading
Loading