Skip to content
Open
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
33 changes: 33 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,39 @@ pnpm typecheck # Type check
2. Register in `src/bin.ts` and update `src/utils/help-json.ts` command registry
3. Include JSON mode tests in spec file

## Telemetry Wiring for New Commands

All commands auto-emit a `command` telemetry event with name, duration, and success/failure. How you register the command determines whether this is automatic:

**Subcommands via `registerSubcommand()`** → auto-wired. Telemetry happens for free.

```typescript
.command('user', 'Manage users', (yargs) => {
registerSubcommand(yargs, 'reset-password', '...', (y) => y,
async (argv) => { await runResetPassword(argv); }, // auto-wrapped
);
})
```

**Top-level `.command()` with inline handler** → MUST manually wrap with `wrapCommandHandler()`:

```typescript
.command(
'migrate',
'Migrate from another provider',
(yargs) => yargs.options({...}),
wrapCommandHandler(async (argv) => { // <-- REQUIRED
await runMigrate(argv);
}),
)
```

If you forget `wrapCommandHandler`, the command still emits a telemetry event (queued by middleware), but duration will be `0` and success will always be `true` -- misleading data in dashboards.

**Skip list**: commands in `SKIP_TELEMETRY_COMMANDS` (`command-telemetry.ts`) are excluded from command-level telemetry because they have their own session-based telemetry. Currently: `install`, `dashboard`, `root` (the default `$0` handler). Add to this set if you're building another installer entry point.

**Aliases**: if you register a command with multiple names (e.g., `['organization', 'org']`), add the alias to `src/lib/command-aliases.ts` so metrics don't fragment across `org.list` and `organization.list`.

## Do / Don't

**Do:**
Expand Down
66 changes: 46 additions & 20 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,27 @@ import {
} from './utils/output.js';
import clack from './utils/clack.js';
import { registerSubcommand } from './utils/register-subcommand.js';
import { COMMAND_ALIASES } from './lib/command-aliases.js';
import { installCrashReporter } from './utils/crash-reporter.js';
import { installStoreForward, recoverPendingEvents } from './utils/telemetry-store-forward.js';
import { commandTelemetryMiddleware, wrapCommandHandler } from './utils/command-telemetry.js';
import { analytics } from './utils/analytics.js';

// Enable debug logging for all commands via env var.
// Subsumes the installer's --debug flag for non-installer commands.
if (process.env.WORKOS_DEBUG === '1') {
const { enableDebugLogs } = await import('./utils/debug.js');
enableDebugLogs();
}

