diff --git a/.gitignore b/.gitignore index 0abeb61..a45dfb5 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ tests/eval-results/ # Local ideation artifacts docs/ + +# Case harness markers +.case-* diff --git a/README.md b/README.md index 99e3caf..a20a5c7 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ workos [command] Commands: install Install WorkOS AuthKit into your project + claim Claim an unclaimed environment (link to your account) auth Manage authentication (login, logout, status) env Manage environment configurations doctor Diagnose WorkOS integration issues @@ -85,6 +86,24 @@ Workflows: All management commands support `--json` for structured output (auto-enabled in non-TTY) and `--api-key` to override the active environment's key. +### Unclaimed Environments + +When you run `workos install` without credentials, the CLI automatically provisions a temporary WorkOS environment — no account needed. This lets you try AuthKit immediately. + +```bash +# Install with zero setup — environment provisioned automatically +workos install + +# Check your environment +workos env list +# Shows: unclaimed (unclaimed) ← active + +# Claim the environment to link it to your WorkOS account +workos claim +``` + +Management commands work on unclaimed environments with a warning reminding you to claim. + ### Workflows The compound workflow commands compose multiple API calls into common operations. These are the highest-value commands for both developers and AI agents. @@ -483,7 +502,7 @@ OAuth credentials are stored in the system keychain (with `~/.workos/credentials ## How It Works 1. **Detects** your framework and project structure -2. **Prompts** for WorkOS credentials (API key masked) +2. **Resolves credentials** — uses existing config, or auto-provisions an unclaimed environment if none found 3. **Auto-configures** WorkOS dashboard (redirect URI, CORS, homepage URL) 4. **Fetches** latest SDK documentation from workos.com 5. **Uses AI** (Claude) to generate integration code diff --git a/src/bin.ts b/src/bin.ts index a3ae927..9688c5a 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -57,6 +57,14 @@ async function applyInsecureStorage(insecureStorage?: boolean): Promise { } } +/** Show non-blocking warning if active env is unclaimed (once per session). */ +async function maybeWarnUnclaimed(): Promise { + const { warnIfUnclaimed } = await import('./lib/unclaimed-warning.js'); + await warnIfUnclaimed(); +} + +import { resolveInstallCredentials } from './lib/resolve-install-credentials.js'; + /** Shared insecure-storage option for commands that access credentials */ const insecureStorageOption = { 'insecure-storage': { @@ -66,20 +74,6 @@ const insecureStorageOption = { }, } as const; -/** - * Wrap a command handler with authentication check. - * Ensures valid auth before executing the handler. - * Respects --skip-auth flag for CI/testing. - */ -function withAuth(handler: (argv: T) => Promise): (argv: T) => Promise { - return async (argv: T) => { - const typedArgv = argv as { skipAuth?: boolean; insecureStorage?: boolean }; - await applyInsecureStorage(typedArgv.insecureStorage); - if (!typedArgv.skipAuth) await ensureAuthenticated(); - await handler(argv); - }; -} - const installerOptions = { direct: { alias: 'D', @@ -188,6 +182,15 @@ yargs(rawArgs) describe: 'Output results as JSON (auto-enabled in non-TTY)', global: true, }) + .middleware(async (argv) => { + // Warn about unclaimed environments before management commands. + // Excluded: auth/claim/install/dashboard handle their own credential flows; + // skills/doctor/env/debug are utility commands where the warning is unnecessary. + const command = String(argv._?.[0] ?? ''); + if (['auth', 'skills', 'doctor', 'env', 'claim', 'install', 'debug', 'dashboard', ''].includes(command)) return; + await applyInsecureStorage(argv.insecureStorage as boolean | undefined); + await maybeWarnUnclaimed(); + }) .command('auth', 'Manage authentication (login, logout, status)', (yargs) => { yargs.options(insecureStorageOption); registerSubcommand( @@ -427,6 +430,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runOrgCreate } = await import('./commands/organization.js'); const apiKey = resolveApiKey({ apiKey: argv.apiKey }); @@ -445,6 +449,7 @@ yargs(rawArgs) .positional('state', { type: 'string', describe: 'Domain state (verified or pending)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runOrgUpdate } = await import('./commands/organization.js'); const apiKey = resolveApiKey({ apiKey: argv.apiKey }); @@ -458,6 +463,7 @@ yargs(rawArgs) (y) => y.positional('orgId', { type: 'string', demandOption: true, describe: 'Organization ID' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runOrgGet } = await import('./commands/organization.js'); const apiKey = resolveApiKey({ apiKey: argv.apiKey }); @@ -478,6 +484,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runOrgList } = await import('./commands/organization.js'); const apiKey = resolveApiKey({ apiKey: argv.apiKey }); @@ -495,6 +502,7 @@ yargs(rawArgs) (y) => y.positional('orgId', { type: 'string', demandOption: true, describe: 'Organization ID' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runOrgDelete } = await import('./commands/organization.js'); const apiKey = resolveApiKey({ apiKey: argv.apiKey }); @@ -518,6 +526,7 @@ yargs(rawArgs) (y) => y.positional('userId', { type: 'string', demandOption: true, describe: 'User ID' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runUserGet } = await import('./commands/user.js'); await runUserGet(argv.userId, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -538,6 +547,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runUserList } = await import('./commands/user.js'); await runUserList( @@ -568,6 +578,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runUserUpdate } = await import('./commands/user.js'); await runUserUpdate( @@ -591,6 +602,7 @@ yargs(rawArgs) (y) => y.positional('userId', { type: 'string', demandOption: true, describe: 'User ID' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runUserDelete } = await import('./commands/user.js'); await runUserDelete(argv.userId, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -612,6 +624,7 @@ yargs(rawArgs) (y) => y, async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runRoleList } = await import('./commands/role.js'); await runRoleList(argv.org, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -624,6 +637,7 @@ yargs(rawArgs) (y) => y.positional('slug', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runRoleGet } = await import('./commands/role.js'); await runRoleGet(argv.slug, argv.org, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -641,6 +655,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runRoleCreate } = await import('./commands/role.js'); await runRoleCreate( @@ -661,6 +676,7 @@ yargs(rawArgs) .options({ name: { type: 'string' }, description: { type: 'string' } }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runRoleUpdate } = await import('./commands/role.js'); await runRoleUpdate( @@ -679,6 +695,7 @@ yargs(rawArgs) (y) => y.positional('slug', { type: 'string', demandOption: true }).demandOption('org'), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runRoleDelete } = await import('./commands/role.js'); await runRoleDelete(argv.slug, argv.org!, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -696,6 +713,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runRoleSetPermissions } = await import('./commands/role.js'); await runRoleSetPermissions( @@ -717,6 +735,7 @@ yargs(rawArgs) .positional('permissionSlug', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runRoleAddPermission } = await import('./commands/role.js'); await runRoleAddPermission( @@ -739,6 +758,7 @@ yargs(rawArgs) .demandOption('org'), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runRoleRemovePermission } = await import('./commands/role.js'); await runRoleRemovePermission( @@ -767,6 +787,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runPermissionList } = await import('./commands/permission.js'); await runPermissionList( @@ -783,6 +804,7 @@ yargs(rawArgs) (y) => y.positional('slug', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runPermissionGet } = await import('./commands/permission.js'); await runPermissionGet(argv.slug, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -800,6 +822,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runPermissionCreate } = await import('./commands/permission.js'); await runPermissionCreate( @@ -819,6 +842,7 @@ yargs(rawArgs) .options({ name: { type: 'string' }, description: { type: 'string' } }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runPermissionUpdate } = await import('./commands/permission.js'); await runPermissionUpdate( @@ -836,6 +860,7 @@ yargs(rawArgs) (y) => y.positional('slug', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runPermissionDelete } = await import('./commands/permission.js'); await runPermissionDelete(argv.slug, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -860,6 +885,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runMembershipList } = await import('./commands/membership.js'); await runMembershipList( @@ -883,6 +909,7 @@ yargs(rawArgs) (y) => y.positional('id', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runMembershipGet } = await import('./commands/membership.js'); await runMembershipGet(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -900,6 +927,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runMembershipCreate } = await import('./commands/membership.js'); await runMembershipCreate( @@ -916,6 +944,7 @@ yargs(rawArgs) (y) => y.positional('id', { type: 'string', demandOption: true }).option('role', { type: 'string' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runMembershipUpdate } = await import('./commands/membership.js'); await runMembershipUpdate(argv.id, argv.role, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -928,6 +957,7 @@ yargs(rawArgs) (y) => y.positional('id', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runMembershipDelete } = await import('./commands/membership.js'); await runMembershipDelete(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -940,6 +970,7 @@ yargs(rawArgs) (y) => y.positional('id', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runMembershipDeactivate } = await import('./commands/membership.js'); await runMembershipDeactivate(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -952,6 +983,7 @@ yargs(rawArgs) (y) => y.positional('id', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runMembershipReactivate } = await import('./commands/membership.js'); await runMembershipReactivate(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -976,6 +1008,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runInvitationList } = await import('./commands/invitation.js'); await runInvitationList( @@ -999,6 +1032,7 @@ yargs(rawArgs) (y) => y.positional('id', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runInvitationGet } = await import('./commands/invitation.js'); await runInvitationGet(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1017,6 +1051,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runInvitationSend } = await import('./commands/invitation.js'); await runInvitationSend( @@ -1033,6 +1068,7 @@ yargs(rawArgs) (y) => y.positional('id', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runInvitationRevoke } = await import('./commands/invitation.js'); await runInvitationRevoke(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1045,6 +1081,7 @@ yargs(rawArgs) (y) => y.positional('id', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runInvitationResend } = await import('./commands/invitation.js'); await runInvitationResend(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1067,6 +1104,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runSessionList } = await import('./commands/session.js'); await runSessionList( @@ -1084,6 +1122,7 @@ yargs(rawArgs) (y) => y.positional('sessionId', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runSessionRevoke } = await import('./commands/session.js'); await runSessionRevoke(argv.sessionId, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1108,6 +1147,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runConnectionList } = await import('./commands/connection.js'); await runConnectionList( @@ -1131,6 +1171,7 @@ yargs(rawArgs) (y) => y.positional('id', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runConnectionGet } = await import('./commands/connection.js'); await runConnectionGet(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1144,6 +1185,7 @@ yargs(rawArgs) y.positional('id', { type: 'string', demandOption: true }).option('force', { type: 'boolean', default: false }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runConnectionDelete } = await import('./commands/connection.js'); await runConnectionDelete( @@ -1172,6 +1214,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runDirectoryList } = await import('./commands/directory.js'); await runDirectoryList( @@ -1188,6 +1231,7 @@ yargs(rawArgs) (y) => y.positional('id', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runDirectoryGet } = await import('./commands/directory.js'); await runDirectoryGet(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1201,6 +1245,7 @@ yargs(rawArgs) y.positional('id', { type: 'string', demandOption: true }).option('force', { type: 'boolean', default: false }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runDirectoryDelete } = await import('./commands/directory.js'); await runDirectoryDelete( @@ -1225,6 +1270,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runDirectoryListUsers } = await import('./commands/directory.js'); await runDirectoryListUsers( @@ -1247,6 +1293,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runDirectoryListGroups } = await import('./commands/directory.js'); await runDirectoryListGroups( @@ -1275,6 +1322,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runEventList } = await import('./commands/event.js'); await runEventList( @@ -1313,6 +1361,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runAuditLogCreateEvent } = await import('./commands/audit-log.js'); await runAuditLogCreateEvent( @@ -1349,6 +1398,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runAuditLogExport } = await import('./commands/audit-log.js'); await runAuditLogExport( @@ -1373,6 +1423,7 @@ yargs(rawArgs) (y) => y, async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runAuditLogListActions } = await import('./commands/audit-log.js'); await runAuditLogListActions(resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1385,6 +1436,7 @@ yargs(rawArgs) (y) => y.positional('action', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runAuditLogGetSchema } = await import('./commands/audit-log.js'); await runAuditLogGetSchema(argv.action, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1400,6 +1452,7 @@ yargs(rawArgs) .option('file', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runAuditLogCreateSchema } = await import('./commands/audit-log.js'); await runAuditLogCreateSchema( @@ -1417,6 +1470,7 @@ yargs(rawArgs) (y) => y.positional('orgId', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runAuditLogGetRetention } = await import('./commands/audit-log.js'); await runAuditLogGetRetention(argv.orgId, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1439,6 +1493,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runFeatureFlagList } = await import('./commands/feature-flag.js'); await runFeatureFlagList( @@ -1455,6 +1510,7 @@ yargs(rawArgs) (y) => y.positional('slug', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runFeatureFlagGet } = await import('./commands/feature-flag.js'); await runFeatureFlagGet(argv.slug, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1467,6 +1523,7 @@ yargs(rawArgs) (y) => y.positional('slug', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runFeatureFlagEnable } = await import('./commands/feature-flag.js'); await runFeatureFlagEnable(argv.slug, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1479,6 +1536,7 @@ yargs(rawArgs) (y) => y.positional('slug', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runFeatureFlagDisable } = await import('./commands/feature-flag.js'); await runFeatureFlagDisable(argv.slug, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1494,6 +1552,7 @@ yargs(rawArgs) .positional('targetId', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runFeatureFlagAddTarget } = await import('./commands/feature-flag.js'); await runFeatureFlagAddTarget( @@ -1514,6 +1573,7 @@ yargs(rawArgs) .positional('targetId', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runFeatureFlagRemoveTarget } = await import('./commands/feature-flag.js'); await runFeatureFlagRemoveTarget( @@ -1535,6 +1595,7 @@ yargs(rawArgs) (y) => y, async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runWebhookList } = await import('./commands/webhook.js'); await runWebhookList(resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1551,6 +1612,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runWebhookCreate } = await import('./commands/webhook.js'); await runWebhookCreate( @@ -1568,6 +1630,7 @@ yargs(rawArgs) (y) => y.positional('id', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runWebhookDelete } = await import('./commands/webhook.js'); await runWebhookDelete(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1585,6 +1648,7 @@ yargs(rawArgs) (y) => y.positional('uri', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runConfigRedirectAdd } = await import('./commands/config.js'); await runConfigRedirectAdd(argv.uri, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1600,6 +1664,7 @@ yargs(rawArgs) (y) => y.positional('origin', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runConfigCorsAdd } = await import('./commands/config.js'); await runConfigCorsAdd(argv.origin, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1615,6 +1680,7 @@ yargs(rawArgs) (y) => y.positional('url', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runConfigHomepageUrlSet } = await import('./commands/config.js'); await runConfigHomepageUrlSet(argv.url, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1643,6 +1709,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runPortalGenerateLink } = await import('./commands/portal.js'); await runPortalGenerateLink( @@ -1669,6 +1736,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runVaultList } = await import('./commands/vault.js'); await runVaultList( @@ -1685,6 +1753,7 @@ yargs(rawArgs) (y) => y.positional('id', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runVaultGet } = await import('./commands/vault.js'); await runVaultGet(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1697,6 +1766,7 @@ yargs(rawArgs) (y) => y.positional('name', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runVaultGetByName } = await import('./commands/vault.js'); await runVaultGetByName(argv.name, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1714,6 +1784,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runVaultCreate } = await import('./commands/vault.js'); await runVaultCreate( @@ -1733,6 +1804,7 @@ yargs(rawArgs) .options({ value: { type: 'string', demandOption: true }, 'version-check': { type: 'string' } }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runVaultUpdate } = await import('./commands/vault.js'); await runVaultUpdate( @@ -1749,6 +1821,7 @@ yargs(rawArgs) (y) => y.positional('id', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runVaultDelete } = await import('./commands/vault.js'); await runVaultDelete(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1761,6 +1834,7 @@ yargs(rawArgs) (y) => y.positional('id', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runVaultDescribe } = await import('./commands/vault.js'); await runVaultDescribe(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1773,6 +1847,7 @@ yargs(rawArgs) (y) => y.positional('id', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runVaultListVersions } = await import('./commands/vault.js'); await runVaultListVersions(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1796,6 +1871,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runApiKeyList } = await import('./commands/api-key-mgmt.js'); await runApiKeyList( @@ -1817,6 +1893,7 @@ yargs(rawArgs) }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runApiKeyCreate } = await import('./commands/api-key-mgmt.js'); await runApiKeyCreate( @@ -1833,6 +1910,7 @@ yargs(rawArgs) (y) => y.positional('value', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runApiKeyValidate } = await import('./commands/api-key-mgmt.js'); await runApiKeyValidate(argv.value, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1845,6 +1923,7 @@ yargs(rawArgs) (y) => y.positional('id', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runApiKeyDelete } = await import('./commands/api-key-mgmt.js'); await runApiKeyDelete(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1861,6 +1940,7 @@ yargs(rawArgs) (y) => y.positional('id', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runOrgDomainGet } = await import('./commands/org-domain.js'); await runOrgDomainGet(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1876,6 +1956,7 @@ yargs(rawArgs) .option('org', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runOrgDomainCreate } = await import('./commands/org-domain.js'); await runOrgDomainCreate(argv.domain, argv.org, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1888,6 +1969,7 @@ yargs(rawArgs) (y) => y.positional('id', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runOrgDomainVerify } = await import('./commands/org-domain.js'); await runOrgDomainVerify(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -1900,6 +1982,7 @@ yargs(rawArgs) (y) => y.positional('id', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runOrgDomainDelete } = await import('./commands/org-domain.js'); await runOrgDomainDelete(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); @@ -2002,23 +2085,149 @@ yargs(rawArgs) await runDebugSync(argv.directoryId, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); }, ) + .command( + 'claim', + 'Claim an unclaimed WorkOS environment (link it to your account)', + (yargs) => + yargs.options({ + ...insecureStorageOption, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runClaim } = await import('./commands/claim.js'); + await runClaim(); + }, + ) .command( 'install', 'Install WorkOS AuthKit into your project (interactive framework detection and setup)', (yargs) => yargs.options(installerOptions), - withAuth(async (argv) => { + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + await resolveInstallCredentials(argv.apiKey, argv.installDir, argv.skipAuth, ensureAuthenticated); const { handleInstall } = await import('./commands/install.js'); await handleInstall(argv); - }), + }, ) + .command('debug', false, (yargs) => { + yargs.options(insecureStorageOption); + registerSubcommand( + yargs, + 'state', + 'Dump raw CLI state (credentials, config, storage)', + (y) => + y.option('show-secrets', { + type: 'boolean', + default: false, + describe: 'Show unredacted tokens and API keys', + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runDebugState } = await import('./commands/debug.js'); + await runDebugState({ showSecrets: argv.showSecrets as boolean }); + }, + ); + registerSubcommand( + yargs, + 'reset', + 'Clear auth state (keyring + files)', + (y) => + y + .option('force', { + type: 'boolean', + default: false, + describe: 'Skip confirmation prompt', + }) + .option('credentials-only', { + type: 'boolean', + default: false, + describe: 'Only clear credentials', + }) + .option('config-only', { + type: 'boolean', + default: false, + describe: 'Only clear config', + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runDebugReset } = await import('./commands/debug.js'); + await runDebugReset({ + force: argv.force as boolean, + credentialsOnly: argv.credentialsOnly as boolean, + configOnly: argv.configOnly as boolean, + }); + }, + ); + registerSubcommand( + yargs, + 'simulate', + 'Simulate CLI states for testing', + (y) => + y + .option('expired-token', { + type: 'boolean', + default: false, + describe: 'Set token expiresAt to the past', + }) + .option('no-keyring', { + type: 'boolean', + default: false, + describe: 'Force file-only storage mode', + }) + .option('unclaimed', { + type: 'boolean', + default: false, + describe: 'Write synthetic unclaimed environment', + }) + .option('no-auth', { + type: 'boolean', + default: false, + describe: 'Clear credentials, keep config', + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runDebugSimulate } = await import('./commands/debug.js'); + await runDebugSimulate({ + expiredToken: argv.expiredToken as boolean, + noKeyring: argv.noKeyring as boolean, + unclaimed: argv.unclaimed as boolean, + noAuth: argv.noAuth as boolean, + }); + }, + ); + registerSubcommand( + yargs, + 'env', + 'Show WORKOS_* environment variables and their effects', + (y) => y, + async () => { + const { runDebugEnv } = await import('./commands/debug.js'); + await runDebugEnv(); + }, + ); + registerSubcommand( + yargs, + 'token', + 'Decode and inspect the current access token', + (y) => y, + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runDebugToken } = await import('./commands/debug.js'); + await runDebugToken(); + }, + ); + return yargs.demandCommand(1, 'Run "workos debug " for debug tools.').strict(); + }) .command( 'dashboard', false, // hidden from help (yargs) => yargs.options(installerOptions), - withAuth(async (argv) => { + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + await resolveInstallCredentials(argv.apiKey, argv.installDir, argv.skipAuth, ensureAuthenticated); const { handleInstall } = await import('./commands/install.js'); await handleInstall({ ...argv, dashboard: true }); - }), + }, ) .command( ['$0'], @@ -2040,9 +2249,8 @@ yargs(rawArgs) process.exit(0); } - // Auth check happens HERE, after user confirms await applyInsecureStorage(argv.insecureStorage); - await ensureAuthenticated(); + await resolveInstallCredentials(undefined, undefined, false, ensureAuthenticated); const { handleInstall } = await import('./commands/install.js'); await handleInstall({ ...argv, dashboard: false }); diff --git a/src/commands/claim.spec.ts b/src/commands/claim.spec.ts new file mode 100644 index 0000000..d5f6346 --- /dev/null +++ b/src/commands/claim.spec.ts @@ -0,0 +1,409 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock debug utilities +vi.mock('../utils/debug.js', () => ({ + logInfo: vi.fn(), + logError: vi.fn(), +})); + +// Mock opn (browser open) +const mockOpen = vi.fn().mockResolvedValue(undefined); +vi.mock('opn', () => ({ default: mockOpen })); + +// Mock clack +const mockSpinner = { + start: vi.fn(), + stop: vi.fn(), + message: vi.fn(), +}; +const mockClack = { + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + step: vi.fn(), + success: vi.fn(), + }, + spinner: () => mockSpinner, +}; +vi.mock('../utils/clack.js', () => ({ default: mockClack })); + +// Mock output utilities +const mockOutputJson = vi.fn(); +let jsonMode = false; +const mockExitWithError = vi.fn(() => { + throw new Error('exitWithError'); +}); +vi.mock('../utils/output.js', () => ({ + isJsonMode: () => jsonMode, + outputJson: (...args: unknown[]) => mockOutputJson(...args), + exitWithError: (...args: unknown[]) => mockExitWithError(...args), +})); + +// Mock helper-functions +vi.mock('../lib/helper-functions.js', () => ({ + sleep: vi.fn((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))), +})); + +// Mock config-store +const mockGetConfig = vi.fn(); +const mockSaveConfig = vi.fn(); +const mockGetActiveEnvironment = vi.fn(); +const mockIsUnclaimedEnvironment = vi.fn(); +const mockMarkEnvironmentClaimed = vi.fn(); +vi.mock('../lib/config-store.js', () => ({ + getConfig: (...args: unknown[]) => mockGetConfig(...args), + saveConfig: (...args: unknown[]) => mockSaveConfig(...args), + getActiveEnvironment: (...args: unknown[]) => mockGetActiveEnvironment(...args), + isUnclaimedEnvironment: (...args: unknown[]) => mockIsUnclaimedEnvironment(...args), + markEnvironmentClaimed: (...args: unknown[]) => mockMarkEnvironmentClaimed(...args), +})); + +// Mock unclaimed-env-api +const mockCreateClaimNonce = vi.fn(); +import { MockUnclaimedEnvApiError } from '../lib/__test-helpers__/mock-unclaimed-env-api-error.js'; +vi.mock('../lib/unclaimed-env-api.js', () => ({ + createClaimNonce: (...args: unknown[]) => mockCreateClaimNonce(...args), + UnclaimedEnvApiError: MockUnclaimedEnvApiError, +})); + +const { runClaim } = await import('./claim.js'); + +describe('claim command', () => { + beforeEach(() => { + vi.clearAllMocks(); + jsonMode = false; + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('runClaim', () => { + it('exits with info when no active environment', async () => { + mockGetActiveEnvironment.mockReturnValue(null); + mockIsUnclaimedEnvironment.mockReturnValue(false); + + await runClaim(); + + expect(mockClack.log.info).toHaveBeenCalledWith(expect.stringContaining('No unclaimed environment found')); + }); + + it('exits with info when active environment is not unclaimed', async () => { + mockGetActiveEnvironment.mockReturnValue({ + name: 'production', + type: 'production', + apiKey: 'sk_live_xxx', + }); + mockIsUnclaimedEnvironment.mockReturnValue(false); + + await runClaim(); + + expect(mockClack.log.info).toHaveBeenCalledWith(expect.stringContaining('No unclaimed environment found')); + }); + + it('outputs JSON when no unclaimed environment in JSON mode', async () => { + jsonMode = true; + mockGetActiveEnvironment.mockReturnValue(null); + mockIsUnclaimedEnvironment.mockReturnValue(false); + + await runClaim(); + + expect(mockOutputJson).toHaveBeenCalledWith(expect.objectContaining({ status: 'no_unclaimed_environment' })); + }); + + // Missing claimToken/clientId tests removed — discriminated union makes these states + // impossible at the type level (UnclaimedEnvironmentConfig requires both fields). + + it('handles already-claimed environment immediately', async () => { + mockGetActiveEnvironment.mockReturnValue({ + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }); + mockIsUnclaimedEnvironment.mockReturnValue(true); + mockCreateClaimNonce.mockResolvedValueOnce({ alreadyClaimed: true }); + + await runClaim(); + + expect(mockClack.log.success).toHaveBeenCalledWith('Environment already claimed!'); + expect(mockMarkEnvironmentClaimed).toHaveBeenCalled(); + }); + + it('generates nonce, opens browser, and polls for claim', async () => { + const unclaimedEnv = { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }; + mockGetActiveEnvironment.mockReturnValue(unclaimedEnv); + mockIsUnclaimedEnvironment.mockReturnValue(true); + + // First call: returns nonce + mockCreateClaimNonce.mockResolvedValueOnce({ + nonce: 'nonce_abc123', + alreadyClaimed: false, + }); + // Second call (poll): returns claimed + mockCreateClaimNonce.mockResolvedValueOnce({ alreadyClaimed: true }); + + const claimPromise = runClaim(); + + // Advance past poll interval + await vi.advanceTimersByTimeAsync(6_000); + await claimPromise; + + expect(mockOpen).toHaveBeenCalledWith( + expect.stringContaining('https://dashboard.workos.com/claim?nonce=nonce_abc123'), + { wait: false }, + ); + expect(mockSpinner.start).toHaveBeenCalledWith('Waiting for claim...'); + expect(mockSpinner.stop).toHaveBeenCalledWith('Environment claimed!'); + expect(mockMarkEnvironmentClaimed).toHaveBeenCalled(); + }); + + it('outputs JSON with claim URL in JSON mode', async () => { + jsonMode = true; + 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, + }); + + await runClaim(); + + expect(mockOutputJson).toHaveBeenCalledWith({ + status: 'claim_url', + claimUrl: 'https://dashboard.workos.com/claim?nonce=nonce_abc123', + nonce: 'nonce_abc123', + }); + // Should NOT open browser or start polling in JSON mode + expect(mockOpen).not.toHaveBeenCalled(); + expect(mockSpinner.start).not.toHaveBeenCalled(); + }); + + it('outputs JSON for already-claimed in JSON mode', async () => { + jsonMode = true; + mockGetActiveEnvironment.mockReturnValue({ + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }); + mockIsUnclaimedEnvironment.mockReturnValue(true); + mockCreateClaimNonce.mockResolvedValueOnce({ alreadyClaimed: true }); + + await runClaim(); + + expect(mockOutputJson).toHaveBeenCalledWith(expect.objectContaining({ status: 'already_claimed' })); + }); + + it('times out after 5 minutes of polling', async () => { + mockGetActiveEnvironment.mockReturnValue({ + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }); + mockIsUnclaimedEnvironment.mockReturnValue(true); + + // First call: returns nonce + mockCreateClaimNonce.mockResolvedValueOnce({ + nonce: 'nonce_abc123', + alreadyClaimed: false, + }); + // All poll calls: not yet claimed + mockCreateClaimNonce.mockResolvedValue({ + nonce: 'nonce_abc123', + alreadyClaimed: false, + }); + + const claimPromise = runClaim(); + + // Advance past 5 minute timeout + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 5_000); + await claimPromise; + + expect(mockSpinner.stop).toHaveBeenCalledWith('Claim timed out'); + expect(mockClack.log.info).toHaveBeenCalledWith(expect.stringContaining('Complete the claim in your browser')); + }); + + it('continues polling on transient poll errors', async () => { + const unclaimedEnv = { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }; + mockGetActiveEnvironment.mockReturnValue(unclaimedEnv); + mockIsUnclaimedEnvironment.mockReturnValue(true); + + // First call: returns nonce + mockCreateClaimNonce.mockResolvedValueOnce({ + nonce: 'nonce_abc123', + alreadyClaimed: false, + }); + // Second poll call: transient error + mockCreateClaimNonce.mockRejectedValueOnce(new Error('Network blip')); + // Third poll call: claimed + mockCreateClaimNonce.mockResolvedValueOnce({ alreadyClaimed: true }); + + const claimPromise = runClaim(); + + // Advance through two poll intervals + await vi.advanceTimersByTimeAsync(11_000); + await claimPromise; + + expect(mockSpinner.stop).toHaveBeenCalledWith('Environment claimed!'); + }); + + it('handles claim nonce generation failure', async () => { + mockGetActiveEnvironment.mockReturnValue({ + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }); + mockIsUnclaimedEnvironment.mockReturnValue(true); + mockCreateClaimNonce.mockRejectedValueOnce(new Error('Invalid claim token.')); + + await runClaim().catch(() => {}); // exitWithError throws + + expect(mockExitWithError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'claim_failed', message: expect.stringContaining('Invalid claim token') }), + ); + }); + + it('treats 401 poll error as implicit claim (environment claimed externally)', async () => { + const unclaimedEnv = { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }; + mockGetActiveEnvironment.mockReturnValue(unclaimedEnv); + mockIsUnclaimedEnvironment.mockReturnValue(true); + + // First call: returns nonce + mockCreateClaimNonce.mockResolvedValueOnce({ + nonce: 'nonce_abc123', + alreadyClaimed: false, + }); + // Poll call: 401 — claim token invalidated (claimed via browser) + mockCreateClaimNonce.mockRejectedValueOnce(new MockUnclaimedEnvApiError('Invalid claim token.', 401)); + + const claimPromise = runClaim(); + await vi.advanceTimersByTimeAsync(6_000); + await claimPromise; + + expect(mockSpinner.stop).toHaveBeenCalledWith('Claim token is invalid or expired.'); + expect(mockMarkEnvironmentClaimed).toHaveBeenCalled(); + expect(mockClack.log.warn).toHaveBeenCalledWith(expect.stringContaining('workos auth login')); + }); + + it('shows connection issues after 3 consecutive poll failures', async () => { + const unclaimedEnv = { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }; + mockGetActiveEnvironment.mockReturnValue(unclaimedEnv); + mockIsUnclaimedEnvironment.mockReturnValue(true); + + // First call: returns nonce + mockCreateClaimNonce.mockResolvedValueOnce({ + nonce: 'nonce_abc123', + alreadyClaimed: false, + }); + // 3 consecutive failures, then success + mockCreateClaimNonce.mockRejectedValueOnce(new Error('Network error')); + mockCreateClaimNonce.mockRejectedValueOnce(new Error('Network error')); + mockCreateClaimNonce.mockRejectedValueOnce(new Error('Network error')); + mockCreateClaimNonce.mockResolvedValueOnce({ alreadyClaimed: true }); + + const claimPromise = runClaim(); + await vi.advanceTimersByTimeAsync(25_000); + await claimPromise; + + expect(mockSpinner.message).toHaveBeenCalledWith('Still waiting... (connection issues detected)'); + expect(mockSpinner.stop).toHaveBeenCalledWith('Environment claimed!'); + }); + + it('exits early after MAX_CONSECUTIVE_FAILURES poll errors', async () => { + const unclaimedEnv = { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }; + mockGetActiveEnvironment.mockReturnValue(unclaimedEnv); + mockIsUnclaimedEnvironment.mockReturnValue(true); + + // First call: returns nonce + mockCreateClaimNonce.mockResolvedValueOnce({ + nonce: 'nonce_abc123', + alreadyClaimed: false, + }); + // 10 consecutive failures (MAX_CONSECUTIVE_FAILURES) + for (let i = 0; i < 10; i++) { + mockCreateClaimNonce.mockRejectedValueOnce(new Error('Server down')); + } + + const claimPromise = runClaim(); + await vi.advanceTimersByTimeAsync(60_000); + await claimPromise; + + expect(mockSpinner.stop).toHaveBeenCalledWith('Too many connection failures'); + expect(mockClack.log.error).toHaveBeenCalledWith(expect.stringContaining('Polling failed 10 times')); + expect(mockMarkEnvironmentClaimed).not.toHaveBeenCalled(); + }); + + it('logs error and shows fallback when browser open fails', async () => { + const unclaimedEnv = { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }; + mockGetActiveEnvironment.mockReturnValue(unclaimedEnv); + mockIsUnclaimedEnvironment.mockReturnValue(true); + mockCreateClaimNonce.mockResolvedValueOnce({ + nonce: 'nonce_abc123', + alreadyClaimed: false, + }); + // Poll returns claimed immediately + mockCreateClaimNonce.mockResolvedValueOnce({ alreadyClaimed: true }); + // Browser open throws synchronously (open() is called without await) + mockOpen.mockImplementationOnce(() => { + throw new Error('No browser available'); + }); + + const claimPromise = runClaim(); + await vi.advanceTimersByTimeAsync(6_000); + await claimPromise; + + expect(mockClack.log.info).toHaveBeenCalledWith(expect.stringContaining('Could not open browser')); + }); + }); +}); diff --git a/src/commands/claim.ts b/src/commands/claim.ts new file mode 100644 index 0000000..9c939f3 --- /dev/null +++ b/src/commands/claim.ts @@ -0,0 +1,124 @@ +/** + * `workos claim` — claim an unclaimed environment. + * + * Reads claim token from active environment, generates a nonce via + * createClaimNonce(), opens browser to dashboard claim URL, and polls + * until the environment is claimed. + */ + +import open from 'opn'; +import clack from '../utils/clack.js'; +import { getActiveEnvironment, isUnclaimedEnvironment, markEnvironmentClaimed } from '../lib/config-store.js'; +import { createClaimNonce, UnclaimedEnvApiError } from '../lib/unclaimed-env-api.js'; +import { logInfo, logError } from '../utils/debug.js'; +import { isJsonMode, outputJson, exitWithError } from '../utils/output.js'; +import { sleep } from '../lib/helper-functions.js'; + +const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes +const POLL_INTERVAL_MS = 5_000; // 5 seconds +const MAX_CONSECUTIVE_FAILURES = 10; + +/** + * Run the claim flow. + */ +export async function runClaim(): Promise { + const activeEnv = getActiveEnvironment(); + + if (!activeEnv || !isUnclaimedEnvironment(activeEnv)) { + if (isJsonMode()) { + outputJson({ status: 'no_unclaimed_environment', message: 'No unclaimed environment found. Nothing to claim.' }); + } else { + clack.log.info('No unclaimed environment found. Nothing to claim.'); + } + return; + } + + // claimToken and clientId guaranteed present by UnclaimedEnvironmentConfig + + logInfo('[claim] Starting claim flow for environment:', activeEnv.name); + + try { + clack.log.step('Generating claim link...'); + + const result = await createClaimNonce(activeEnv.clientId, activeEnv.claimToken); + + if (result.alreadyClaimed) { + markEnvironmentClaimed(); + if (isJsonMode()) { + outputJson({ status: 'already_claimed', message: 'Environment already claimed!' }); + } else { + clack.log.success('Environment already claimed!'); + clack.log.info('Run `workos auth login` to connect your account.'); + } + return; + } + + const claimUrl = `https://dashboard.workos.com/claim?nonce=${result.nonce}`; + + if (isJsonMode()) { + outputJson({ status: 'claim_url', claimUrl, nonce: result.nonce }); + return; + } + + clack.log.info(`Open this URL to claim your environment:\n\n ${claimUrl}`); + + try { + open(claimUrl, { wait: false }); + clack.log.info('Browser opened automatically'); + } catch (openError) { + logError('[claim] Failed to open browser:', openError instanceof Error ? openError.message : String(openError)); + clack.log.info('Could not open browser — open the URL above manually.'); + } + + // Poll for claim completion + const spinner = clack.spinner(); + spinner.start('Waiting for claim...'); + + const startTime = Date.now(); + let consecutiveFailures = 0; + + while (Date.now() - startTime < POLL_TIMEOUT_MS) { + await sleep(POLL_INTERVAL_MS); + try { + const check = await createClaimNonce(activeEnv.clientId, activeEnv.claimToken); + if (check.alreadyClaimed) { + spinner.stop('Environment claimed!'); + markEnvironmentClaimed(); + clack.log.info('Run `workos auth login` to connect your account.'); + return; + } + consecutiveFailures = 0; + } catch (pollError) { + const statusCode = pollError instanceof UnclaimedEnvApiError ? pollError.statusCode : undefined; + if (statusCode === 401) { + // 401 means the server invalidated the claim token — this happens + // when the environment is claimed. Safe to promote to sandbox. + spinner.stop('Claim token is invalid or expired.'); + markEnvironmentClaimed(); + clack.log.warn('Run `workos auth login` to set up your environment.'); + return; + } + consecutiveFailures++; + logError('[claim] Poll error:', pollError instanceof Error ? pollError.message : 'Unknown'); + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + spinner.stop('Too many connection failures'); + clack.log.error( + `Polling failed ${consecutiveFailures} times in a row. Check your network and try again.\n` + + `You can also complete the claim at: ${claimUrl}`, + ); + return; + } + if (consecutiveFailures >= 3) { + spinner.message('Still waiting... (connection issues detected)'); + } + } + } + + spinner.stop('Claim timed out'); + clack.log.info('Complete the claim in your browser, then run `workos env list` to verify.'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logError('[claim] Error:', message); + exitWithError({ code: 'claim_failed', message: `Claim failed: ${message}` }); + } +} diff --git a/src/commands/debug.spec.ts b/src/commands/debug.spec.ts new file mode 100644 index 0000000..2f656eb --- /dev/null +++ b/src/commands/debug.spec.ts @@ -0,0 +1,548 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Mock credentials +const mockGetCredentials = vi.fn(); +const mockSaveCredentials = vi.fn(); +const mockClearCredentials = vi.fn(); +const mockIsTokenExpired = vi.fn(); +const mockDiagnoseCredentials = vi.fn(); +const mockGetCredentialsPath = vi.fn(() => '/home/user/.workos/credentials.json'); +const mockSetInsecureStorage = vi.fn(); + +vi.mock('../lib/credentials.js', () => ({ + getCredentials: (...args: unknown[]) => mockGetCredentials(...args), + saveCredentials: (...args: unknown[]) => mockSaveCredentials(...args), + clearCredentials: (...args: unknown[]) => mockClearCredentials(...args), + isTokenExpired: (...args: unknown[]) => mockIsTokenExpired(...args), + diagnoseCredentials: (...args: unknown[]) => mockDiagnoseCredentials(...args), + getCredentialsPath: (...args: unknown[]) => mockGetCredentialsPath(...args), + setInsecureStorage: (...args: unknown[]) => mockSetInsecureStorage(...args), +})); + +// Mock config store +const mockGetConfig = vi.fn(); +const mockSaveConfig = vi.fn(); +const mockClearConfig = vi.fn(); +const mockGetActiveEnvironment = vi.fn(); +const mockGetConfigPath = vi.fn(() => '/home/user/.workos/config.json'); +const mockSetInsecureConfigStorage = vi.fn(); +const mockDiagnoseConfig = vi.fn(); + +vi.mock('../lib/config-store.js', () => ({ + getConfig: (...args: unknown[]) => mockGetConfig(...args), + saveConfig: (...args: unknown[]) => mockSaveConfig(...args), + clearConfig: (...args: unknown[]) => mockClearConfig(...args), + getActiveEnvironment: (...args: unknown[]) => mockGetActiveEnvironment(...args), + getConfigPath: (...args: unknown[]) => mockGetConfigPath(...args), + setInsecureConfigStorage: (...args: unknown[]) => mockSetInsecureConfigStorage(...args), + diagnoseConfig: (...args: unknown[]) => mockDiagnoseConfig(...args), +})); + +// Mock output +let jsonMode = false; +vi.mock('../utils/output.js', () => ({ + isJsonMode: () => jsonMode, + outputJson: vi.fn((data: unknown) => console.log(JSON.stringify(data))), + outputError: vi.fn((err: { message: string }) => console.error(err.message)), + exitWithError: vi.fn((err: { message: string }) => { + throw new Error(err.message); + }), +})); + +// Mock clack +const mockConfirm = vi.fn(); +const mockIsCancel = vi.fn(() => false); +vi.mock('../utils/clack.js', () => ({ + default: { + confirm: (...args: unknown[]) => mockConfirm(...args), + isCancel: (...args: unknown[]) => mockIsCancel(...args), + log: { + info: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }, + }, +})); + +// Mock environment +const mockIsNonInteractive = vi.fn(() => false); +vi.mock('../utils/environment.js', () => ({ + isNonInteractiveEnvironment: () => mockIsNonInteractive(), +})); + +const { runDebugState, runDebugReset, runDebugSimulate, runDebugToken, runDebugEnv } = await import('./debug.js'); + +const makeCreds = (overrides = {}) => ({ + accessToken: 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.sig', + expiresAt: Date.now() + 3600_000, + userId: 'user_123', + email: 'test@example.com', + refreshToken: 'refresh_abc123', + ...overrides, +}); + +const makeConfig = (overrides = {}) => ({ + activeEnvironment: 'default', + environments: { + default: { + name: 'default', + type: 'sandbox' as const, + apiKey: 'sk_test_abc123def456', + clientId: 'client_123', + }, + }, + ...overrides, +}); + +describe('debug commands', () => { + let consoleOutput: string[]; + let consoleErrors: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + jsonMode = false; + consoleOutput = []; + consoleErrors = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + mockDiagnoseCredentials.mockReturnValue([ + 'file: /home/user/.workos/credentials.json (exists=true)', + 'keyring: found, userId=user_123, expired=false, hasRefreshToken=true', + 'insecureStorage=false', + ]); + mockDiagnoseConfig.mockReturnValue([ + 'file: /home/user/.workos/config.json (exists=true)', + 'keyring: found, active=default, environments=1', + 'insecureStorage=false', + ]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('debug state', () => { + it('outputs credentials and config when present', async () => { + mockGetCredentials.mockReturnValue(makeCreds()); + mockGetConfig.mockReturnValue(makeConfig()); + mockIsTokenExpired.mockReturnValue(false); + + await runDebugState({ showSecrets: false }); + + const output = consoleOutput.join('\n'); + expect(output).toContain('Credentials'); + expect(output).toContain('user_123'); + expect(output).toContain('Config'); + expect(output).toContain('sandbox'); + }); + + it('shows present: false when no credentials', async () => { + mockGetCredentials.mockReturnValue(null); + mockGetConfig.mockReturnValue(null); + mockDiagnoseCredentials.mockReturnValue([ + 'file: /home/user/.workos/credentials.json (exists=false)', + 'insecureStorage=false', + ]); + mockDiagnoseConfig.mockReturnValue([ + 'file: /home/user/.workos/config.json (exists=false)', + 'keyring: empty (getPassword returned null)', + 'insecureStorage=false', + ]); + + await runDebugState({ showSecrets: false }); + + const output = consoleOutput.join('\n'); + expect(output).toContain('false'); + }); + + it('redacts tokens and keys by default', async () => { + const creds = makeCreds(); + mockGetCredentials.mockReturnValue(creds); + mockGetConfig.mockReturnValue(makeConfig()); + mockIsTokenExpired.mockReturnValue(false); + + await runDebugState({ showSecrets: false }); + + const output = consoleOutput.join('\n'); + expect(output).not.toContain(creds.accessToken); + expect(output).toContain('****'); + }); + + it('shows full values with --show-secrets', async () => { + const creds = makeCreds(); + mockGetCredentials.mockReturnValue(creds); + mockGetConfig.mockReturnValue(makeConfig()); + mockIsTokenExpired.mockReturnValue(false); + + await runDebugState({ showSecrets: true }); + + const output = consoleOutput.join('\n'); + expect(output).toContain(creds.accessToken); + }); + + it('outputs valid JSON in json mode', async () => { + jsonMode = true; + mockGetCredentials.mockReturnValue(makeCreds()); + mockGetConfig.mockReturnValue(makeConfig()); + mockIsTokenExpired.mockReturnValue(false); + + await runDebugState({ showSecrets: false }); + + expect(consoleOutput).toHaveLength(1); + const parsed = JSON.parse(consoleOutput[0]); + expect(parsed.credentials.present).toBe(true); + expect(parsed.credentials.userId).toBe('user_123'); + expect(parsed.config.present).toBe(true); + expect(parsed.storage.credentialsPath).toBeDefined(); + }); + + it('shows correct storage source from diagnostics', async () => { + mockGetCredentials.mockReturnValue(makeCreds()); + mockGetConfig.mockReturnValue(makeConfig()); + mockIsTokenExpired.mockReturnValue(false); + + await runDebugState({ showSecrets: false }); + + const output = consoleOutput.join('\n'); + expect(output).toContain('keyring'); + }); + + it('shows file source when insecure storage', async () => { + mockGetCredentials.mockReturnValue(makeCreds()); + mockGetConfig.mockReturnValue(makeConfig()); + mockIsTokenExpired.mockReturnValue(false); + mockDiagnoseCredentials.mockReturnValue([ + 'file: /home/user/.workos/credentials.json (exists=true)', + 'insecureStorage=true', + ]); + mockDiagnoseConfig.mockReturnValue([ + 'file: /home/user/.workos/config.json (exists=true)', + 'insecureStorage=true', + ]); + + jsonMode = true; + await runDebugState({ showSecrets: false }); + + const parsed = JSON.parse(consoleOutput[0]); + expect(parsed.credentials.source).toBe('file'); + }); + }); + + describe('debug reset', () => { + it('clears both credentials and config by default', async () => { + mockConfirm.mockResolvedValue(true); + + await runDebugReset({ force: false, credentialsOnly: false, configOnly: false }); + + expect(mockClearCredentials).toHaveBeenCalled(); + expect(mockClearConfig).toHaveBeenCalled(); + }); + + it('--credentials-only clears only credentials', async () => { + mockConfirm.mockResolvedValue(true); + + await runDebugReset({ force: false, credentialsOnly: true, configOnly: false }); + + expect(mockClearCredentials).toHaveBeenCalled(); + expect(mockClearConfig).not.toHaveBeenCalled(); + }); + + it('--config-only clears only config', async () => { + mockConfirm.mockResolvedValue(true); + + await runDebugReset({ force: false, credentialsOnly: false, configOnly: true }); + + expect(mockClearConfig).toHaveBeenCalled(); + expect(mockClearCredentials).not.toHaveBeenCalled(); + }); + + it('--force skips confirmation', async () => { + await runDebugReset({ force: true, credentialsOnly: false, configOnly: false }); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockClearCredentials).toHaveBeenCalled(); + expect(mockClearConfig).toHaveBeenCalled(); + }); + + it('both --credentials-only and --config-only clears both', async () => { + mockConfirm.mockResolvedValue(true); + + await runDebugReset({ force: false, credentialsOnly: true, configOnly: true }); + + expect(mockClearCredentials).toHaveBeenCalled(); + expect(mockClearConfig).toHaveBeenCalled(); + }); + + it('errors in non-interactive mode without --force', async () => { + mockIsNonInteractive.mockReturnValue(true); + + await expect(runDebugReset({ force: false, credentialsOnly: false, configOnly: false })).rejects.toThrow( + 'Use --force to reset in non-interactive mode', + ); + }); + + it('outputs JSON on reset', async () => { + jsonMode = true; + + await runDebugReset({ force: true, credentialsOnly: false, configOnly: false }); + + const parsed = JSON.parse(consoleOutput[0]); + expect(parsed.cleared).toBe(true); + expect(parsed.credentials).toBe(true); + expect(parsed.config).toBe(true); + }); + }); + + describe('debug simulate', () => { + it('--expired-token sets expiresAt to the past', async () => { + const creds = makeCreds(); + mockGetCredentials.mockReturnValue(creds); + + await runDebugSimulate({ + expiredToken: true, + noKeyring: false, + unclaimed: false, + noAuth: false, + }); + + expect(mockSaveCredentials).toHaveBeenCalledWith(expect.objectContaining({ expiresAt: expect.any(Number) })); + const saved = mockSaveCredentials.mock.calls[0][0]; + expect(saved.expiresAt).toBeLessThan(Date.now()); + }); + + it('--no-auth clears credentials but preserves config', async () => { + await runDebugSimulate({ + expiredToken: false, + noKeyring: false, + unclaimed: false, + noAuth: true, + }); + + expect(mockClearCredentials).toHaveBeenCalled(); + expect(mockClearConfig).not.toHaveBeenCalled(); + }); + + it('--unclaimed writes unclaimed environment config', async () => { + mockGetConfig.mockReturnValue(null); + + await runDebugSimulate({ + expiredToken: false, + noKeyring: false, + unclaimed: true, + noAuth: false, + }); + + expect(mockSaveConfig).toHaveBeenCalledWith( + expect.objectContaining({ + activeEnvironment: 'simulated-unclaimed', + environments: expect.objectContaining({ + 'simulated-unclaimed': expect.objectContaining({ type: 'unclaimed' }), + }), + }), + ); + }); + + it('rejects contradictory --expired-token and --no-auth', async () => { + await expect( + runDebugSimulate({ + expiredToken: true, + noKeyring: false, + unclaimed: false, + noAuth: true, + }), + ).rejects.toThrow("can't expire a cleared token"); + }); + + it('requires at least one flag', async () => { + await expect( + runDebugSimulate({ + expiredToken: false, + noKeyring: false, + unclaimed: false, + noAuth: false, + }), + ).rejects.toThrow('Specify at least one simulation flag'); + }); + + it('allows combinable flags (--expired-token --no-keyring)', async () => { + const creds = makeCreds(); + const config = makeConfig(); + mockGetCredentials.mockReturnValue(creds); + mockGetConfig.mockReturnValue(config); + + await runDebugSimulate({ + expiredToken: true, + noKeyring: true, + unclaimed: false, + noAuth: false, + }); + + expect(mockSetInsecureStorage).toHaveBeenCalledWith(true); + expect(mockSetInsecureConfigStorage).toHaveBeenCalledWith(true); + expect(mockSaveCredentials).toHaveBeenCalled(); + // saveCredentials called twice: once for keyring migration, once for expired token + const lastCall = mockSaveCredentials.mock.calls[mockSaveCredentials.mock.calls.length - 1][0]; + expect(lastCall.expiresAt).toBeLessThan(Date.now()); + }); + + it('outputs JSON with actions', async () => { + jsonMode = true; + mockGetConfig.mockReturnValue(null); + + await runDebugSimulate({ + expiredToken: false, + noKeyring: false, + unclaimed: true, + noAuth: false, + }); + + const parsed = JSON.parse(consoleOutput[0]); + expect(parsed.simulated).toBe(true); + expect(parsed.actions).toHaveLength(1); + expect(parsed.actions[0]).toContain('unclaimed'); + }); + }); + + describe('debug token', () => { + it('decodes valid JWT and shows claims', async () => { + // Create a real JWT-like token + const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64url'); + const payload = Buffer.from( + JSON.stringify({ sub: 'user_123', exp: 9999999999, iss: 'https://api.workos.com' }), + ).toString('base64url'); + const token = `${header}.${payload}.fakesig`; + + mockGetCredentials.mockReturnValue(makeCreds({ accessToken: token })); + mockIsTokenExpired.mockReturnValue(false); + + jsonMode = true; + await runDebugToken(); + + const parsed = JSON.parse(consoleOutput[0]); + expect(parsed.present).toBe(true); + expect(parsed.format).toBe('jwt'); + expect(parsed.claims.sub).toBe('user_123'); + expect(parsed.claims.iss).toBe('https://api.workos.com'); + expect(parsed.refreshToken.present).toBe(true); + }); + + it('handles missing credentials', async () => { + mockGetCredentials.mockReturnValue(null); + + jsonMode = true; + await runDebugToken(); + + const parsed = JSON.parse(consoleOutput[0]); + expect(parsed.present).toBe(false); + }); + + it('handles opaque (non-JWT) tokens', async () => { + mockGetCredentials.mockReturnValue(makeCreds({ accessToken: 'opaque_token_value' })); + mockIsTokenExpired.mockReturnValue(false); + + jsonMode = true; + await runDebugToken(); + + const parsed = JSON.parse(consoleOutput[0]); + expect(parsed.present).toBe(true); + expect(parsed.format).toBe('opaque'); + expect(parsed.claims).toBeNull(); + }); + + it('shows correct expiry status when expired', async () => { + const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ sub: 'user_123', exp: 1000 })).toString('base64url'); + const token = `${header}.${payload}.sig`; + + mockGetCredentials.mockReturnValue(makeCreds({ accessToken: token, expiresAt: Date.now() - 60_000 })); + mockIsTokenExpired.mockReturnValue(true); + + jsonMode = true; + await runDebugToken(); + + const parsed = JSON.parse(consoleOutput[0]); + expect(parsed.expired).toBe(true); + expect(parsed.expiresIn).toContain('expired'); + }); + + it('shows human-readable output for JWT', async () => { + const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ sub: 'user_123', exp: 9999999999 })).toString('base64url'); + const token = `${header}.${payload}.sig`; + + mockGetCredentials.mockReturnValue(makeCreds({ accessToken: token })); + mockIsTokenExpired.mockReturnValue(false); + + await runDebugToken(); + + const output = consoleOutput.join('\n'); + expect(output).toContain('JWT Token'); + expect(output).toContain('Claims'); + expect(output).toContain('sub'); + }); + + it('shows human-readable output for opaque token', async () => { + mockGetCredentials.mockReturnValue(makeCreds({ accessToken: 'not-a-jwt' })); + mockIsTokenExpired.mockReturnValue(false); + + await runDebugToken(); + + const output = consoleOutput.join('\n'); + expect(output).toContain('Opaque Token'); + }); + }); + + describe('debug env', () => { + it('shows set env vars with values', async () => { + process.env.WORKOS_FORCE_TTY = '1'; + + await runDebugEnv(); + + const output = consoleOutput.join('\n'); + expect(output).toContain('WORKOS_FORCE_TTY'); + expect(output).toContain('1'); + + delete process.env.WORKOS_FORCE_TTY; + }); + + it('shows unset env vars with descriptions', async () => { + delete process.env.WORKOS_API_KEY; + + await runDebugEnv(); + + const output = consoleOutput.join('\n'); + expect(output).toContain('WORKOS_API_KEY'); + expect(output).toContain('Bypasses credential resolution'); + }); + + it('outputs valid JSON in json mode', async () => { + jsonMode = true; + process.env.WORKOS_NO_PROMPT = '1'; + + await runDebugEnv(); + + const parsed = JSON.parse(consoleOutput[0]); + expect(parsed.variables.WORKOS_NO_PROMPT.value).toBe('1'); + expect(parsed.set).toContain('WORKOS_NO_PROMPT'); + expect(parsed.unset).not.toContain('WORKOS_NO_PROMPT'); + + delete process.env.WORKOS_NO_PROMPT; + }); + + it('lists all known env vars', async () => { + jsonMode = true; + + await runDebugEnv(); + + const parsed = JSON.parse(consoleOutput[0]); + expect(Object.keys(parsed.variables)).toContain('WORKOS_API_KEY'); + expect(Object.keys(parsed.variables)).toContain('WORKOS_FORCE_TTY'); + expect(Object.keys(parsed.variables)).toContain('WORKOS_TELEMETRY'); + expect(Object.keys(parsed.variables)).toContain('INSTALLER_DEV'); + }); + }); +}); diff --git a/src/commands/debug.ts b/src/commands/debug.ts new file mode 100644 index 0000000..7e9a8a7 --- /dev/null +++ b/src/commands/debug.ts @@ -0,0 +1,429 @@ +import chalk from 'chalk'; +import clack from '../utils/clack.js'; +import { + getCredentials, + saveCredentials, + clearCredentials, + isTokenExpired, + diagnoseCredentials, + getCredentialsPath, + setInsecureStorage, +} from '../lib/credentials.js'; +import { + getConfig, + saveConfig, + clearConfig, + getConfigPath, + setInsecureConfigStorage, + diagnoseConfig, +} from '../lib/config-store.js'; +import { isJsonMode, outputJson, exitWithError } from '../utils/output.js'; +import { isNonInteractiveEnvironment } from '../utils/environment.js'; + +function maskSecret(value: string | undefined): string | undefined { + if (!value) return undefined; + if (value.length <= 8) return '****'; + return value.slice(0, 4) + '****' + value.slice(-4); +} + +function formatTimeRemaining(ms: number): string { + if (ms <= 0) return 'expired'; + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + if (hours > 0) return `${hours}h ${minutes % 60}m`; + if (minutes > 0) return `${minutes}m ${seconds % 60}s`; + return `${seconds}s`; +} + +function parseJwt(token: string): Record | null { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')); + } catch { + return null; + } +} + +function determineCredentialSource(diagnostics: string[]): 'keyring' | 'file' | 'none' { + const hasKeyring = diagnostics.some((l) => l.startsWith('keyring: found')); + const hasFile = diagnostics.some((l) => l.includes('exists=true')); + const isInsecure = diagnostics.some((l) => l.includes('insecureStorage=true')); + + if (isInsecure) return hasFile ? 'file' : 'none'; + if (hasKeyring) return 'keyring'; + if (hasFile) return 'file'; + return 'none'; +} + +// --- debug state --- + +export async function runDebugState({ showSecrets }: { showSecrets: boolean }): Promise { + const creds = getCredentials(); + const config = getConfig(); + const diagnostics = diagnoseCredentials(); + const credSource = determineCredentialSource(diagnostics); + const maybeRedact = showSecrets ? (v: string | undefined) => v : maskSecret; + + const credentialsOutput: Record = { present: !!creds, source: credSource }; + + if (creds) { + const timeRemaining = creds.expiresAt - Date.now(); + const expired = isTokenExpired(creds); + Object.assign(credentialsOutput, { + userId: creds.userId, + email: creds.email ?? null, + accessToken: maybeRedact(creds.accessToken), + refreshToken: creds.refreshToken ? 'present' : 'absent', + expiresAt: creds.expiresAt, + expiresIn: expired + ? `expired ${formatTimeRemaining(-timeRemaining)} ago` + : `in ${formatTimeRemaining(timeRemaining)}`, + isExpired: expired, + }); + if (creds.staging) { + credentialsOutput.staging = { + clientId: creds.staging.clientId, + apiKey: maybeRedact(creds.staging.apiKey), + fetchedAt: creds.staging.fetchedAt, + }; + } + } + + const configOutput: Record = { present: !!config }; + + if (config) { + configOutput.activeEnvironment = config.activeEnvironment ?? null; + configOutput.environments = Object.fromEntries( + Object.entries(config.environments).map(([key, env]) => [ + key, + { + name: env.name, + type: env.type, + apiKey: maybeRedact(env.apiKey), + clientId: env.clientId ?? null, + endpoint: env.endpoint ?? null, + ...(env.type === 'unclaimed' && { claimToken: maybeRedact(env.claimToken) }), + }, + ]), + ); + } + + const configDiagnostics = diagnoseConfig(); + const configSource = determineCredentialSource(configDiagnostics); + configOutput.source = configSource; + + const result = { + credentials: credentialsOutput, + config: configOutput, + storage: { + credentialsPath: getCredentialsPath(), + configPath: getConfigPath(), + credentialDiagnostics: diagnostics, + configDiagnostics, + }, + }; + + if (isJsonMode()) { + outputJson(result); + return; + } + + console.log(chalk.bold('Credentials')); + console.log(` present: ${creds ? chalk.green('true') : chalk.yellow('false')}`); + console.log(` source: ${credSource}`); + if (creds) { + console.log(` userId: ${creds.userId}`); + console.log(` email: ${creds.email ?? chalk.dim('none')}`); + console.log(` token: ${maybeRedact(creds.accessToken)}`); + console.log(` refresh: ${creds.refreshToken ? 'present' : 'absent'}`); + const expired = isTokenExpired(creds); + const timeRemaining = creds.expiresAt - Date.now(); + console.log( + ` expires: ${expired ? chalk.red(`expired ${formatTimeRemaining(-timeRemaining)} ago`) : chalk.green(`in ${formatTimeRemaining(timeRemaining)}`)}`, + ); + if (creds.staging) { + console.log(` staging: clientId=${creds.staging.clientId} apiKey=${maybeRedact(creds.staging.apiKey)}`); + } + } + + console.log(); + console.log(chalk.bold('Config')); + console.log(` present: ${config ? chalk.green('true') : chalk.yellow('false')}`); + console.log(` source: ${configSource}`); + if (config) { + console.log(` active: ${config.activeEnvironment ?? chalk.dim('none')}`); + for (const [key, env] of Object.entries(config.environments)) { + console.log(` env[${key}]: type=${env.type} apiKey=${maybeRedact(env.apiKey)}`); + if (env.type === 'unclaimed') console.log(` claimToken=${maybeRedact(env.claimToken)}`); + } + } + + console.log(); + console.log(chalk.bold('Storage — Credentials')); + console.log(` path: ${getCredentialsPath()}`); + for (const line of diagnostics) { + console.log(` ${chalk.dim(line)}`); + } + + console.log(); + console.log(chalk.bold('Storage — Config')); + console.log(` path: ${getConfigPath()}`); + for (const line of configDiagnostics) { + console.log(` ${chalk.dim(line)}`); + } +} + +// --- debug reset --- + +export async function runDebugReset({ + force, + credentialsOnly, + configOnly, +}: { + force: boolean; + credentialsOnly: boolean; + configOnly: boolean; +}): Promise { + // Both flags = clear both (same as neither) + const clearCreds = !configOnly || credentialsOnly; + const clearConf = !credentialsOnly || configOnly; + + const targets = [clearCreds && 'credentials', clearConf && 'config'].filter(Boolean).join(' and '); + + if (!force) { + if (isNonInteractiveEnvironment()) { + exitWithError({ + code: 'non_interactive_reset', + message: 'Use --force to reset in non-interactive mode', + }); + } + + const confirmed = await clack.confirm({ + message: `Clear all ${targets}? This cannot be undone.`, + }); + + if (clack.isCancel(confirmed) || !confirmed) { + if (isJsonMode()) { + outputJson({ cleared: false, cancelled: true }); + } else { + clack.log.info('Reset cancelled'); + } + return; + } + } + + if (clearCreds) clearCredentials(); + if (clearConf) clearConfig(); + + if (isJsonMode()) { + outputJson({ cleared: true, credentials: clearCreds, config: clearConf }); + } else { + clack.log.success(`Cleared ${targets}`); + } +} + +// --- debug simulate --- + +export async function runDebugSimulate({ + expiredToken, + noKeyring, + unclaimed, + noAuth, +}: { + expiredToken: boolean; + noKeyring: boolean; + unclaimed: boolean; + noAuth: boolean; +}): Promise { + // Validate: at least one flag + if (!expiredToken && !noKeyring && !unclaimed && !noAuth) { + exitWithError({ + code: 'no_simulation_flags', + message: 'Specify at least one simulation flag: --expired-token, --no-keyring, --unclaimed, --no-auth', + }); + } + + // Validate: contradictory + if (expiredToken && noAuth) { + exitWithError({ + code: 'contradictory_flags', + message: "Cannot combine --expired-token and --no-auth (can't expire a cleared token)", + }); + } + + const actions: string[] = []; + + // Apply in order: storage tier first, then credential mutations, then config mutations + + if (noKeyring) { + // Migrate current state to file storage + const creds = getCredentials(); + const config = getConfig(); + + setInsecureStorage(true); + setInsecureConfigStorage(true); + + if (creds) saveCredentials(creds); + if (config) saveConfig(config); + + actions.push('Forced file-only storage (keyring bypassed)'); + } + + if (expiredToken) { + const creds = getCredentials(); + if (!creds) { + exitWithError({ + code: 'no_credentials', + message: 'Cannot simulate expired token — no credentials found. Log in first.', + }); + } + saveCredentials({ ...creds, expiresAt: Date.now() - 60_000 }); + actions.push('Set token expiresAt to 1 minute ago'); + } + + if (noAuth) { + clearCredentials(); + actions.push('Cleared credentials (config preserved)'); + } + + if (unclaimed) { + const config = getConfig() ?? { environments: {} }; + const envName = 'simulated-unclaimed'; + config.environments[envName] = { + name: envName, + type: 'unclaimed', + apiKey: 'sk_test_simulated_unclaimed_000000000000', + clientId: 'client_simulated', + claimToken: 'claim_simulated_token_000000000000', + }; + config.activeEnvironment = envName; + saveConfig(config); + actions.push(`Created unclaimed environment "${envName}" and set as active`); + } + + if (isJsonMode()) { + outputJson({ simulated: true, actions }); + } else { + for (const action of actions) { + clack.log.success(action); + } + } +} + +// --- debug env --- + +interface EnvVarInfo { + name: string; + value: string | undefined; + effect: string; +} + +const ENV_VAR_CATALOG: { name: string; effect: string }[] = [ + { name: 'WORKOS_API_KEY', effect: 'Bypasses credential resolution — used directly for API calls' }, + { name: 'WORKOS_CLIENT_ID', effect: 'Overrides client ID from settings' }, + { name: 'WORKOS_FORCE_TTY', effect: 'Forces human (non-JSON) output mode, even when piped' }, + { name: 'WORKOS_NO_PROMPT', effect: 'Forces non-interactive/JSON mode' }, + { name: 'WORKOS_TELEMETRY', effect: 'Set to "false" to disable telemetry' }, + { name: 'WORKOS_API_URL', effect: 'Overrides API base URL (default: https://api.workos.com)' }, + { name: 'WORKOS_DASHBOARD_URL', effect: 'Overrides dashboard URL (default: https://dashboard.workos.com)' }, + { name: 'WORKOS_AUTHKIT_DOMAIN', effect: 'Overrides AuthKit domain from settings' }, + { name: 'WORKOS_LLM_GATEWAY_URL', effect: 'Overrides LLM gateway URL from settings' }, + { name: 'INSTALLER_DEV', effect: 'Enables dev mode — loads .env.local at startup' }, + { name: 'INSTALLER_DISABLE_PROXY', effect: 'Disables the credential proxy for gateway auth' }, +]; + +export async function runDebugEnv(): Promise { + const vars: EnvVarInfo[] = ENV_VAR_CATALOG.map(({ name, effect }) => ({ + name, + value: process.env[name], + effect, + })); + + const setVars = vars.filter((v) => v.value !== undefined); + const unsetVars = vars.filter((v) => v.value === undefined); + + if (isJsonMode()) { + outputJson({ + variables: Object.fromEntries(vars.map((v) => [v.name, { value: v.value ?? null, effect: v.effect }])), + set: setVars.map((v) => v.name), + unset: unsetVars.map((v) => v.name), + }); + return; + } + + if (setVars.length > 0) { + console.log(chalk.bold('Set')); + for (const v of setVars) { + console.log(` ${chalk.green(v.name)}=${v.value}`); + console.log(` ${chalk.dim(v.effect)}`); + } + console.log(); + } + + console.log(chalk.bold(`Unset${setVars.length > 0 ? '' : ' (none active)'}`)); + for (const v of unsetVars) { + console.log(` ${chalk.dim(v.name)} — ${chalk.dim(v.effect)}`); + } +} + +// --- debug token --- + +export async function runDebugToken(): Promise { + const creds = getCredentials(); + + if (!creds) { + if (isJsonMode()) { + outputJson({ present: false }); + } else { + console.log(chalk.yellow('No credentials found')); + } + return; + } + + const claims = parseJwt(creds.accessToken); + const expired = isTokenExpired(creds); + const timeRemaining = creds.expiresAt - Date.now(); + + if (isJsonMode()) { + outputJson({ + present: true, + format: claims ? 'jwt' : 'opaque', + expired, + expiresAt: creds.expiresAt, + expiresIn: expired + ? `expired ${formatTimeRemaining(-timeRemaining)} ago` + : `in ${formatTimeRemaining(timeRemaining)}`, + claims: claims ?? null, + refreshToken: { present: !!creds.refreshToken }, + }); + return; + } + + if (claims) { + console.log(chalk.bold('JWT Token')); + console.log( + ` expires: ${expired ? chalk.red(`expired ${formatTimeRemaining(-timeRemaining)} ago`) : chalk.green(`in ${formatTimeRemaining(timeRemaining)}`)}`, + ); + console.log(); + console.log(chalk.bold('Claims')); + for (const [key, value] of Object.entries(claims)) { + if (key === 'exp' || key === 'iat' || key === 'nbf') { + const date = new Date((value as number) * 1000).toISOString(); + console.log(` ${key}: ${value} (${date})`); + } else { + console.log(` ${key}: ${JSON.stringify(value)}`); + } + } + } else { + console.log(chalk.bold('Opaque Token')); + console.log(chalk.dim(' Token is not a JWT — cannot decode claims')); + console.log( + ` expires: ${expired ? chalk.red(`expired ${formatTimeRemaining(-timeRemaining)} ago`) : chalk.green(`in ${formatTimeRemaining(timeRemaining)}`)}`, + ); + } + + console.log(); + console.log(` refresh token: ${creds.refreshToken ? chalk.green('present') : chalk.yellow('absent')}`); +} diff --git a/src/commands/env.ts b/src/commands/env.ts index a7ea781..6b545e2 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import clack from '../utils/clack.js'; -import { getConfig, saveConfig } from '../lib/config-store.js'; +import { getConfig, saveConfig, isUnclaimedEnvironment } from '../lib/config-store.js'; import type { CliConfig } from '../lib/config-store.js'; import { outputSuccess, outputJson, exitWithError, isJsonMode } from '../utils/output.js'; import { isNonInteractiveEnvironment } from '../utils/environment.js'; @@ -202,7 +202,9 @@ export async function runEnvList(): Promise { } // Human-mode table - const nameW = Math.max(6, ...entries.map(([k]) => k.length)) + 2; + const hasUnclaimed = entries.some(([, env]) => isUnclaimedEnvironment(env)); + const nameW = + Math.max(6, ...entries.map(([k, env]) => k.length + (isUnclaimedEnvironment(env) ? ' (unclaimed)'.length : 0))) + 2; const typeW = 12; const header = [ @@ -220,10 +222,17 @@ export async function runEnvList(): Promise { for (const [key, env] of entries) { const isActive = key === config.activeEnvironment; const marker = isActive ? chalk.green('▸ ') : ' '; - const name = isActive ? chalk.green(key.padEnd(nameW)) : key.padEnd(nameW); - const type = env.type === 'sandbox' ? 'Sandbox' : 'Production'; + const unclaimed = isUnclaimedEnvironment(env); + const displayName = unclaimed ? `${key} ${chalk.yellow('(unclaimed)')}` : key; + const name = isActive ? chalk.green(displayName.padEnd(nameW)) : displayName.padEnd(nameW); + const type = unclaimed ? 'Unclaimed' : env.type === 'sandbox' ? 'Sandbox' : 'Production'; const endpoint = env.endpoint || chalk.dim('default'); console.log([marker, name, type.padEnd(typeW), endpoint].join(' ')); } + + if (hasUnclaimed) { + console.log(''); + console.log(chalk.dim(' Run `workos claim` to keep this environment.')); + } } diff --git a/src/lib/__test-helpers__/mock-unclaimed-env-api-error.ts b/src/lib/__test-helpers__/mock-unclaimed-env-api-error.ts new file mode 100644 index 0000000..863a7fa --- /dev/null +++ b/src/lib/__test-helpers__/mock-unclaimed-env-api-error.ts @@ -0,0 +1,12 @@ +/** + * Shared mock for UnclaimedEnvApiError — used by claim.spec.ts and unclaimed-warning.spec.ts. + */ +export class MockUnclaimedEnvApiError extends Error { + constructor( + message: string, + public readonly statusCode?: number, + ) { + super(message); + this.name = 'UnclaimedEnvApiError'; + } +} diff --git a/src/lib/agent-interface.ts b/src/lib/agent-interface.ts index 29b8d75..b8108b3 100644 --- a/src/lib/agent-interface.ts +++ b/src/lib/agent-interface.ts @@ -15,7 +15,8 @@ import { getConfig } from './settings.js'; import { getCredentials, hasCredentials } from './credentials.js'; import { ensureValidToken } from './token-refresh.js'; import type { InstallerEventEmitter } from './events.js'; -import { startCredentialProxy, type CredentialProxyHandle } from './credential-proxy.js'; +import { startCredentialProxy, startClaimTokenProxy, type CredentialProxyHandle } from './credential-proxy.js'; +import { getActiveEnvironment, isUnclaimedEnvironment } from './config-store.js'; import { getAuthkitDomain, getCliAuthClientId } from './settings.js'; import type { SDKMessage, @@ -358,8 +359,21 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt // Gateway mode (existing behavior) const gatewayUrl = getLlmGatewayUrlFromHost(); - // Check/refresh authentication for production (unless skipping auth) - if (!options.skipAuth && !options.local) { + // Check for unclaimed environment — use claim token auth + const activeEnv = getActiveEnvironment(); + if (activeEnv && isUnclaimedEnvironment(activeEnv)) { + activeProxyHandle = await startClaimTokenProxy({ + upstreamUrl: gatewayUrl, + claimToken: activeEnv.claimToken, + clientId: activeEnv.clientId, + }); + + sdkEnv.ANTHROPIC_BASE_URL = activeProxyHandle.url; + delete sdkEnv.ANTHROPIC_AUTH_TOKEN; + authMode = `claim-token-proxy:${activeProxyHandle.url}→${gatewayUrl}`; + logInfo(`[agent-interface] Using claim token proxy for unclaimed environment`); + } else if (!options.skipAuth && !options.local) { + // Check/refresh authentication for production (unless skipping auth) if (!hasCredentials()) { throw new Error('Not authenticated. Run `workos auth login` to authenticate.'); } diff --git a/src/lib/config-store.spec.ts b/src/lib/config-store.spec.ts index f7f434e..b44acaf 100644 --- a/src/lib/config-store.spec.ts +++ b/src/lib/config-store.spec.ts @@ -74,8 +74,16 @@ vi.mock('node:os', async (importOriginal) => { }); // Now import config-store module (after mock is set up) -const { getConfig, saveConfig, clearConfig, getActiveEnvironment, setInsecureConfigStorage, getConfigPath } = - await import('./config-store.js'); +const { + getConfig, + saveConfig, + clearConfig, + getActiveEnvironment, + setInsecureConfigStorage, + getConfigPath, + isUnclaimedEnvironment, + markEnvironmentClaimed, +} = await import('./config-store.js'); import type { CliConfig, EnvironmentConfig } from './config-store.js'; describe('config-store', () => { @@ -343,4 +351,178 @@ describe('config-store', () => { expect(existsSync(configFile)).toBe(true); }); }); + + describe('unclaimed environment type', () => { + const unclaimedEnv: EnvironmentConfig = { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_oneshot', + clientId: 'client_01ONESHOT', + claimToken: 'ct_claim_token_abc', + }; + + const unclaimedConfig: CliConfig = { + activeEnvironment: 'unclaimed', + environments: { + unclaimed: unclaimedEnv, + }, + }; + + it('round-trips unclaimed config through file storage', () => { + saveConfig(unclaimedConfig); + const config = getConfig(); + expect(config).not.toBeNull(); + const env = config?.environments['unclaimed']; + expect(env?.type).toBe('unclaimed'); + expect(env?.claimToken).toBe('ct_claim_token_abc'); + expect(env?.clientId).toBe('client_01ONESHOT'); + expect(env?.apiKey).toBe('sk_test_oneshot'); + }); + + it('round-trips unclaimed config through keyring storage', () => { + setInsecureConfigStorage(false); + saveConfig(unclaimedConfig); + const config = getConfig(); + expect(config).not.toBeNull(); + const env = config?.environments['unclaimed']; + expect(env?.type).toBe('unclaimed'); + expect(env?.claimToken).toBe('ct_claim_token_abc'); + }); + + it('returns unclaimed environment from getActiveEnvironment', () => { + saveConfig(unclaimedConfig); + const env = getActiveEnvironment(); + expect(env).not.toBeNull(); + expect(env?.type).toBe('unclaimed'); + expect(env?.claimToken).toBe('ct_claim_token_abc'); + }); + + it('preserves claimToken alongside other optional fields', () => { + const envWithEndpoint: EnvironmentConfig = { + ...unclaimedEnv, + endpoint: 'http://localhost:8001', + }; + saveConfig({ + activeEnvironment: 'unclaimed', + environments: { unclaimed: envWithEndpoint }, + }); + const env = getActiveEnvironment(); + expect(env?.claimToken).toBe('ct_claim_token_abc'); + expect(env?.endpoint).toBe('http://localhost:8001'); + }); + + it('existing configs without claimToken remain valid', () => { + saveConfig(sampleConfig); + const env = getActiveEnvironment(); + expect(env).not.toBeNull(); + expect(env?.claimToken).toBeUndefined(); + }); + }); + + describe('isUnclaimedEnvironment', () => { + it('returns true for unclaimed type', () => { + expect(isUnclaimedEnvironment({ name: 'test', type: 'unclaimed', apiKey: 'sk_test' })).toBe(true); + }); + + it('returns false for production type', () => { + expect(isUnclaimedEnvironment({ name: 'test', type: 'production', apiKey: 'sk_test' })).toBe(false); + }); + + it('returns false for sandbox type', () => { + expect(isUnclaimedEnvironment({ name: 'test', type: 'sandbox', apiKey: 'sk_test' })).toBe(false); + }); + }); + + describe('markEnvironmentClaimed', () => { + it('renames environment from unclaimed to sandbox', () => { + saveConfig({ + activeEnvironment: 'unclaimed', + environments: { + unclaimed: { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }, + }, + }); + + markEnvironmentClaimed(); + + const config = getConfig(); + expect(config?.environments['unclaimed']).toBeUndefined(); + expect(config?.environments['sandbox']).toBeDefined(); + expect(config?.environments['sandbox'].type).toBe('sandbox'); + expect(config?.environments['sandbox'].name).toBe('sandbox'); + expect(config?.environments['sandbox'].claimToken).toBeUndefined(); + expect(config?.activeEnvironment).toBe('sandbox'); + }); + + it('does nothing when no config', () => { + // No config saved — should not throw + expect(() => markEnvironmentClaimed()).not.toThrow(); + }); + + it('does nothing when no active environment', () => { + saveConfig({ + environments: { unclaimed: { name: 'unclaimed', type: 'unclaimed', apiKey: 'sk_test' } }, + }); + + markEnvironmentClaimed(); + + const config = getConfig(); + // Type should remain unchanged since there's no activeEnvironment + expect(config?.environments['unclaimed'].type).toBe('unclaimed'); + }); + + it('does nothing when active environment is not unclaimed', () => { + saveConfig({ + activeEnvironment: 'production', + environments: { + production: { + name: 'production', + type: 'production', + apiKey: 'sk_live_xxx', + }, + }, + }); + + markEnvironmentClaimed(); + + const config = getConfig(); + expect(config?.environments['production'].type).toBe('production'); + expect(config?.environments['production'].apiKey).toBe('sk_live_xxx'); + }); + + it('does not overwrite existing sandbox environment on claim', () => { + saveConfig({ + activeEnvironment: 'unclaimed', + environments: { + sandbox: { + name: 'sandbox', + type: 'sandbox', + apiKey: 'sk_test_existing_sandbox', + }, + unclaimed: { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_oneshot', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }, + }, + }); + + markEnvironmentClaimed(); + + const config = getConfig(); + // Existing sandbox should be preserved + expect(config?.environments['sandbox'].apiKey).toBe('sk_test_existing_sandbox'); + // Unclaimed env should keep its key but change type + expect(config?.environments['unclaimed'].type).toBe('sandbox'); + expect(config?.environments['unclaimed'].claimToken).toBeUndefined(); + expect(config?.activeEnvironment).toBe('unclaimed'); + }); + }); }); diff --git a/src/lib/config-store.ts b/src/lib/config-store.ts index 4c35f88..2253909 100644 --- a/src/lib/config-store.ts +++ b/src/lib/config-store.ts @@ -15,14 +15,32 @@ import path from 'node:path'; import os from 'node:os'; import { logWarn } from '../utils/debug.js'; -export interface EnvironmentConfig { +interface BaseEnvironmentConfig { name: string; - type: 'production' | 'sandbox'; apiKey: string; - clientId?: string; endpoint?: string; } +export interface ClaimedEnvironmentConfig extends BaseEnvironmentConfig { + type: 'production' | 'sandbox'; + clientId?: string; +} + +export interface UnclaimedEnvironmentConfig extends BaseEnvironmentConfig { + type: 'unclaimed'; + clientId: string; + claimToken: string; +} + +export type EnvironmentConfig = ClaimedEnvironmentConfig | UnclaimedEnvironmentConfig; + +/** + * Type guard — narrows to UnclaimedEnvironmentConfig with required clientId and claimToken. + */ +export function isUnclaimedEnvironment(env: EnvironmentConfig): env is UnclaimedEnvironmentConfig { + return env.type === 'unclaimed'; +} + export interface CliConfig { activeEnvironment?: string; environments: Record; @@ -152,6 +170,14 @@ export function saveConfig(config: CliConfig): void { if (!writeToKeyring(config)) { showFallbackWarning(); writeToFile(config); + return; + } + + // Verify the keyring write is readable (guards against silent keyring failures + // where setPassword succeeds but getPassword returns null in the same process) + if (!readFromKeyring()) { + logWarn('Keyring write succeeded but read-back failed — falling back to file'); + writeToFile(config); } } @@ -170,3 +196,77 @@ export function getActiveEnvironment(): EnvironmentConfig | null { export function getConfigPath(): string { return getConfigFilePath(); } + +/** + * Diagnostic info about config storage state — for debugging config persistence failures. + */ +export function diagnoseConfig(): string[] { + const lines: string[] = []; + const filePath = getConfigFilePath(); + const filePresent = fileExists(); + + lines.push(`file: ${filePath} (exists=${filePresent})`); + + if (filePresent) { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(content) as Partial; + const envCount = parsed.environments ? Object.keys(parsed.environments).length : 0; + lines.push(`file config: active=${parsed.activeEnvironment ?? 'none'}, environments=${envCount}`); + } catch (e) { + lines.push(`file config: parse error — ${e instanceof Error ? e.message : String(e)}`); + } + } + + try { + const entry = getKeyringEntry(); + const data = entry.getPassword(); + if (data) { + const parsed = JSON.parse(data) as Partial; + const envCount = parsed.environments ? Object.keys(parsed.environments).length : 0; + lines.push(`keyring: found, active=${parsed.activeEnvironment ?? 'none'}, environments=${envCount}`); + } else { + lines.push('keyring: empty (getPassword returned null)'); + } + } catch (e) { + lines.push(`keyring: error — ${e instanceof Error ? e.message : String(e)}`); + } + + lines.push(`insecureStorage=${forceInsecureStorage}`); + return lines; +} + +/** + * Mark the active unclaimed environment as claimed. + * Updates type to 'sandbox', removes the claim token, and renames + * the environment key from 'unclaimed' to 'sandbox'. + */ +export function markEnvironmentClaimed(): void { + const config = getConfig(); + if (!config?.activeEnvironment) return; + const oldKey = config.activeEnvironment; + const env = config.environments[oldKey]; + if (env && env.type === 'unclaimed') { + // Pick a key that won't overwrite an existing environment + let newKey = 'sandbox'; + if (oldKey !== newKey && config.environments[newKey]) { + newKey = oldKey; // keep existing key if 'sandbox' is already taken + } + + const claimed: ClaimedEnvironmentConfig = { + name: newKey, + type: 'sandbox', + apiKey: env.apiKey, + clientId: env.clientId, + ...(env.endpoint && { endpoint: env.endpoint }), + }; + + if (oldKey !== newKey) { + delete config.environments[oldKey]; + } + config.environments[newKey] = claimed; + config.activeEnvironment = newKey; + + saveConfig(config); + } +} diff --git a/src/lib/credential-proxy.ts b/src/lib/credential-proxy.ts index 7b7b895..68f26d7 100644 --- a/src/lib/credential-proxy.ts +++ b/src/lib/credential-proxy.ts @@ -1,5 +1,5 @@ /** - * Lightweight HTTP proxy that injects credentials from file into requests. + * Lightweight HTTP proxy that injects credentials into upstream requests. * Includes lazy token refresh - refreshes proactively when token is expiring soon. */ @@ -42,6 +42,41 @@ export interface CredentialProxyHandle { stop: () => Promise; } +// Hop-by-hop headers that must not be forwarded by proxies (RFC 7230 §6.1) +const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); + +/** Copy headers, excluding hop-by-hop headers */ +function filterHeaders(headers: Record): http.OutgoingHttpHeaders { + const out: http.OutgoingHttpHeaders = {}; + for (const [key, value] of Object.entries(headers)) { + if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase()) && value !== undefined) { + out[key] = value; + } + } + return out; +} + +/** Build the upstream path, stripping the `beta` query param (unsupported by WorkOS LLM gateway) */ +function buildUpstreamPath(reqUrl: string | undefined, upstream: URL): string { + const requestPath = reqUrl || '/'; + const basePath = upstream.pathname.replace(/\/$/, ''); + const fullPath = basePath + requestPath; + const upstreamUrl = new URL(fullPath, upstream.origin); + const searchParams = new URLSearchParams(upstreamUrl.search); + searchParams.delete('beta'); + const queryString = searchParams.toString(); + return upstreamUrl.pathname + (queryString ? `?${queryString}` : ''); +} + // Module-level state for lazy refresh let refreshPromise: Promise | null = null; let refreshConfig: RefreshConfig | null = null; @@ -257,42 +292,11 @@ async function handleRequest( return; } - // Build upstream request options - // Concatenate paths properly - URL() would replace the base path with absolute paths - const requestPath = req.url || '/'; - const basePath = upstream.pathname.replace(/\/$/, ''); // Remove trailing slash - const fullPath = basePath + requestPath; - const upstreamUrl = new URL(fullPath, upstream.origin); - - const headers: http.OutgoingHttpHeaders = {}; - - // Copy headers, excluding hop-by-hop headers - const hopByHop = new Set([ - 'connection', - 'keep-alive', - 'proxy-authenticate', - 'proxy-authorization', - 'te', - 'trailer', - 'transfer-encoding', - 'upgrade', - ]); - - for (const [key, value] of Object.entries(req.headers)) { - if (!hopByHop.has(key.toLowerCase()) && value !== undefined) { - headers[key] = value; - } - } - - // Inject credentials + // Build upstream request + const headers = filterHeaders(req.headers); headers['authorization'] = `Bearer ${creds.accessToken}`; headers['host'] = upstream.host; - - // Strip beta=true query param - WorkOS LLM gateway doesn't support it - const searchParams = new URLSearchParams(upstreamUrl.search); - searchParams.delete('beta'); - const queryString = searchParams.toString(); - const finalPath = upstreamUrl.pathname + (queryString ? `?${queryString}` : ''); + const finalPath = buildUpstreamPath(req.url, upstream); const requestOptions: http.RequestOptions = { hostname: upstream.hostname, @@ -306,15 +310,7 @@ async function handleRequest( const transport = useHttps ? https : http; const proxyReq = transport.request(requestOptions, (proxyRes) => { - // Copy response headers - const responseHeaders: http.OutgoingHttpHeaders = {}; - for (const [key, value] of Object.entries(proxyRes.headers)) { - if (!hopByHop.has(key.toLowerCase()) && value !== undefined) { - responseHeaders[key] = value; - } - } - - res.writeHead(proxyRes.statusCode || 500, responseHeaders); + res.writeHead(proxyRes.statusCode || 500, filterHeaders(proxyRes.headers)); proxyRes.pipe(res); }); @@ -367,6 +363,80 @@ async function handleRequest( req.pipe(proxyReq); } +/** + * Start a lightweight proxy that injects claim token headers for unclaimed environments. + * No refresh logic — claim tokens are assumed valid for the duration of an install session. + */ +export async function startClaimTokenProxy(options: { + upstreamUrl: string; + claimToken: string; + clientId: string; +}): Promise { + const upstream = new URL(options.upstreamUrl); + const useHttps = upstream.protocol === 'https:'; + + const server = http.createServer(async (req, res) => { + const headers = filterHeaders(req.headers); + headers['x-workos-claim-token'] = options.claimToken; + headers['x-workos-client-id'] = options.clientId; + headers['host'] = upstream.host; + const finalPath = buildUpstreamPath(req.url, upstream); + + const transport = useHttps ? https : http; + + const proxyReq = transport.request( + { + hostname: upstream.hostname, + port: upstream.port || (useHttps ? 443 : 80), + path: finalPath, + method: req.method, + headers, + timeout: 120_000, + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode || 500, filterHeaders(proxyRes.headers)); + proxyRes.pipe(res); + }, + ); + + proxyReq.on('error', (err) => { + logError('[claim-token-proxy] Upstream error:', err.message); + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'proxy_error', message: err.message })); + } + }); + + proxyReq.on('timeout', () => { + proxyReq.destroy(); + if (!res.headersSent) { + res.writeHead(504, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'upstream_timeout', message: 'Upstream server timed out' })); + } + }); + + req.pipe(proxyReq); + }); + + const port = await new Promise((resolve, reject) => { + server.once('error', (err) => reject(err)); + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + if (addr && typeof addr === 'object') resolve(addr.port); + else reject(new Error('Failed to get server address')); + }); + }); + + const url = `http://127.0.0.1:${port}`; + logInfo(`[claim-token-proxy] Started on ${url}, forwarding to ${options.upstreamUrl}`); + + return { + port, + url, + stop: async () => stopServer(server), + }; +} + function stopServer(server: http.Server): Promise { return new Promise((resolve, reject) => { // Set a timeout for graceful shutdown diff --git a/src/lib/env-writer.ts b/src/lib/env-writer.ts index 5e9b1f3..096af73 100644 --- a/src/lib/env-writer.ts +++ b/src/lib/env-writer.ts @@ -34,6 +34,7 @@ interface EnvVars { WORKOS_REDIRECT_URI?: string; NEXT_PUBLIC_WORKOS_REDIRECT_URI?: string; WORKOS_COOKIE_PASSWORD?: string; + WORKOS_CLAIM_TOKEN?: string; } /** diff --git a/src/lib/resolve-install-credentials.spec.ts b/src/lib/resolve-install-credentials.spec.ts new file mode 100644 index 0000000..de78c0b --- /dev/null +++ b/src/lib/resolve-install-credentials.spec.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock config-store +const mockGetActiveEnvironment = vi.fn(); +const mockIsUnclaimedEnvironment = vi.fn(); +vi.mock('./config-store.js', () => ({ + getActiveEnvironment: (...args: unknown[]) => mockGetActiveEnvironment(...args), + isUnclaimedEnvironment: (...args: unknown[]) => mockIsUnclaimedEnvironment(...args), +})); + +// Mock credentials +const mockHasCredentials = vi.fn(); +vi.mock('./credentials.js', () => ({ + hasCredentials: () => mockHasCredentials(), +})); + +// Mock unclaimed-env-provision +const mockTryProvisionUnclaimedEnv = vi.fn(); +vi.mock('./unclaimed-env-provision.js', () => ({ + tryProvisionUnclaimedEnv: (...args: unknown[]) => mockTryProvisionUnclaimedEnv(...args), +})); + +const { resolveInstallCredentials } = await import('./resolve-install-credentials.js'); + +describe('resolveInstallCredentials', () => { + const mockAuthenticate = vi.fn(); + const originalEnv = process.env.WORKOS_API_KEY; + + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.WORKOS_API_KEY; + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.WORKOS_API_KEY = originalEnv; + } else { + delete process.env.WORKOS_API_KEY; + } + }); + + it('returns immediately when WORKOS_API_KEY env var is set', async () => { + process.env.WORKOS_API_KEY = 'sk_test_env'; + + await resolveInstallCredentials(undefined, undefined, undefined, mockAuthenticate); + + expect(mockGetActiveEnvironment).not.toHaveBeenCalled(); + expect(mockAuthenticate).not.toHaveBeenCalled(); + }); + + it('returns immediately when apiKey argument is provided', async () => { + await resolveInstallCredentials('sk_test_flag', undefined, undefined, mockAuthenticate); + + expect(mockGetActiveEnvironment).not.toHaveBeenCalled(); + expect(mockAuthenticate).not.toHaveBeenCalled(); + }); + + it('returns without auth when active env is unclaimed', async () => { + mockGetActiveEnvironment.mockReturnValue({ + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }); + mockIsUnclaimedEnvironment.mockReturnValue(true); + + await resolveInstallCredentials(undefined, undefined, undefined, mockAuthenticate); + + expect(mockAuthenticate).not.toHaveBeenCalled(); + expect(mockTryProvisionUnclaimedEnv).not.toHaveBeenCalled(); + }); + + it('returns without auth when active env has API key and OAuth credentials', async () => { + mockGetActiveEnvironment.mockReturnValue({ + type: 'sandbox', + apiKey: 'sk_test_xxx', + }); + mockIsUnclaimedEnvironment.mockReturnValue(false); + mockHasCredentials.mockReturnValue(true); + + await resolveInstallCredentials(undefined, undefined, undefined, mockAuthenticate); + + expect(mockAuthenticate).not.toHaveBeenCalled(); + }); + + it('authenticates when active env has API key but no gateway auth', async () => { + mockGetActiveEnvironment.mockReturnValue({ + type: 'sandbox', + apiKey: 'sk_test_xxx', + }); + mockIsUnclaimedEnvironment.mockReturnValue(false); + mockHasCredentials.mockReturnValue(false); + + await resolveInstallCredentials(undefined, undefined, undefined, mockAuthenticate); + + expect(mockAuthenticate).toHaveBeenCalled(); + }); + + it('skips auth when skipAuth is true and env has API key but no gateway auth', async () => { + mockGetActiveEnvironment.mockReturnValue({ + type: 'sandbox', + apiKey: 'sk_test_xxx', + }); + mockIsUnclaimedEnvironment.mockReturnValue(false); + mockHasCredentials.mockReturnValue(false); + + await resolveInstallCredentials(undefined, undefined, true, mockAuthenticate); + + expect(mockAuthenticate).not.toHaveBeenCalled(); + }); + + it('tries unclaimed provisioning when no active environment', async () => { + mockGetActiveEnvironment.mockReturnValue(null); + mockTryProvisionUnclaimedEnv.mockResolvedValue(true); + + await resolveInstallCredentials(undefined, '/test/dir', undefined, mockAuthenticate); + + expect(mockTryProvisionUnclaimedEnv).toHaveBeenCalledWith({ installDir: '/test/dir' }); + expect(mockAuthenticate).not.toHaveBeenCalled(); + }); + + it('falls back to auth when provisioning fails', async () => { + mockGetActiveEnvironment.mockReturnValue(null); + mockTryProvisionUnclaimedEnv.mockResolvedValue(false); + + await resolveInstallCredentials(undefined, '/test/dir', undefined, mockAuthenticate); + + expect(mockTryProvisionUnclaimedEnv).toHaveBeenCalled(); + expect(mockAuthenticate).toHaveBeenCalled(); + }); + + it('skips auth fallback when provisioning fails and skipAuth is true', async () => { + mockGetActiveEnvironment.mockReturnValue(null); + mockTryProvisionUnclaimedEnv.mockResolvedValue(false); + + await resolveInstallCredentials(undefined, undefined, true, mockAuthenticate); + + expect(mockAuthenticate).not.toHaveBeenCalled(); + }); + + it('uses process.cwd() when no installDir provided', async () => { + mockGetActiveEnvironment.mockReturnValue(null); + mockTryProvisionUnclaimedEnv.mockResolvedValue(true); + + await resolveInstallCredentials(undefined, undefined, undefined, mockAuthenticate); + + expect(mockTryProvisionUnclaimedEnv).toHaveBeenCalledWith({ installDir: process.cwd() }); + }); +}); diff --git a/src/lib/resolve-install-credentials.ts b/src/lib/resolve-install-credentials.ts new file mode 100644 index 0000000..13108e1 --- /dev/null +++ b/src/lib/resolve-install-credentials.ts @@ -0,0 +1,55 @@ +/** + * Resolve credentials for install flow. + * Priority: existing creds (env var, --api-key, active env) -> unclaimed env provisioning -> login fallback. + * + * The installer needs both API credentials (for WorkOS API calls) AND gateway auth + * (for the LLM agent). This function ensures both are available: + * - Unclaimed env: API key + claim token (claim token proxy handles gateway) + * - Logged-in user: API key + OAuth token (credential proxy handles gateway) + * - Direct mode: not handled here (resolved in agent-interface.ts via ANTHROPIC_API_KEY) + */ +export async function resolveInstallCredentials( + apiKey: string | undefined, + installDir: string | undefined, + skipAuth: boolean | undefined, + authenticate: () => Promise, +): Promise { + // Explicit API key from env var or flag — user handles gateway auth separately + const envApiKey = process.env.WORKOS_API_KEY; + if (envApiKey) return; + if (apiKey) return; + + try { + const { getActiveEnvironment, isUnclaimedEnvironment } = await import('./config-store.js'); + const { hasCredentials } = await import('./credentials.js'); + const activeEnv = getActiveEnvironment(); + + if (activeEnv?.apiKey) { + // Has API key — but does it have gateway auth? + if (isUnclaimedEnvironment(activeEnv)) { + // Unclaimed with claim token — claim token proxy will handle gateway + return; + } + if (hasCredentials()) { + // Has OAuth tokens — credential proxy will handle gateway + return; + } + // Has API key but no gateway auth — need to log in + if (!skipAuth) await authenticate(); + return; + } + + // No existing credentials — try unclaimed env provisioning + const { tryProvisionUnclaimedEnv } = await import('./unclaimed-env-provision.js'); + const dir = installDir ?? process.cwd(); + const provisioned = await tryProvisionUnclaimedEnv({ installDir: dir }); + if (!provisioned) { + // Unclaimed env provisioning failed — fall back to login + if (!skipAuth) await authenticate(); + } + } catch (error) { + const { logError } = await import('../utils/debug.js'); + logError('[resolve-install-credentials] Failed:', error instanceof Error ? error.message : String(error)); + throw error; + } +} diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index b7f47c1..e79b7d7 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -219,6 +219,12 @@ export async function runWithCore(options: InstallerOptions): Promise { const machineWithActors = installerMachine.provide({ actors: { checkAuthentication: fromPromise(async () => { + // Check for active environment with credentials (covers unclaimed environments) + const activeEnv = getActiveEnvironment(); + if (activeEnv?.clientId && activeEnv?.apiKey) { + return true; + } + const token = getAccessToken(); if (!token) { // This should rarely happen since bin.ts handles auth first diff --git a/src/lib/unclaimed-env-api.spec.ts b/src/lib/unclaimed-env-api.spec.ts new file mode 100644 index 0000000..184a38c --- /dev/null +++ b/src/lib/unclaimed-env-api.spec.ts @@ -0,0 +1,303 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../utils/debug.js', () => ({ + logInfo: vi.fn(), + logError: vi.fn(), +})); + +vi.mock('./api-key.js', () => ({ + resolveApiBaseUrl: vi.fn(() => 'https://api.workos.com'), +})); + +const { provisionUnclaimedEnvironment, createClaimNonce, UnclaimedEnvApiError } = + await import('./unclaimed-env-api.js'); +const { resolveApiBaseUrl } = await import('./api-key.js'); + +describe('unclaimed-env-api', () => { + const mockFetch = vi.fn(); + const originalFetch = globalThis.fetch; + + beforeEach(() => { + globalThis.fetch = mockFetch; + mockFetch.mockReset(); + vi.mocked(resolveApiBaseUrl).mockReturnValue('https://api.workos.com'); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + describe('provisionUnclaimedEnvironment', () => { + const validResponse = { + clientId: 'client_01ABC', + apiKey: 'sk_test_xyz', + claimToken: 'ct_token123', + authkitDomain: 'auth.example.com', + }; + + it('returns all 4 fields on success (camelCase)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => validResponse, + }); + + const result = await provisionUnclaimedEnvironment(); + + expect(result).toEqual(validResponse); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.workos.com/x/one-shot-environments', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }), + ); + }); + + it('handles snake_case response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + client_id: 'client_456', + api_key: 'sk_test_def', + claim_token: 'ct_snake', + authkit_domain: 'auth.snake.com', + }), + }); + + const result = await provisionUnclaimedEnvironment(); + + expect(result).toEqual({ + clientId: 'client_456', + apiKey: 'sk_test_def', + claimToken: 'ct_snake', + authkitDomain: 'auth.snake.com', + }); + }); + + it('prefers camelCase over snake_case when both present', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + clientId: 'camel_client', + apiKey: 'camel_key', + claimToken: 'camel_token', + authkitDomain: 'camel.domain', + client_id: 'snake_client', + api_key: 'snake_key', + claim_token: 'snake_token', + authkit_domain: 'snake.domain', + }), + }); + + const result = await provisionUnclaimedEnvironment(); + + expect(result).toEqual({ + clientId: 'camel_client', + apiKey: 'camel_key', + claimToken: 'camel_token', + authkitDomain: 'camel.domain', + }); + }); + + it('throws UnclaimedEnvApiError on 429 rate limit', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 429, + text: async () => 'Too Many Requests', + }); + + await expect(provisionUnclaimedEnvironment()).rejects.toThrow( + 'Rate limited. Please wait a moment and try again.', + ); + await expect( + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 429, + text: async () => '', + }) && provisionUnclaimedEnvironment(), + ).rejects.toThrow(UnclaimedEnvApiError); + }); + + it('throws UnclaimedEnvApiError on 500 server error', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => 'Internal Server Error', + }); + + await expect(provisionUnclaimedEnvironment()).rejects.toThrow('Server error: 500'); + }); + + it('throws UnclaimedEnvApiError with statusCode on HTTP errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 503, + text: async () => '', + }); + + try { + await provisionUnclaimedEnvironment(); + expect.unreachable('Should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(UnclaimedEnvApiError); + expect((err as InstanceType).statusCode).toBe(503); + } + }); + + it('throws UnclaimedEnvApiError on network error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network failed')); + + await expect(provisionUnclaimedEnvironment()).rejects.toThrow('Network error: Network failed'); + }); + + it('throws UnclaimedEnvApiError on timeout (AbortError)', async () => { + const abortError = new Error('Aborted'); + abortError.name = 'AbortError'; + mockFetch.mockRejectedValueOnce(abortError); + + await expect(provisionUnclaimedEnvironment()).rejects.toThrow('Request timed out.'); + }); + + it('throws when response is missing required fields', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ clientId: 'client_123' }), + }); + + await expect(provisionUnclaimedEnvironment()).rejects.toThrow('missing required fields'); + }); + + it('uses active environment endpoint when available', async () => { + vi.mocked(resolveApiBaseUrl).mockReturnValue('http://localhost:8001'); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => validResponse, + }); + + await provisionUnclaimedEnvironment(); + + expect(mockFetch).toHaveBeenCalledWith('http://localhost:8001/x/one-shot-environments', expect.anything()); + }); + }); + + describe('createClaimNonce', () => { + it('returns nonce on success', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ nonce: 'nonce_abc123', alreadyClaimed: false }), + }); + + const result = await createClaimNonce('client_01ABC', 'ct_token'); + + expect(result).toEqual({ nonce: 'nonce_abc123', alreadyClaimed: false }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.workos.com/x/one-shot-environments/claim-nonces', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: 'client_01ABC', claim_token: 'ct_token' }), + }), + ); + }); + + it('returns alreadyClaimed when environment is claimed (camelCase)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ alreadyClaimed: true }), + }); + + const result = await createClaimNonce('client_01ABC', 'ct_token'); + + expect(result).toEqual({ alreadyClaimed: true }); + }); + + it('returns alreadyClaimed when environment is claimed (snake_case)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ already_claimed: true }), + }); + + const result = await createClaimNonce('client_01ABC', 'ct_token'); + + expect(result).toEqual({ alreadyClaimed: true }); + }); + + it('throws UnclaimedEnvApiError on 401 (bad token)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: async () => 'Unauthorized', + }); + + await expect(createClaimNonce('client_01ABC', 'bad_token')).rejects.toThrow('Invalid claim token.'); + }); + + it('throws UnclaimedEnvApiError on 404 (bad client_id)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => 'Not Found', + }); + + await expect(createClaimNonce('bad_client', 'ct_token')).rejects.toThrow('Environment not found.'); + }); + + it('returns alreadyClaimed on 409 Conflict (claimed server-side)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 409, + text: async () => 'Conflict', + }); + + const result = await createClaimNonce('client_01ABC', 'ct_token'); + + expect(result).toEqual({ alreadyClaimed: true }); + }); + + it('throws UnclaimedEnvApiError on 429 rate limit', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 429, + text: async () => '', + }); + + await expect(createClaimNonce('client_01ABC', 'ct_token')).rejects.toThrow( + 'Rate limited. Please wait a moment and try again.', + ); + }); + + it('throws UnclaimedEnvApiError on server error', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => 'Internal Server Error', + }); + + await expect(createClaimNonce('client_01ABC', 'ct_token')).rejects.toThrow('Server error: 500'); + }); + + it('throws UnclaimedEnvApiError on network error', async () => { + mockFetch.mockRejectedValueOnce(new Error('DNS lookup failed')); + + await expect(createClaimNonce('client_01ABC', 'ct_token')).rejects.toThrow('Network error: DNS lookup failed'); + }); + + it('throws UnclaimedEnvApiError on timeout', async () => { + const abortError = new Error('Aborted'); + abortError.name = 'AbortError'; + mockFetch.mockRejectedValueOnce(abortError); + + await expect(createClaimNonce('client_01ABC', 'ct_token')).rejects.toThrow('Request timed out.'); + }); + + it('throws when response is missing nonce and not already claimed', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + await expect(createClaimNonce('client_01ABC', 'ct_token')).rejects.toThrow('missing nonce'); + }); + }); +}); diff --git a/src/lib/unclaimed-env-api.ts b/src/lib/unclaimed-env-api.ts new file mode 100644 index 0000000..a5faf78 --- /dev/null +++ b/src/lib/unclaimed-env-api.ts @@ -0,0 +1,185 @@ +/** + * Unclaimed Environment Provisioning API Client + * + * Provisions unauthenticated unclaimed environments and generates claim nonces. + * No authentication required for provisioning — claim tokens are used for + * subsequent claim operations. + */ + +import { logInfo, logError } from '../utils/debug.js'; +import { resolveApiBaseUrl } from './api-key.js'; + +export interface UnclaimedEnvProvisionResult { + clientId: string; + apiKey: string; + claimToken: string; + authkitDomain: string; +} + +export interface ClaimNonceResult { + nonce: string; + alreadyClaimed: false; +} + +export interface AlreadyClaimedResult { + alreadyClaimed: true; +} + +export type ClaimNonceResponse = ClaimNonceResult | AlreadyClaimedResult; + +export class UnclaimedEnvApiError extends Error { + constructor( + message: string, + public readonly statusCode?: number, + ) { + super(message); + this.name = 'UnclaimedEnvApiError'; + } +} + +const REQUEST_TIMEOUT_MS = 30_000; + +/** + * Provision a new unclaimed environment. No authentication required. + * + * @returns UnclaimedEnvProvisionResult containing clientId, apiKey, claimToken, and authkitDomain + * @throws UnclaimedEnvApiError on rate limit, network failure, timeout, or server error + */ +export async function provisionUnclaimedEnvironment(): Promise { + const url = `${resolveApiBaseUrl()}/x/one-shot-environments`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + logInfo('[unclaimed-env-api] Provisioning unclaimed environment:', url); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: controller.signal, + }); + + logInfo('[unclaimed-env-api] Response status:', res.status); + if (!res.ok) { + const text = await res.text().catch(() => ''); + logError('[unclaimed-env-api] Error response:', res.status, text); + + if (res.status === 429) { + throw new UnclaimedEnvApiError('Rate limited. Please wait a moment and try again.', 429); + } + + throw new UnclaimedEnvApiError(`Server error: ${res.status}`, res.status); + } + + const data = (await res.json()) as { + clientId?: string; + apiKey?: string; + claimToken?: string; + authkitDomain?: string; + client_id?: string; + api_key?: string; + claim_token?: string; + authkit_domain?: string; + }; + + // Handle both camelCase and snake_case responses (API may respond in either format) + const clientId = data.clientId ?? data.client_id; + const apiKey = data.apiKey ?? data.api_key; + const claimToken = data.claimToken ?? data.claim_token; + const authkitDomain = data.authkitDomain ?? data.authkit_domain; + + if (!clientId || !apiKey || !claimToken || !authkitDomain) { + logError('[unclaimed-env-api] Invalid response: missing required fields'); + throw new UnclaimedEnvApiError('Invalid response: missing required fields'); + } + + logInfo('[unclaimed-env-api] Unclaimed environment provisioned successfully'); + return { clientId, apiKey, claimToken, authkitDomain }; + } catch (error) { + if (error instanceof UnclaimedEnvApiError) throw error; + if (error instanceof Error && error.name === 'AbortError') { + logError('[unclaimed-env-api] Request timed out'); + throw new UnclaimedEnvApiError('Request timed out.'); + } + logError('[unclaimed-env-api] Network error:', error instanceof Error ? error.message : 'Unknown'); + throw new UnclaimedEnvApiError(`Network error: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Generate a claim nonce from a claim token + client ID. + * Returns { alreadyClaimed: true } if environment was already claimed. + * + * @param clientId - The client ID of the unclaimed environment + * @param claimToken - The claim token from provisioning + * @returns ClaimNonceResponse — either a nonce or already-claimed indicator + * @throws UnclaimedEnvApiError on invalid token, not found, or server error + */ +export async function createClaimNonce(clientId: string, claimToken: string): Promise { + const url = `${resolveApiBaseUrl()}/x/one-shot-environments/claim-nonces`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + logInfo('[unclaimed-env-api] Creating claim nonce:', url); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: clientId, claim_token: claimToken }), + signal: controller.signal, + }); + + logInfo('[unclaimed-env-api] Response status:', res.status); + if (!res.ok) { + const text = await res.text().catch(() => ''); + logError('[unclaimed-env-api] Error response:', res.status, text); + + if (res.status === 401) { + throw new UnclaimedEnvApiError('Invalid claim token.', 401); + } + if (res.status === 404) { + throw new UnclaimedEnvApiError('Environment not found.', 404); + } + if (res.status === 409) { + logInfo('[unclaimed-env-api] Environment already claimed (409)'); + return { alreadyClaimed: true }; + } + if (res.status === 429) { + throw new UnclaimedEnvApiError('Rate limited. Please wait a moment and try again.', 429); + } + + throw new UnclaimedEnvApiError(`Server error: ${res.status}`, res.status); + } + + const data = (await res.json()) as { + nonce?: string; + alreadyClaimed?: boolean; + already_claimed?: boolean; + }; + + const alreadyClaimed = data.alreadyClaimed ?? data.already_claimed; + if (alreadyClaimed) { + logInfo('[unclaimed-env-api] Environment already claimed'); + return { alreadyClaimed: true }; + } + + if (!data.nonce) { + logError('[unclaimed-env-api] Invalid response: missing nonce'); + throw new UnclaimedEnvApiError('Invalid response: missing nonce'); + } + + logInfo('[unclaimed-env-api] Claim nonce created successfully'); + return { nonce: data.nonce, alreadyClaimed: false }; + } catch (error) { + if (error instanceof UnclaimedEnvApiError) throw error; + if (error instanceof Error && error.name === 'AbortError') { + logError('[unclaimed-env-api] Request timed out'); + throw new UnclaimedEnvApiError('Request timed out.'); + } + logError('[unclaimed-env-api] Network error:', error instanceof Error ? error.message : 'Unknown'); + throw new UnclaimedEnvApiError(`Network error: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + clearTimeout(timeoutId); + } +} diff --git a/src/lib/unclaimed-env-provision.spec.ts b/src/lib/unclaimed-env-provision.spec.ts new file mode 100644 index 0000000..1758fd6 --- /dev/null +++ b/src/lib/unclaimed-env-provision.spec.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { existsSync, readFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// Mock debug utilities +vi.mock('../utils/debug.js', () => ({ + logInfo: vi.fn(), + logError: vi.fn(), +})); + +// Mock clack +const mockClack = { + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + step: vi.fn(), + success: vi.fn(), + }, +}; +vi.mock('../utils/clack.js', () => ({ default: mockClack })); + +// Mock config-store — track calls +const mockGetConfig = vi.fn(); +const mockSaveConfig = vi.fn(); +const mockGetActiveEnvironment = vi.fn(() => null); +vi.mock('./config-store.js', () => ({ + getConfig: (...args: unknown[]) => mockGetConfig(...args), + saveConfig: (...args: unknown[]) => mockSaveConfig(...args), + getActiveEnvironment: (...args: unknown[]) => mockGetActiveEnvironment(...args), +})); + +// Mock unclaimed-env-api +const mockProvisionUnclaimedEnvironment = vi.fn(); +vi.mock('./unclaimed-env-api.js', () => ({ + provisionUnclaimedEnvironment: (...args: unknown[]) => mockProvisionUnclaimedEnvironment(...args), + UnclaimedEnvApiError: class UnclaimedEnvApiError extends Error { + constructor( + message: string, + public readonly statusCode?: number, + ) { + super(message); + this.name = 'UnclaimedEnvApiError'; + } + }, +})); + +// Mock box utility +vi.mock('../utils/box.js', () => ({ + renderStderrBox: vi.fn(), +})); + +const { tryProvisionUnclaimedEnv } = await import('./unclaimed-env-provision.js'); + +describe('unclaimed-env-provision', () => { + let testDir: string; + + const validProvisionResult = { + clientId: 'client_01ABC', + apiKey: 'sk_test_oneshot', + claimToken: 'ct_token123', + authkitDomain: 'auth.example.com', + }; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'unclaimed-env-provision-test-')); + vi.clearAllMocks(); + mockGetConfig.mockReturnValue(null); + // Read-back after save should return the unclaimed env by default + mockGetActiveEnvironment.mockReturnValue({ + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_oneshot', + clientId: 'client_01ABC', + claimToken: 'ct_token123', + }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + describe('tryProvisionUnclaimedEnv', () => { + it('returns true on successful provisioning', async () => { + mockProvisionUnclaimedEnvironment.mockResolvedValueOnce(validProvisionResult); + + const result = await tryProvisionUnclaimedEnv({ installDir: testDir }); + + expect(result).toBe(true); + }); + + it('saves config with type unclaimed and sets as active', async () => { + mockProvisionUnclaimedEnvironment.mockResolvedValueOnce(validProvisionResult); + + await tryProvisionUnclaimedEnv({ installDir: testDir }); + + expect(mockSaveConfig).toHaveBeenCalledWith({ + environments: { + unclaimed: { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_oneshot', + clientId: 'client_01ABC', + claimToken: 'ct_token123', + }, + }, + activeEnvironment: 'unclaimed', + }); + }); + + it('preserves existing config environments', async () => { + mockGetConfig.mockReturnValue({ + activeEnvironment: 'production', + environments: { + production: { + name: 'production', + type: 'production', + apiKey: 'sk_live_existing', + }, + }, + }); + mockProvisionUnclaimedEnvironment.mockResolvedValueOnce(validProvisionResult); + + await tryProvisionUnclaimedEnv({ installDir: testDir }); + + expect(mockSaveConfig).toHaveBeenCalledWith( + expect.objectContaining({ + environments: expect.objectContaining({ + production: expect.objectContaining({ apiKey: 'sk_live_existing' }), + unclaimed: expect.objectContaining({ type: 'unclaimed' }), + }), + activeEnvironment: 'unclaimed', + }), + ); + }); + + it('writes .env.local with all credentials including cookie password and claim token', async () => { + mockProvisionUnclaimedEnvironment.mockResolvedValueOnce(validProvisionResult); + + await tryProvisionUnclaimedEnv({ installDir: testDir }); + + const envPath = join(testDir, '.env.local'); + expect(existsSync(envPath)).toBe(true); + const content = readFileSync(envPath, 'utf-8'); + expect(content).toContain('WORKOS_API_KEY=sk_test_oneshot'); + expect(content).toContain('WORKOS_CLIENT_ID=client_01ABC'); + expect(content).toContain('WORKOS_COOKIE_PASSWORD='); + expect(content).toContain('WORKOS_CLAIM_TOKEN=ct_token123'); + }); + + it('shows provisioning message to user', async () => { + mockProvisionUnclaimedEnvironment.mockResolvedValueOnce(validProvisionResult); + const { renderStderrBox } = await import('../utils/box.js'); + + await tryProvisionUnclaimedEnv({ installDir: testDir }); + + expect(renderStderrBox).toHaveBeenCalled(); + }); + + it('returns false when config read-back fails after save', async () => { + mockProvisionUnclaimedEnvironment.mockResolvedValueOnce(validProvisionResult); + // Read-back returns null — simulates keyring write that silently fails + mockGetActiveEnvironment.mockReturnValue(null); + + const result = await tryProvisionUnclaimedEnv({ installDir: testDir }); + + expect(result).toBe(false); + expect(mockSaveConfig).toHaveBeenCalled(); + expect(mockClack.log.warn).toHaveBeenCalledWith(expect.stringContaining('config storage may be unreliable')); + }); + + it('returns false on API failure (network error)', async () => { + mockProvisionUnclaimedEnvironment.mockRejectedValueOnce(new Error('Network error: DNS failed')); + + const result = await tryProvisionUnclaimedEnv({ installDir: testDir }); + + expect(result).toBe(false); + expect(mockSaveConfig).not.toHaveBeenCalled(); + }); + + it('returns false on API failure (rate limit)', async () => { + const { UnclaimedEnvApiError } = await import('./unclaimed-env-api.js'); + mockProvisionUnclaimedEnvironment.mockRejectedValueOnce( + new UnclaimedEnvApiError('Rate limited. Please wait a moment and try again.', 429), + ); + + const result = await tryProvisionUnclaimedEnv({ installDir: testDir }); + + expect(result).toBe(false); + expect(mockClack.log.warn).toHaveBeenCalledWith(expect.stringContaining('falling back to login')); + }); + + it('returns false on API failure (server error)', async () => { + mockProvisionUnclaimedEnvironment.mockRejectedValueOnce(new Error('Server error: 500')); + + const result = await tryProvisionUnclaimedEnv({ installDir: testDir }); + + expect(result).toBe(false); + }); + + it('writes redirect URI to .env.local when provided', async () => { + mockProvisionUnclaimedEnvironment.mockResolvedValueOnce(validProvisionResult); + + await tryProvisionUnclaimedEnv({ + installDir: testDir, + redirectUri: 'http://localhost:3000/callback', + redirectUriKey: 'NEXT_PUBLIC_WORKOS_REDIRECT_URI', + }); + + const content = readFileSync(join(testDir, '.env.local'), 'utf-8'); + expect(content).toContain('NEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/callback'); + }); + + it('uses WORKOS_REDIRECT_URI key by default when redirect URI provided', async () => { + mockProvisionUnclaimedEnvironment.mockResolvedValueOnce(validProvisionResult); + + await tryProvisionUnclaimedEnv({ + installDir: testDir, + redirectUri: 'http://localhost:3000/callback', + }); + + const content = readFileSync(join(testDir, '.env.local'), 'utf-8'); + expect(content).toContain('WORKOS_REDIRECT_URI=http://localhost:3000/callback'); + }); + }); +}); diff --git a/src/lib/unclaimed-env-provision.ts b/src/lib/unclaimed-env-provision.ts new file mode 100644 index 0000000..ef3eea7 --- /dev/null +++ b/src/lib/unclaimed-env-provision.ts @@ -0,0 +1,95 @@ +/** + * Unclaimed environment provisioning helper. + * + * Calls the unclaimed env API, saves credentials to config store as type 'unclaimed', + * and returns whether provisioning succeeded. Non-fatal — wraps everything in + * try/catch so install flow can fall back to login. + */ + +import chalk from 'chalk'; +import { provisionUnclaimedEnvironment, UnclaimedEnvApiError } from './unclaimed-env-api.js'; +import { getConfig, saveConfig, getActiveEnvironment } from './config-store.js'; +import type { CliConfig } from './config-store.js'; +import { writeEnvLocal } from './env-writer.js'; +import { logInfo, logError } from '../utils/debug.js'; +import { renderStderrBox } from '../utils/box.js'; +import clack from '../utils/clack.js'; + +export interface UnclaimedEnvProvisionOptions { + installDir: string; + /** Redirect URI key name varies by framework */ + redirectUriKey?: string; + /** Redirect URI value */ + redirectUri?: string; +} + +/** + * Try to provision an unclaimed environment. Non-fatal — returns true on success, + * false on any failure. + * + * On success: + * - Saves environment to config store as type 'unclaimed' + * - Sets it as active environment + * - Writes credentials (including cookie password and claim token) to .env.local + */ +export async function tryProvisionUnclaimedEnv(options: UnclaimedEnvProvisionOptions): Promise { + try { + logInfo('[unclaimed-env-provision] Attempting unclaimed environment provisioning'); + + const result = await provisionUnclaimedEnvironment(); + + // Write .env.local first — if this fails, config stays clean (no orphan entries) + const envVars: Record = { + WORKOS_API_KEY: result.apiKey, + WORKOS_CLIENT_ID: result.clientId, + WORKOS_CLAIM_TOKEN: result.claimToken, + }; + + if (options.redirectUri) { + const key = options.redirectUriKey ?? 'WORKOS_REDIRECT_URI'; + envVars[key] = options.redirectUri; + } + + writeEnvLocal(options.installDir, envVars); + + // Save to config store (after .env.local succeeds) + const config: CliConfig = getConfig() ?? { environments: {} }; + config.environments['unclaimed'] = { + name: 'unclaimed', + type: 'unclaimed', + apiKey: result.apiKey, + clientId: result.clientId, + claimToken: result.claimToken, + }; + config.activeEnvironment = 'unclaimed'; + saveConfig(config); + + // Verify config persisted — critical for `workos claim` in a later process + const readBack = getActiveEnvironment(); + if (!readBack || readBack.type !== 'unclaimed') { + logError('[unclaimed-env-provision] Config read-back failed after save — claim token may not persist'); + clack.log.warn('Environment provisioned but config storage may be unreliable. Falling back to login...'); + return false; + } + + logInfo('[unclaimed-env-provision] Unclaimed environment provisioned and saved'); + const inner = ` ✓ ${chalk.green('Environment provisioned')} — Run ${chalk.cyan('workos claim')} to keep it. `; + renderStderrBox(inner, chalk.green); + + return true; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logError('[unclaimed-env-provision] Failed:', message); + + if (error instanceof UnclaimedEnvApiError) { + if (error.statusCode === 429) { + clack.log.warn('WorkOS is busy, falling back to login...'); + } + } else { + // Non-API errors (filesystem, keyring) are unexpected — surface to user + clack.log.warn(`Could not set up environment: ${message}. Falling back to login...`); + } + + return false; + } +} diff --git a/src/lib/unclaimed-warning.spec.ts b/src/lib/unclaimed-warning.spec.ts new file mode 100644 index 0000000..301c237 --- /dev/null +++ b/src/lib/unclaimed-warning.spec.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock debug utilities +vi.mock('../utils/debug.js', () => ({ + logInfo: vi.fn(), + logError: vi.fn(), +})); + +// Mock output utilities +let jsonMode = false; +vi.mock('../utils/output.js', () => ({ + isJsonMode: () => jsonMode, +})); + +// Mock config-store +const mockGetActiveEnvironment = vi.fn(); +const mockIsUnclaimedEnvironment = vi.fn(); +const mockMarkEnvironmentClaimed = vi.fn(); +vi.mock('./config-store.js', () => ({ + getActiveEnvironment: (...args: unknown[]) => mockGetActiveEnvironment(...args), + isUnclaimedEnvironment: (...args: unknown[]) => mockIsUnclaimedEnvironment(...args), + markEnvironmentClaimed: (...args: unknown[]) => mockMarkEnvironmentClaimed(...args), +})); + +// Mock unclaimed-env-api +const mockCreateClaimNonce = vi.fn(); +import { MockUnclaimedEnvApiError } from './__test-helpers__/mock-unclaimed-env-api-error.js'; +vi.mock('./unclaimed-env-api.js', () => ({ + createClaimNonce: (...args: unknown[]) => mockCreateClaimNonce(...args), + UnclaimedEnvApiError: MockUnclaimedEnvApiError, +})); + +// Mock box utility +const mockRenderStderrBox = vi.fn(); +vi.mock('../utils/box.js', () => ({ + renderStderrBox: (...args: unknown[]) => mockRenderStderrBox(...args), +})); + +const { warnIfUnclaimed, resetUnclaimedWarningState } = await import('./unclaimed-warning.js'); + +describe('unclaimed-warning', () => { + beforeEach(() => { + vi.clearAllMocks(); + jsonMode = false; + resetUnclaimedWarningState(); + }); + + it('shows warning when active env is unclaimed', async () => { + mockGetActiveEnvironment.mockReturnValue({ + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + }); + mockIsUnclaimedEnvironment.mockReturnValue(true); + + await warnIfUnclaimed(); + + expect(mockRenderStderrBox).toHaveBeenCalled(); + }); + + it('does not show warning when active env is not unclaimed', async () => { + mockGetActiveEnvironment.mockReturnValue({ + name: 'production', + type: 'production', + apiKey: 'sk_live_xxx', + }); + mockIsUnclaimedEnvironment.mockReturnValue(false); + + await warnIfUnclaimed(); + + expect(mockRenderStderrBox).not.toHaveBeenCalled(); + }); + + it('does not show warning when no active env', async () => { + mockGetActiveEnvironment.mockReturnValue(null); + + await warnIfUnclaimed(); + + expect(mockRenderStderrBox).not.toHaveBeenCalled(); + }); + + it('shows warning only once per session (dedup)', async () => { + mockGetActiveEnvironment.mockReturnValue({ + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + }); + mockIsUnclaimedEnvironment.mockReturnValue(true); + + await warnIfUnclaimed(); + expect(mockRenderStderrBox).toHaveBeenCalledTimes(1); + await warnIfUnclaimed(); + + // Second call should not add any more output (dedup) + expect(mockRenderStderrBox).toHaveBeenCalledTimes(1); + }); + + it('suppresses warning in JSON mode', async () => { + jsonMode = true; + mockGetActiveEnvironment.mockReturnValue({ + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + }); + mockIsUnclaimedEnvironment.mockReturnValue(true); + + await warnIfUnclaimed(); + + expect(mockRenderStderrBox).not.toHaveBeenCalled(); + }); + + it('resetUnclaimedWarningState allows re-testing', async () => { + mockGetActiveEnvironment.mockReturnValue({ + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + }); + mockIsUnclaimedEnvironment.mockReturnValue(true); + + await warnIfUnclaimed(); + expect(mockRenderStderrBox).toHaveBeenCalledTimes(1); + + resetUnclaimedWarningState(); + await warnIfUnclaimed(); + // Should have doubled the output (warning shown again after reset) + expect(mockRenderStderrBox).toHaveBeenCalledTimes(2); + }); + + it('detects claimed status and updates config', async () => { + mockGetActiveEnvironment.mockReturnValue({ + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }); + mockIsUnclaimedEnvironment.mockReturnValue(true); + mockCreateClaimNonce.mockResolvedValue({ alreadyClaimed: true }); + + await warnIfUnclaimed(); + + expect(mockMarkEnvironmentClaimed).toHaveBeenCalled(); + expect(mockRenderStderrBox).not.toHaveBeenCalled(); + }); + + it('shows warning when claim check fails', async () => { + mockGetActiveEnvironment.mockReturnValue({ + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }); + mockIsUnclaimedEnvironment.mockReturnValue(true); + mockCreateClaimNonce.mockRejectedValue(new Error('Network error')); + + await warnIfUnclaimed(); + + expect(mockRenderStderrBox).toHaveBeenCalled(); + }); + + it('promotes to claimed when claim check returns 401', async () => { + mockGetActiveEnvironment.mockReturnValue({ + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }); + mockIsUnclaimedEnvironment.mockReturnValue(true); + mockCreateClaimNonce.mockRejectedValue(new MockUnclaimedEnvApiError('Invalid claim token.', 401)); + + await warnIfUnclaimed(); + + expect(mockMarkEnvironmentClaimed).toHaveBeenCalled(); + expect(mockRenderStderrBox).not.toHaveBeenCalled(); + }); + + it('never throws even if getActiveEnvironment throws', async () => { + mockGetActiveEnvironment.mockImplementation(() => { + throw new Error('Config store failure'); + }); + + // Should not throw + await expect(warnIfUnclaimed()).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/unclaimed-warning.ts b/src/lib/unclaimed-warning.ts new file mode 100644 index 0000000..24407ed --- /dev/null +++ b/src/lib/unclaimed-warning.ts @@ -0,0 +1,75 @@ +/** + * Unclaimed environment warning module. + * + * Shows a one-line stderr warning when the active environment is unclaimed. + * On first run, checks if the environment was claimed externally (e.g. via + * browser) and updates the local config if so. + * Never throws — all errors are caught to avoid blocking management commands. + */ + +import chalk from 'chalk'; +import { getActiveEnvironment, isUnclaimedEnvironment, markEnvironmentClaimed } from './config-store.js'; +import { createClaimNonce, UnclaimedEnvApiError } from './unclaimed-env-api.js'; +import { logError, logInfo } from '../utils/debug.js'; +import { isJsonMode } from '../utils/output.js'; +import { renderStderrBox } from '../utils/box.js'; + +let warningShownThisSession = false; +let claimCheckDoneThisSession = false; + +/** + * Show a warning if the active environment is unclaimed. + * Non-blocking — never throws. + */ +export async function warnIfUnclaimed(): Promise { + try { + const env = getActiveEnvironment(); + if (!env || !isUnclaimedEnvironment(env)) return; + + // Check once per session if the env was claimed externally + // claimToken and clientId guaranteed present by UnclaimedEnvironmentConfig + if (!claimCheckDoneThisSession) { + claimCheckDoneThisSession = true; + try { + const result = await createClaimNonce(env.clientId, env.claimToken); + if (result.alreadyClaimed) { + markEnvironmentClaimed(); + logInfo('[unclaimed-warning] Environment was claimed externally, config updated'); + return; + } + } catch (error) { + if (error instanceof UnclaimedEnvApiError && error.statusCode === 401) { + // 401 likely means the claim token was invalidated after the environment + // was claimed. We assume claimed and promote to sandbox. + markEnvironmentClaimed(); + logInfo('[unclaimed-warning] Claim token invalid/expired, removed'); + return; + } + // Log non-401 errors for diagnostics, then fall through to show warning + if (error instanceof UnclaimedEnvApiError) { + logError('[unclaimed-warning] Claim check failed:', error.statusCode, error.message); + } else { + logError('[unclaimed-warning] Claim check failed:', error instanceof Error ? error.message : String(error)); + } + } + } + + // Show warning once per session + if (warningShownThisSession) return; + warningShownThisSession = true; + + if (!isJsonMode()) { + const inner = ` ${chalk.yellow('⚠ Unclaimed environment')} — Run ${chalk.cyan('workos claim')} to keep your data. `; + renderStderrBox(inner, chalk.yellow); + } + } catch (error) { + // Never block command execution, but log for diagnostics + logError('[unclaimed-warning] Unexpected error:', error instanceof Error ? error.message : String(error)); + } +} + +/** Reset session state (for testing) */ +export function resetUnclaimedWarningState(): void { + warningShownThisSession = false; + claimCheckDoneThisSession = false; +} diff --git a/src/utils/box.ts b/src/utils/box.ts new file mode 100644 index 0000000..f5f93bf --- /dev/null +++ b/src/utils/box.ts @@ -0,0 +1,15 @@ +import type chalk from 'chalk'; +import { stripAnsii } from './string.js'; + +/** + * Render a one-line bordered box to stderr. + */ +export function renderStderrBox(inner: string, color: typeof chalk.yellow | typeof chalk.green): void { + const plainLen = stripAnsii(inner).length; + const border = '─'.repeat(plainLen); + console.error(''); + console.error(color(` ┌${border}┐`)); + console.error(color(' │') + inner + color('│')); + console.error(color(` └${border}┘`)); + console.error(''); +} diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index c1f6837..1b9e339 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -1034,6 +1034,21 @@ const commands: CommandSchema[] = [ }, ], }, + { + name: 'claim', + description: 'Claim an unclaimed WorkOS environment (link it to your account)', + options: [ + insecureStorageOpt, + { + name: 'json', + type: 'boolean', + description: 'Output in JSON format', + required: false, + default: false, + hidden: false, + }, + ], + }, // --- Workflow Commands --- { name: 'seed',