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
65 changes: 45 additions & 20 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,27 @@ import { isNonInteractiveEnvironment } from './utils/environment.js';
import { resolveOutputMode, setOutputMode, isJsonMode, outputJson, exitWithError } 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();

// Resolve output mode early from raw argv (before yargs parses)
const rawArgs = hideBin(process.argv);
Expand All @@ -40,9 +61,8 @@ setOutputMode(resolveOutputMode(hasJsonFlag));
// Intercept --help --json before yargs parses (yargs exits on --help)
if (hasJsonFlag && (rawArgs.includes('--help') || rawArgs.includes('-h'))) {
const { buildCommandTree } = await import('./utils/help-json.js');
const commandAliases: Record<string, string> = { org: 'organization' };
const rawCommand = rawArgs.find((a) => !a.startsWith('-'));
const command = rawCommand ? (commandAliases[rawCommand] ?? rawCommand) : undefined;
const command = rawCommand ? (COMMAND_ALIASES[rawCommand] ?? rawCommand) : undefined;
outputJson(buildCommandTree(command));
process.exit(0);
}
Expand Down Expand Up @@ -183,6 +203,7 @@ yargs(rawArgs)
describe: 'Output results as JSON (auto-enabled in non-TTY)',
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 @@ -344,10 +365,10 @@ yargs(rawArgs)
description: 'Copy report to clipboard',
},
}),
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 @@ -2008,6 +2029,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 @@ -2019,7 +2044,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 @@ -2028,7 +2053,7 @@ yargs(rawArgs)
resolveApiKey({ apiKey: argv.apiKey }),
resolveApiBaseUrl(),
);
},
}),
)
.command(
'setup-org <name>',
Expand All @@ -2040,7 +2065,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 @@ -2049,7 +2074,7 @@ yargs(rawArgs)
resolveApiKey({ apiKey: argv.apiKey }),
resolveApiBaseUrl(),
);
},
}),
)
.command(
'onboard-user <email>',
Expand All @@ -2062,7 +2087,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 @@ -2071,7 +2096,7 @@ yargs(rawArgs)
resolveApiKey({ apiKey: argv.apiKey }),
resolveApiBaseUrl(),
);
},
}),
)
.command(
'debug-sso <connectionId>',
Expand All @@ -2081,12 +2106,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 @@ -2096,12 +2121,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 @@ -2111,11 +2136,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 @@ -2136,10 +2161,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 @@ -2149,14 +2174,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
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_FORCE_TTY', effect: 'Forces human (non-JSON) output mode, even when piped' },
{ name: 'WORKOS_NO_PROMPT', effect: 'Forces non-interactive/JSON mode' },
Expand Down
11 changes: 11 additions & 0 deletions src/lib/command-aliases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Shared canonical command alias map.
* Single source of truth for both telemetry and help-json.
*
* Keys are user-facing aliases, values are canonical command names.
* Adding an alias here updates both metrics aggregation and --help --json output.
*/
export const COMMAND_ALIASES: Record<string, string> = {
org: 'organization',
claim: 'env.claim',
};
Loading
Loading