// Telemetry infrastructure: crash reporter, store-forward, and gateway init.
// Must be before yargs so crashes during startup are captured.
installCrashReporter();
installStoreForward();
analytics.initForNonInstaller();
// Fire-and-forget: recover events from previous crashes/exits.
// NO await — must not block startup (flush timeout is 3s).
recoverPendingEvents();
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// Resolve output mode early from raw argv (before yargs parses)
const rawArgs = hideBin(process.argv);
Expand Down Expand Up @@ -210,6 +231,7 @@ yargs(rawArgs)
describe: 'Interaction mode: human, coding agent, or CI automation',
global: true,
})
.middleware(commandTelemetryMiddleware(rawArgs))
.middleware(async (argv) => {
// Warn about unclaimed environments before management commands.
// Excluded: auth/claim/install/dashboard handle their own credential flows;
Expand Down Expand Up @@ -376,10 +398,10 @@ yargs(rawArgs)
description: 'Auto-update stale WorkOS skills (writes to <agent>/skills/workos/ and workos-widgets/ only)',
},
}),
async (argv) => {
wrapCommandHandler(async (argv) => {
const { handleDoctor } = await import('./commands/doctor.js');
await handleDoctor(argv);
},
}),
)
// NOTE: When adding commands here, also update src/utils/help-json.ts
.command('env', 'Manage environment configurations (API keys, endpoints, active environment)', (yargs) => {
Expand Down Expand Up @@ -511,7 +533,7 @@ yargs(rawArgs)
.example('workos api /user_management/users', 'GET /user_management/users')
.example('workos api /organizations -d \'{"name":"Acme"}\'', 'POST with a JSON body')
.example('workos api /organizations/org_123 -X DELETE', 'DELETE an organization'),
async (argv) => {
wrapCommandHandler(async (argv) => {
await applyInsecureStorage(argv.insecureStorage as boolean | undefined);
const endpoint = argv.endpoint as string | undefined;
const filter = argv.filter as string | undefined;
Expand All @@ -537,7 +559,7 @@ yargs(rawArgs)
dryRun: argv.dryRun,
yes: argv.yes,
});
},
}),
)
.command(['organization', 'org'], 'Manage WorkOS organizations (create, update, get, list, delete)', (yargs) => {
yargs.options({
Expand Down Expand Up @@ -2122,6 +2144,10 @@ yargs(rawArgs)
return yargs.demandCommand(1, 'Please specify an org-domain subcommand').strict();
})
// --- Workflow Commands ---
// NOTE: Top-level `.command()` registrations with inline handlers MUST wrap
// the handler with `wrapCommandHandler()` for correct command telemetry.
// Subcommands registered via `registerSubcommand()` are auto-wrapped.
// See CLAUDE.md "Telemetry Wiring for New Commands".
.command(
'seed',
'Seed WorkOS environment from a YAML config file',
Expand All @@ -2133,7 +2159,7 @@ yargs(rawArgs)
clean: { type: 'boolean', default: false, describe: 'Tear down seeded resources' },
init: { type: 'boolean', default: false, describe: 'Create an example workos-seed.yml file' },
}),
async (argv) => {
wrapCommandHandler(async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js');
const { runSeed } = await import('./commands/seed.js');
Expand All @@ -2142,7 +2168,7 @@ yargs(rawArgs)
resolveApiKey({ apiKey: argv.apiKey }),
resolveApiBaseUrl(),
);
},
}),
)
.command(
'setup-org <name>',
Expand All @@ -2154,7 +2180,7 @@ yargs(rawArgs)
domain: { type: 'string', describe: 'Domain to add and verify' },
roles: { type: 'string', describe: 'Comma-separated role slugs to create' },
}),
async (argv) => {
wrapCommandHandler(async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js');
const { runSetupOrg } = await import('./commands/setup-org.js');
Expand All @@ -2163,7 +2189,7 @@ yargs(rawArgs)
resolveApiKey({ apiKey: argv.apiKey }),
resolveApiBaseUrl(),
);
},
}),
)
.command(
'onboard-user <email>',
Expand All @@ -2176,7 +2202,7 @@ yargs(rawArgs)
role: { type: 'string', describe: 'Role slug to assign' },
wait: { type: 'boolean', default: false, describe: 'Wait for invitation acceptance' },
}),
async (argv) => {
wrapCommandHandler(async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js');
const { runOnboardUser } = await import('./commands/onboard-user.js');
Expand All @@ -2185,7 +2211,7 @@ yargs(rawArgs)
resolveApiKey({ apiKey: argv.apiKey }),
resolveApiBaseUrl(),
);
},
}),
)
.command(
'debug-sso <connectionId>',
Expand All @@ -2195,12 +2221,12 @@ yargs(rawArgs)
...insecureStorageOption,
'api-key': { type: 'string' as const, describe: 'WorkOS API key' },
}),
async (argv) => {
wrapCommandHandler(async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js');
const { runDebugSso } = await import('./commands/debug-sso.js');
await runDebugSso(argv.connectionId, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl());
},
}),
)
.command(
'debug-sync <directoryId>',
Expand All @@ -2210,12 +2236,12 @@ yargs(rawArgs)
...insecureStorageOption,
'api-key': { type: 'string' as const, describe: 'WorkOS API key' },
}),
async (argv) => {
wrapCommandHandler(async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js');
const { runDebugSync } = await import('./commands/debug-sync.js');
await runDebugSync(argv.directoryId, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl());
},
}),
)
// Alias — canonical command is `workos env claim`
.command(
Expand All @@ -2225,11 +2251,11 @@ yargs(rawArgs)
yargs.options({
...insecureStorageOption,
}),
async (argv) => {
wrapCommandHandler(async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
const { runClaim } = await import('./commands/claim.js');
await runClaim();
},
}),
)
.command(
'install',
Expand All @@ -2250,10 +2276,10 @@ yargs(rawArgs)
port: { type: 'number', default: 4100, describe: 'Port to listen on' },
seed: { type: 'string', describe: 'Path to seed config file (YAML or JSON)' },
}),
async (argv) => {
wrapCommandHandler(async (argv) => {
const { runEmulate } = await import('./commands/emulate.js');
await runEmulate({ port: argv.port, seed: argv.seed, json: argv.json as boolean });
},
}),
)
.command(
'dev',
Expand All @@ -2263,14 +2289,14 @@ yargs(rawArgs)
port: { type: 'number', default: 4100, describe: 'Emulator port' },
seed: { type: 'string', describe: 'Path to seed config file' },
}),
async (argv) => {
wrapCommandHandler(async (argv) => {
const { runDev } = await import('./commands/dev.js');
await runDev({
port: argv.port,
seed: argv.seed,
'--': argv['--'] as string[] | undefined,
});
},
}),
)
.command('debug', false, (yargs) => {
yargs.options(insecureStorageOption);
Expand Down
2 changes: 1 addition & 1 deletion src/commands/api/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ describe('runApiRequest', () => {

it('aborts when the user declines the confirmation prompt', async () => {
mockConfirm.mockResolvedValueOnce(false);
await expect(runApiRequest('/organizations', { method: 'POST', data: '{}' })).rejects.toThrow(/__exit__:0/);
await expect(runApiRequest('/organizations', { method: 'POST', data: '{}' })).rejects.toThrow(/__exit__:2/);
expect(mockApiRequest).not.toHaveBeenCalled();
});

Expand Down
9 changes: 7 additions & 2 deletions src/commands/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { loadCatalog, endpointsByTag } from './catalog.js';
import { apiRequest } from './request.js';
import { resolveApiBaseUrl } from '../../lib/api-key.js';
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';
Expand Down Expand Up @@ -145,7 +146,7 @@ export async function runApiRequest(endpoint: string, options: ApiCommandOptions
if (hasBody) prettyPrint(body);
const ok = await clack.confirm({ message: 'Proceed?' });
if (!ok || clack.isCancel(ok)) {
process.exit(0);
exitWithCode(ExitCode.CANCELLED);
}
}

Expand All @@ -160,7 +161,11 @@ export async function runApiRequest(endpoint: string, options: ApiCommandOptions
printResponse(response, { includeStatus: options.include });

if (response.status >= 400) {
process.exit(1);
exitWithError({
code: `http_${response.status}`,
message: `API request failed with status ${response.status}`,
apiContext: { status: response.status },
});
}
}

Expand Down
12 changes: 6 additions & 6 deletions src/commands/api/interactive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,20 +232,20 @@ describe('apiInteractive', () => {
expect(mockApiRequest).toHaveBeenCalledWith(expect.objectContaining({ body: undefined }));
});

it('exits with code 0 when the user cancels at the category prompt', async () => {
it('exits with code 2 when the user cancels at the category prompt', async () => {
mockSelect.mockResolvedValueOnce(cancelSymbol);

await expect(apiInteractive()).rejects.toThrow(/__exit__:0/);
expect(exitSpy).toHaveBeenCalledWith(0);
await expect(apiInteractive()).rejects.toThrow(/__exit__:2/);
expect(exitSpy).toHaveBeenCalledWith(2);
expect(mockApiRequest).not.toHaveBeenCalled();
});

it('exits with code 0 when the user declines the final confirmation', async () => {
it('exits with code 2 when the user declines the final confirmation', async () => {
mockSelect.mockResolvedValueOnce('Users').mockResolvedValueOnce(mockCatalog.endpoints[0]);
mockConfirm.mockResolvedValueOnce(false);

await expect(apiInteractive()).rejects.toThrow(/__exit__:0/);
expect(exitSpy).toHaveBeenCalledWith(0);
await expect(apiInteractive()).rejects.toThrow(/__exit__:2/);
expect(exitSpy).toHaveBeenCalledWith(2);
expect(mockApiRequest).not.toHaveBeenCalled();
});

Expand Down
12 changes: 9 additions & 3 deletions src/commands/api/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { loadCatalog, endpointsByTag, type EndpointInfo } from './catalog.js';
import { apiRequest } from './request.js';
import { colorMethod, printResponse } from './format.js';
import { resolveApiKey, resolveApiBaseUrl } from '../../lib/api-key.js';
import { ExitCode, exitWithCode } from '../../utils/exit-codes.js';
import { exitWithError } from '../../utils/output.js';

function assertNotCancelled<T>(value: T | symbol): T {
if (clack.isCancel(value)) process.exit(0);
if (clack.isCancel(value)) exitWithCode(ExitCode.CANCELLED);
return value as T;
}

Expand Down Expand Up @@ -136,7 +138,7 @@ export async function apiInteractive(options?: { apiKey?: string }): Promise<voi
console.log();

const ok = assertNotCancelled(await clack.confirm({ message: 'Execute this request?' }));
if (!ok) process.exit(0);
if (!ok) exitWithCode(ExitCode.CANCELLED);

const response = await apiRequest({
method: ep.method,
Expand All @@ -149,6 +151,10 @@ export async function apiInteractive(options?: { apiKey?: string }): Promise<voi
printResponse(response, { includeStatus: true });

if (response.status >= 400) {
process.exit(1);
exitWithError({
code: `http_${response.status}`,
message: `API request failed with status ${response.status}`,
apiContext: { status: response.status },
});
}
}
1 change: 1 addition & 0 deletions src/commands/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ interface EnvVarInfo {
}

const ENV_VAR_CATALOG: { name: string; effect: string }[] = [
{ name: 'WORKOS_DEBUG', effect: 'Set to "1" to enable verbose debug logging for all commands' },
{ name: 'WORKOS_API_KEY', effect: 'Bypasses credential resolution — used directly for API calls' },
{ name: 'WORKOS_MODE', effect: 'Controls interaction behavior: human, agent, or CI' },
{ name: 'WORKOS_FORCE_TTY', effect: 'Forces human (non-JSON) output mode, even when piped' },
Expand Down
9 changes: 5 additions & 4 deletions src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { readFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { parse as parseYaml } from 'yaml';
import chalk from 'chalk';
import { ExitCode, exitWithCode } from '../utils/exit-codes.js';
import { exitWithError } from '../utils/output.js';

export interface DevArgs {
port: number;
Expand All @@ -15,8 +17,7 @@ export interface DevArgs {
function loadSeedFile(filePath: string): EmulatorSeedConfig {
const resolved = resolve(filePath);
if (!existsSync(resolved)) {
console.error(`Seed file not found: ${resolved}`);
process.exit(1);
exitWithError({ code: 'seed_not_found', message: `Seed file not found: ${resolved}` });
}

const content = readFileSync(resolved, 'utf-8');
Expand Down Expand Up @@ -127,7 +128,7 @@ export async function runDev(argv: DevArgs): Promise<void> {
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>'));
await emulator.close();
process.exit(1);
exitWithCode(ExitCode.GENERAL_ERROR);
}

child.on('error', async (err) => {
Expand All @@ -139,7 +140,7 @@ export async function runDev(argv: DevArgs): Promise<void> {
console.error(chalk.dim(err.message));
}
await emulator.close();
process.exit(1);
exitWithCode(ExitCode.GENERAL_ERROR);
});

// 5. Signal handling — forward to child, then close emulator
Expand Down
Loading
Loading