Skip to content
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ WorkOS CLI for installing AuthKit integrations and managing WorkOS resources (or
- **Auth**: Exits code 4 instead of opening browser. Requires prior `workos auth login` or `WORKOS_API_KEY` env var.
- **Errors**: Structured JSON to stderr: `{ "error": { "code": "...", "message": "..." } }`
- **Exit codes**: 0=success, 1=error, 2=cancelled, 4=auth required (follows `gh` CLI convention)
- **Headless flags**: `--no-branch`, `--no-commit`, `--create-pr`, `--no-git-check`
- **Headless flags**: `--no-branch`, `--no-commit`, `--create-pr`, `--no-git-check`. CI mode (`WORKOS_MODE=ci`) auto-continues past a dirty tree without `--no-git-check`; agent mode requires the flag.

## Tech Constraints

Expand Down
115 changes: 115 additions & 0 deletions src/bin-default-command.integration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

/**
* Integration test for the fixed `$0` default-command output.
*
* bin.ts runs runCli() at import and exposes no seams, so the only honest way
* to prove the friction fix is to drive the real CLI as a subprocess and read
* its streams. Pre-fix, a bare non-TTY invocation ran `yargs(rawArgs).showHelp()`
* on a FRESH yargs instance (zero commands registered), emitting a degenerate
* two-line `--help`/`--version` block to stderr and leaving stdout empty — a
* piped agent/CI consumer got nothing and never discovered `install`.
*
* We use a LOCAL spawn helper (not the shared one in
* bin-command-telemetry.integration.spec.ts) because that one hardcodes
* WORKOS_MODE=agent, which the CI and human-force-TTY cases must omit. The env
* is built clean (spawnSync replaces, not merges) so host agent/CI markers are
* absent unless a case adds them.
*/
const binPath = fileURLToPath(new URL('./bin.ts', import.meta.url));
const forceInsecureStorageImport = new URL('./test/force-insecure-storage.ts', import.meta.url).href;
const repoRoot = fileURLToPath(new URL('..', import.meta.url));

let sandboxTmp: string;

beforeEach(() => {
sandboxTmp = mkdtempSync(join(tmpdir(), 'wos-cli-default-it-'));
});

afterEach(() => {
rmSync(sandboxTmp, { recursive: true, force: true });
});

function runCli(args: string[], envOverrides: NodeJS.ProcessEnv = {}) {
const env: NodeJS.ProcessEnv = {
PATH: process.env.PATH,
HOME: sandboxTmp,
USERPROFILE: sandboxTmp,
TMPDIR: sandboxTmp,
TMP: sandboxTmp,
TEMP: sandboxTmp,
// Keep machine streams clean: no telemetry, no update check network calls.
WORKOS_TELEMETRY: 'false',
// Unroutable API base (defense-in-depth; the $0 path never hits the network).
WORKOS_API_URL: 'http://127.0.0.1:59999',
// Per-case env last. Agent/CI markers are absent unless a case adds one.
...envOverrides,
};

return spawnSync(process.execPath, ['--import', 'tsx', '--import', forceInsecureStorageImport, binPath, ...args], {
cwd: repoRoot,
encoding: 'utf-8',
env,
});
}

describe('bin $0 default command (non-TTY)', () => {
it('agent mode emits the full machine-readable command tree on stdout', () => {
const result = runCli([], { WORKOS_MODE: 'agent' });

expect(result.status).toBe(0);

const tree = JSON.parse(result.stdout.trim());
expect(tree.name).toBe('workos');
expect(tree.version).toBeTruthy();

const names = tree.commands.map((c: { name: string }) => c.name);
expect(names).toEqual(expect.arrayContaining(['install', 'auth login', 'organization', 'doctor']));
// The installer must be discoverable — this is the friction being fixed.
expect(tree.commands.some((c: { name: string }) => c.name === 'install')).toBe(true);

// Regression guard: never emit the degenerate fresh-yargs help block.
expect(result.stderr).not.toContain('Show version number');
}, 20_000);

it('CI mode (CI=1, no WORKOS_MODE) emits the command tree on stdout', () => {
const result = runCli([], { CI: '1' });

expect(result.status).toBe(0);

const tree = JSON.parse(result.stdout.trim());
expect(tree.name).toBe('workos');
expect(tree.version).toBeTruthy();

const names = tree.commands.map((c: { name: string }) => c.name);
expect(names).toEqual(expect.arrayContaining(['install', 'auth login', 'organization', 'doctor']));
expect(result.stderr).not.toContain('Show version number');
}, 20_000);

it('human non-TTY edge (WORKOS_FORCE_TTY=1) shows the configured help, not JSON', () => {
// WORKOS_MODE omitted entirely (never set to '' — that throws InvalidInteractionModeError).
const result = runCli([], { WORKOS_FORCE_TTY: '1' });

expect(result.status).toBe(0);

const combined = `${result.stdout}\n${result.stderr}`;
// parser.showHelp() (not the fresh-yargs block) lists the full command set.
expect(combined).toMatch(/install/);
expect(combined).toMatch(/organization/);
expect(combined).toMatch(/auth/);
}, 20_000);

it('--help --json still returns the command tree (regression for the intercept)', () => {
const result = runCli(['--help', '--json'], { WORKOS_MODE: 'agent' });

expect(result.status).toBe(0);
const parsed = JSON.parse(result.stdout.trim());
expect(Array.isArray(parsed.commands)).toBe(true);
expect(parsed.commands.length).toBeGreaterThan(0);
}, 20_000);
});
83 changes: 75 additions & 8 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ import { CliExit } from './utils/cli-exit.js';
import { telemetryClient } from './utils/telemetry-client.js';
import { ExitCode } from './utils/exit-codes.js';
import { analytics } from './utils/analytics.js';
import { formatWorkOSCommand, getWorkOSCommand } from './utils/command-invocation.js';
import { MIGRATIONS_DESCRIPTION } from './lib/constants.js';
// Type-only import (erased at build, does not pull the handler into the startup
// path) so the `argv.method as VerifyLoginMethod` cast type-checks below.
import type { VerifyLoginMethod } from './commands/verify-login.js';

// Enable debug logging for all commands via env var.
// Subsumes the installer's --debug flag for non-installer commands.
Expand Down Expand Up @@ -241,6 +246,11 @@ const installerOptions = {
choices: ['npm', 'pnpm', 'yarn', 'bun'] as const,
type: 'string' as const,
},
router: {
choices: ['app', 'pages'] as const,
describe: 'Next.js router to target when detection is ambiguous (app or pages)',
type: 'string' as const,
},
};

// Check for updates (blocks up to 500ms, skip in JSON/non-human modes to keep machine streams clean)
Expand Down Expand Up @@ -587,6 +597,46 @@ async function runCli(): Promise<void> {
await handleDoctor(argv);
},
)
.command(
'verify-login',
'Verify the AuthKit login loop end-to-end against the active environment (creates and deletes a throwaway user)',
(yargs) =>
yargs.options({
...insecureStorageOption,
'api-key': {
type: 'string' as const,
describe: 'WorkOS API key (overrides environment config). Format: sk_test_* (production keys are refused)',
},
'client-id': {
type: 'string' as const,
describe: 'WorkOS client ID (overrides the active environment)',
},
method: {
type: 'string' as const,
choices: ['password'] as const,
default: 'password',
describe: 'Authentication method to verify',
},
}),
async (argv) => {
await applyInsecureStorage(argv.insecureStorage as boolean | undefined);
const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js');
const { getActiveEnvironment } = await import('./lib/config-store.js');
const { runVerifyLogin } = await import('./commands/verify-login.js');

const apiKey = resolveApiKey({ apiKey: argv.apiKey as string | undefined }); // exits 4 if none
const activeEnv = getActiveEnvironment();

await runVerifyLogin({
apiKey,
clientId: (argv.clientId as string | undefined) ?? activeEnv?.clientId,
baseUrl: resolveApiBaseUrl(),
envType: activeEnv?.type ?? null,
envName: activeEnv?.name,
method: argv.method as VerifyLoginMethod,
});
},
)
// NOTE: When adding commands here, also update src/utils/help-json.ts
.command('env', 'Manage environment configurations (API keys, endpoints, active environment)', (yargs) => {
yargs.options(insecureStorageOption);
Expand Down Expand Up @@ -614,7 +664,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 @@ -631,7 +681,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 @@ -653,14 +703,25 @@ 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);
const { runClaim } = await import('./commands/claim.js');
await runClaim();
},
);
registerSubcommand(
yargs,
'provision',
'Provision a new unclaimed WorkOS environment (credentials only, no code changes)',
(y) => y,
async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
const { runEnvProvision } = await import('./commands/env.js');
await runEnvProvision();
},
);
return yargs.demandCommand(1, 'Please specify an env subcommand').strict();
})
.command(
Expand Down Expand Up @@ -2471,7 +2532,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 @@ -2636,11 +2697,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 Expand Up @@ -2677,9 +2738,15 @@ async function runCli(): Promise<void> {
'WorkOS AuthKit CLI',
(yargs) => yargs.options(insecureStorageOption),
async (argv) => {
// Non-human modes: show help instead of prompting
// Non-human modes: emit machine-readable command tree (JSON) or the
// fully-configured parser help (human non-TTY edge) instead of prompting.
if (!isPromptAllowed()) {
yargs(rawArgs).showHelp();
if (isJsonMode()) {
const { buildCommandTree } = await import('./utils/help-json.js');
outputJson(buildCommandTree());
} else {
parser.showHelp();
}
return;
}

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
Loading
Loading