From 4002ce5445fdcbaf3adc3d087e58bff053cb4544 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 5 Mar 2026 15:20:08 -0600 Subject: [PATCH 01/12] feat!: move login/logout to auth subcommand, add auth status BREAKING CHANGE: `workos login` and `workos logout` are now `workos auth login` and `workos auth logout`. New `workos auth status` command shows current auth state: - Logged in email/userId - Token expiry (time remaining or how long ago it expired) - Refresh token presence - Active environment name and type - Supports --json for machine-readable output All error messages referencing `workos login` updated to `workos auth login`. --- src/bin.ts | 29 +++++++++------ src/commands/auth-status.ts | 60 ++++++++++++++++++++++++++++++++ src/commands/login.ts | 4 +-- src/doctor/checks/ai-analysis.ts | 6 ++-- src/lib/adapters/cli-adapter.ts | 2 +- src/lib/agent-interface.ts | 10 +++--- src/lib/credential-proxy.ts | 2 +- src/lib/ensure-auth.ts | 4 +-- src/lib/run-with-core.ts | 2 +- src/lib/token-refresh-client.ts | 2 +- src/lib/token-refresh.spec.ts | 2 +- src/lib/token-refresh.ts | 2 +- src/utils/exit-codes.ts | 2 +- src/utils/help-json.spec.ts | 5 +-- src/utils/help-json.ts | 9 +++-- 15 files changed, 108 insertions(+), 33 deletions(-) create mode 100644 src/commands/auth-status.ts diff --git a/src/bin.ts b/src/bin.ts index 87c1b24..a43fc20 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -188,16 +188,25 @@ yargs(rawArgs) describe: 'Output results as JSON (auto-enabled in non-TTY)', global: true, }) - .command('login', 'Authenticate with WorkOS via browser-based OAuth', insecureStorageOption, async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - const { runLogin } = await import('./commands/login.js'); - await runLogin(); - process.exit(0); - }) - .command('logout', 'Remove stored WorkOS credentials and tokens', insecureStorageOption, async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - const { runLogout } = await import('./commands/logout.js'); - await runLogout(); + .command('auth', 'Manage authentication (login, logout, status)', (yargs) => { + yargs.options(insecureStorageOption); + registerSubcommand(yargs, 'login', 'Authenticate with WorkOS via browser-based OAuth', (y) => y, async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runLogin } = await import('./commands/login.js'); + await runLogin(); + process.exit(0); + }); + registerSubcommand(yargs, 'logout', 'Remove stored WorkOS credentials and tokens', (y) => y, async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runLogout } = await import('./commands/logout.js'); + await runLogout(); + }); + registerSubcommand(yargs, 'status', 'Show current authentication status', (y) => y, async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runAuthStatus } = await import('./commands/auth-status.js'); + await runAuthStatus(); + }); + return yargs.demandCommand(1, 'Please specify an auth subcommand').strict(); }) .command( 'install-skill', diff --git a/src/commands/auth-status.ts b/src/commands/auth-status.ts new file mode 100644 index 0000000..cd5a156 --- /dev/null +++ b/src/commands/auth-status.ts @@ -0,0 +1,60 @@ +import clack from '../utils/clack.js'; +import { getCredentials, isTokenExpired } from '../lib/credentials.js'; +import { getActiveEnvironment } from '../lib/config-store.js'; +import { isJsonMode, outputJson } from '../utils/output.js'; + +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`; +} + +export async function runAuthStatus(): Promise { + const creds = getCredentials(); + const activeEnv = getActiveEnvironment(); + + if (!creds) { + if (isJsonMode()) { + outputJson({ authenticated: false }); + return; + } + clack.log.info('Not logged in'); + clack.log.info('Run `workos auth login` to authenticate'); + return; + } + + const expired = isTokenExpired(creds); + const timeRemaining = creds.expiresAt - Date.now(); + + if (isJsonMode()) { + outputJson({ + authenticated: true, + email: creds.email ?? null, + userId: creds.userId, + tokenExpired: expired, + tokenExpiresAt: new Date(creds.expiresAt).toISOString(), + tokenExpiresIn: expired ? null : formatTimeRemaining(timeRemaining), + hasRefreshToken: !!creds.refreshToken, + activeEnvironment: activeEnv ? { name: activeEnv.name, type: activeEnv.type } : null, + }); + return; + } + + clack.log.info(`Logged in as ${creds.email ?? creds.userId}`); + + if (expired) { + clack.log.warn(`Token expired ${formatTimeRemaining(-timeRemaining)} ago`); + } else { + clack.log.info(`Token expires in ${formatTimeRemaining(timeRemaining)}`); + } + + clack.log.info(`Refresh token: ${creds.refreshToken ? 'present' : 'absent'}`); + + if (activeEnv) { + clack.log.info(`Environment: ${activeEnv.name} (${activeEnv.type})`); + } +} diff --git a/src/commands/login.ts b/src/commands/login.ts index 5b73276..bb2ff6a 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -77,7 +77,7 @@ export async function runLogin(): Promise { if (getAccessToken()) { const creds = getCredentials(); clack.log.info(`Already logged in as ${creds?.email ?? 'unknown'}`); - clack.log.info('Run `workos logout` to log out'); + clack.log.info('Run `workos auth logout` to log out'); return; } @@ -91,7 +91,7 @@ export async function runLogin(): Promise { updateTokens(result.accessToken, result.expiresAt, result.refreshToken); logInfo('[login] Session refreshed via refresh token'); clack.log.info(`Already logged in as ${existingCreds.email ?? 'unknown'}`); - clack.log.info('Run `workos logout` to log out'); + clack.log.info('Run `workos auth logout` to log out'); return; } } catch { diff --git a/src/doctor/checks/ai-analysis.ts b/src/doctor/checks/ai-analysis.ts index 43f09bf..0b25b39 100644 --- a/src/doctor/checks/ai-analysis.ts +++ b/src/doctor/checks/ai-analysis.ts @@ -61,10 +61,10 @@ async function callModel(prompt: string, model: string): Promise { if (!creds) throw new Error('Not authenticated'); if (isTokenExpired(creds)) { - if (!creds.refreshToken) throw new Error('Session expired — run `workos login` to re-authenticate'); + if (!creds.refreshToken) throw new Error('Session expired — run `workos auth login` to re-authenticate'); const result = await refreshAccessToken(getAuthkitDomain(), getCliAuthClientId()); if (!result.success || !result.accessToken || !result.expiresAt) { - throw new Error('Session expired — run `workos login` to re-authenticate'); + throw new Error('Session expired — run `workos auth login` to re-authenticate'); } updateTokens(result.accessToken, result.expiresAt, result.refreshToken); creds = getCredentials()!; @@ -111,7 +111,7 @@ export async function checkAiAnalysis(context: AnalysisContext, options: { skipA process.stderr.write(` ${line}\n`); } process.stderr.write('\n'); - return skippedResult('Not authenticated — run `workos login` for AI-powered analysis'); + return skippedResult('Not authenticated — run `workos auth login` for AI-powered analysis'); } const startTime = Date.now(); diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index 085c346..9bed10f 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -406,7 +406,7 @@ export class CLIAdapter implements InstallerAdapter { // Add actionable hints for common errors if (message.includes('authentication') || message.includes('auth')) { - clack.log.info('Try running: workos logout && workos install'); + clack.log.info('Try running: workos auth logout && workos install'); } if (message.includes('ENOENT') || message.includes('not found')) { clack.log.info('Ensure you are in a project directory'); diff --git a/src/lib/agent-interface.ts b/src/lib/agent-interface.ts index 33a73bf..5c025ca 100644 --- a/src/lib/agent-interface.ts +++ b/src/lib/agent-interface.ts @@ -361,12 +361,12 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt // Check/refresh authentication for production (unless skipping auth) if (!options.skipAuth && !options.local) { if (!hasCredentials()) { - throw new Error('Not authenticated. Run `workos login` to authenticate.'); + throw new Error('Not authenticated. Run `workos auth login` to authenticate.'); } const creds = getCredentials(); if (!creds) { - throw new Error('Not authenticated. Run `workos login` to authenticate.'); + throw new Error('Not authenticated. Run `workos auth login` to authenticate.'); } // Check if we have refresh token capability and proxy is not disabled @@ -387,7 +387,7 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt onRefreshExpired: () => { logError('[agent-interface] Session expired, refresh token invalid'); options.emitter?.emit('error', { - message: 'Session expired. Run `workos login` to re-authenticate.', + message: 'Session expired. Run `workos auth login` to re-authenticate.', }); }, }, @@ -404,9 +404,9 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt // No refresh token OR proxy disabled - fall back to old behavior (5 min limit) if (!creds.refreshToken) { logWarn('[agent-interface] No refresh token available, session limited to 5 minutes'); - logWarn('[agent-interface] Run `workos login` to enable extended sessions'); + logWarn('[agent-interface] Run `workos auth login` to enable extended sessions'); options.emitter?.emit('status', { - message: 'Note: Run `workos login` to enable extended sessions', + message: 'Note: Run `workos auth login` to enable extended sessions', }); } else { logWarn('[agent-interface] Proxy disabled via INSTALLER_DISABLE_PROXY'); diff --git a/src/lib/credential-proxy.ts b/src/lib/credential-proxy.ts index 63638bf..7b7b895 100644 --- a/src/lib/credential-proxy.ts +++ b/src/lib/credential-proxy.ts @@ -251,7 +251,7 @@ async function handleRequest( res.end( JSON.stringify({ error: 'credentials_unavailable', - message: 'Not authenticated. Run `workos login` first.', + message: 'Not authenticated. Run `workos auth login` first.', }), ); return; diff --git a/src/lib/ensure-auth.ts b/src/lib/ensure-auth.ts index 8e0be91..728772f 100644 --- a/src/lib/ensure-auth.ts +++ b/src/lib/ensure-auth.ts @@ -77,7 +77,7 @@ export async function ensureAuthenticated(): Promise { if (refreshResult.errorType === 'invalid_grant') { clearCredentials(); if (isNonInteractiveEnvironment()) { - exitWithAuthRequired('Session expired. Run `workos login` in an interactive terminal to re-authenticate.'); + exitWithAuthRequired('Session expired. Run `workos auth login` in an interactive terminal to re-authenticate.'); } logInfo('[ensure-auth] Refresh token expired, triggering login'); await runLogin(); @@ -103,7 +103,7 @@ export async function ensureAuthenticated(): Promise { // Case 4: No refresh token available — clear stale creds, must login clearCredentials(); if (isNonInteractiveEnvironment()) { - exitWithAuthRequired('Session expired. Run `workos login` in an interactive terminal to re-authenticate.'); + exitWithAuthRequired('Session expired. Run `workos auth login` in an interactive terminal to re-authenticate.'); } logInfo('[ensure-auth] No refresh token, triggering login'); await runLogin(); diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 57bcbc8..b7f47c1 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -223,7 +223,7 @@ export async function runWithCore(options: InstallerOptions): Promise { if (!token) { // This should rarely happen since bin.ts handles auth first // But keep as safety net for programmatic usage - throw new Error('Not authenticated. Run `workos login` first.'); + throw new Error('Not authenticated. Run `workos auth login` first.'); } // Set telemetry from existing credentials diff --git a/src/lib/token-refresh-client.ts b/src/lib/token-refresh-client.ts index ad7fb1c..7952a07 100644 --- a/src/lib/token-refresh-client.ts +++ b/src/lib/token-refresh-client.ts @@ -73,7 +73,7 @@ export async function refreshAccessToken(authkitDomain: string, clientId: string if (errorData.error === 'invalid_grant') { return { success: false, - error: 'Session expired. Run `workos login` to re-authenticate.', + error: 'Session expired. Run `workos auth login` to re-authenticate.', errorType: 'invalid_grant', }; } diff --git a/src/lib/token-refresh.spec.ts b/src/lib/token-refresh.spec.ts index 2b88884..4f04e42 100644 --- a/src/lib/token-refresh.spec.ts +++ b/src/lib/token-refresh.spec.ts @@ -92,7 +92,7 @@ describe('token-refresh', () => { expect(result.success).toBe(false); expect(result.error).toContain('Session expired'); - expect(result.error).toContain('workos login'); + expect(result.error).toContain('workos auth login'); }); it('preserves credentials on valid token', async () => { diff --git a/src/lib/token-refresh.ts b/src/lib/token-refresh.ts index 709ece1..8430607 100644 --- a/src/lib/token-refresh.ts +++ b/src/lib/token-refresh.ts @@ -27,7 +27,7 @@ export async function ensureValidToken(): Promise { logInfo('[ensureValidToken] Token expired, re-authentication required'); return { success: false, - error: 'Session expired. Run `workos login` to re-authenticate.', + error: 'Session expired. Run `workos auth login` to re-authenticate.', }; } diff --git a/src/utils/exit-codes.ts b/src/utils/exit-codes.ts index 3cfc111..ab77f8f 100644 --- a/src/utils/exit-codes.ts +++ b/src/utils/exit-codes.ts @@ -30,6 +30,6 @@ export function exitWithCode(code: ExitCodeValue, error?: { code: string; messag export function exitWithAuthRequired(message?: string): never { exitWithCode(ExitCode.AUTH_REQUIRED, { code: 'auth_required', - message: message ?? 'Not authenticated. Run `workos login` in an interactive terminal, or set WORKOS_API_KEY.', + message: message ?? 'Not authenticated. Run `workos auth login` in an interactive terminal, or set WORKOS_API_KEY.', }); } diff --git a/src/utils/help-json.spec.ts b/src/utils/help-json.spec.ts index 08a9bf2..4744301 100644 --- a/src/utils/help-json.spec.ts +++ b/src/utils/help-json.spec.ts @@ -29,8 +29,9 @@ describe('help-json', () => { const names = (tree as { commands: { name: string }[] }).commands.map((c) => c.name); expect(names).toEqual( expect.arrayContaining([ - 'login', - 'logout', + 'auth login', + 'auth logout', + 'auth status', 'install-skill', 'doctor', 'env', diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 6d46bc4..f8a44cb 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -96,15 +96,20 @@ const paginationOpts: OptionSchema[] = [ const commands: CommandSchema[] = [ { - name: 'login', + name: 'auth login', description: 'Authenticate with WorkOS via browser-based OAuth', options: [insecureStorageOpt], }, { - name: 'logout', + name: 'auth logout', description: 'Remove stored WorkOS credentials and tokens', options: [insecureStorageOpt], }, + { + name: 'auth status', + description: 'Show current authentication status', + options: [insecureStorageOpt], + }, { name: 'install-skill', description: 'Install bundled AuthKit skills to coding agents (Claude Code, Codex, Cursor, Goose)', From bfa1b89a0437389da63d3aca3f95ac29f03b7a89 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 5 Mar 2026 15:24:32 -0600 Subject: [PATCH 02/12] chore: formatting and linting fixes --- src/bin.ts | 50 ++++++++++++++++++++++++++------------ src/lib/agent-interface.ts | 1 - src/lib/ensure-auth.ts | 4 ++- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index a43fc20..274a426 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -190,22 +190,40 @@ yargs(rawArgs) }) .command('auth', 'Manage authentication (login, logout, status)', (yargs) => { yargs.options(insecureStorageOption); - registerSubcommand(yargs, 'login', 'Authenticate with WorkOS via browser-based OAuth', (y) => y, async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - const { runLogin } = await import('./commands/login.js'); - await runLogin(); - process.exit(0); - }); - registerSubcommand(yargs, 'logout', 'Remove stored WorkOS credentials and tokens', (y) => y, async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - const { runLogout } = await import('./commands/logout.js'); - await runLogout(); - }); - registerSubcommand(yargs, 'status', 'Show current authentication status', (y) => y, async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - const { runAuthStatus } = await import('./commands/auth-status.js'); - await runAuthStatus(); - }); + registerSubcommand( + yargs, + 'login', + 'Authenticate with WorkOS via browser-based OAuth', + (y) => y, + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runLogin } = await import('./commands/login.js'); + await runLogin(); + process.exit(0); + }, + ); + registerSubcommand( + yargs, + 'logout', + 'Remove stored WorkOS credentials and tokens', + (y) => y, + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runLogout } = await import('./commands/logout.js'); + await runLogout(); + }, + ); + registerSubcommand( + yargs, + 'status', + 'Show current authentication status', + (y) => y, + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runAuthStatus } = await import('./commands/auth-status.js'); + await runAuthStatus(); + }, + ); return yargs.demandCommand(1, 'Please specify an auth subcommand').strict(); }) .command( diff --git a/src/lib/agent-interface.ts b/src/lib/agent-interface.ts index 5c025ca..557438c 100644 --- a/src/lib/agent-interface.ts +++ b/src/lib/agent-interface.ts @@ -3,7 +3,6 @@ * Uses Claude Agent SDK directly with WorkOS MCP server */ -import path from 'path'; import { getPackageRoot } from '../utils/paths.js'; import { debug, logInfo, logWarn, logError, initLogFile, getLogFilePath } from '../utils/debug.js'; import type { InstallerOptions } from '../utils/types.js'; diff --git a/src/lib/ensure-auth.ts b/src/lib/ensure-auth.ts index 728772f..f680635 100644 --- a/src/lib/ensure-auth.ts +++ b/src/lib/ensure-auth.ts @@ -77,7 +77,9 @@ export async function ensureAuthenticated(): Promise { if (refreshResult.errorType === 'invalid_grant') { clearCredentials(); if (isNonInteractiveEnvironment()) { - exitWithAuthRequired('Session expired. Run `workos auth login` in an interactive terminal to re-authenticate.'); + exitWithAuthRequired( + 'Session expired. Run `workos auth login` in an interactive terminal to re-authenticate.', + ); } logInfo('[ensure-auth] Refresh token expired, triggering login'); await runLogin(); From 2d78cf5fa926bae406230f39581573929a215bc0 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 5 Mar 2026 15:28:10 -0600 Subject: [PATCH 03/12] feat: add credential audit log for debugging auth loss Writes timestamped entries to ~/.workos/audit.log for every credential mutation: SAVE_CREDENTIALS, CLEAR_CREDENTIALS, UPDATE_TOKENS, and GET_CREDENTIALS (when returning null). Logs only non-sensitive metadata: email, userId, token expiry, refresh token presence, storage backend, and caller stack frame. Never logs tokens or API keys. Disabled automatically in test environments (VITEST). --- src/lib/credential-store.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/lib/credential-store.ts b/src/lib/credential-store.ts index 169c448..1360bd4 100644 --- a/src/lib/credential-store.ts +++ b/src/lib/credential-store.ts @@ -33,6 +33,33 @@ const ACCOUNT_NAME = 'credentials'; let fallbackWarningShown = false; let forceInsecureStorage = false; let migrationAttempted = false; +let auditEnabled = true; + +function getAuditLogPath(): string { + return path.join(os.homedir(), '.workos', 'audit.log'); +} + +function audit(action: string, detail?: string): void { + if (!auditEnabled || process.env.VITEST) return; + try { + const dir = path.join(os.homedir(), '.workos'); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + const timestamp = new Date().toISOString(); + const caller = new Error().stack?.split('\n')[2]?.trim() ?? 'unknown'; + const line = `[${timestamp}] ${action}${detail ? ` — ${detail}` : ''} (${caller})\n`; + fs.appendFileSync(getAuditLogPath(), line, { mode: 0o600 }); + } catch { + // Never fail on audit logging + } +} + +export function setAuditEnabled(value: boolean): void { + auditEnabled = value; +} + +export { getAuditLogPath }; export function setInsecureStorage(value: boolean): void { forceInsecureStorage = value; @@ -154,19 +181,25 @@ export function getCredentials(): Credentials | null { return fileCreds; } + audit('GET_CREDENTIALS', 'result=null (no credentials found)'); return null; } export function saveCredentials(creds: Credentials): void { + const backend = forceInsecureStorage ? 'file' : 'keyring'; + audit('SAVE_CREDENTIALS', `user=${creds.email ?? creds.userId}, expiresAt=${new Date(creds.expiresAt).toISOString()}, hasRefreshToken=${!!creds.refreshToken}, backend=${backend}`); + if (forceInsecureStorage) return writeToFile(creds); if (!writeToKeyring(creds)) { + audit('SAVE_CREDENTIALS', 'keyring write failed, falling back to file'); showFallbackWarning(); writeToFile(creds); } } export function clearCredentials(): void { + audit('CLEAR_CREDENTIALS', 'deleting from keyring and file'); deleteFromKeyring(); deleteFile(); migrationAttempted = false; @@ -175,9 +208,12 @@ export function clearCredentials(): void { export function updateTokens(accessToken: string, expiresAt: number, refreshToken?: string): void { const creds = getCredentials(); if (!creds) { + audit('UPDATE_TOKENS', 'failed — no existing credentials'); throw new Error('No existing credentials to update'); } + audit('UPDATE_TOKENS', `user=${creds.email ?? creds.userId}, newExpiresAt=${new Date(expiresAt).toISOString()}, tokenRotated=${!!refreshToken}`); + const updated: Credentials = { ...creds, accessToken, From e8b74d1cd4f8097fd597bcd8c4b904f8f5d0aa51 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 5 Mar 2026 15:31:43 -0600 Subject: [PATCH 04/12] fix: guard keyring operations behind insecure storage flag When forceInsecureStorage is true, all keyring read/write/delete operations are now no-ops. Previously, deleteFromKeyring() would still hit the real system keychain even in insecure-storage mode, meaning tests using setInsecureStorage(true) could wipe real credentials from the keychain. This was the root cause of credentials mysteriously vanishing after running tests. --- src/lib/config-store.ts | 3 +++ src/lib/credential-store.ts | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/lib/config-store.ts b/src/lib/config-store.ts index 4c35f88..007a987 100644 --- a/src/lib/config-store.ts +++ b/src/lib/config-store.ts @@ -84,6 +84,7 @@ function getKeyringEntry(): Entry { } function readFromKeyring(): CliConfig | null { + if (forceInsecureStorage) return null; try { const entry = getKeyringEntry(); const data = entry.getPassword(); @@ -96,6 +97,7 @@ function readFromKeyring(): CliConfig | null { } function writeToKeyring(config: CliConfig): boolean { + if (forceInsecureStorage) return false; try { const entry = getKeyringEntry(); entry.setPassword(JSON.stringify(config)); @@ -107,6 +109,7 @@ function writeToKeyring(config: CliConfig): boolean { } function deleteFromKeyring(): void { + if (forceInsecureStorage) return; try { const entry = getKeyringEntry(); entry.deletePassword(); diff --git a/src/lib/credential-store.ts b/src/lib/credential-store.ts index 1360bd4..8972eb8 100644 --- a/src/lib/credential-store.ts +++ b/src/lib/credential-store.ts @@ -40,7 +40,7 @@ function getAuditLogPath(): string { } function audit(action: string, detail?: string): void { - if (!auditEnabled || process.env.VITEST) return; + if (!auditEnabled || process.env.VITEST || process.env.NODE_ENV === 'test') return; try { const dir = path.join(os.homedir(), '.workos'); if (!fs.existsSync(dir)) { @@ -110,6 +110,7 @@ function getKeyringEntry(): Entry { } function readFromKeyring(): Credentials | null { + if (forceInsecureStorage) return null; try { const entry = getKeyringEntry(); const data = entry.getPassword(); @@ -126,6 +127,7 @@ function readFromKeyring(): Credentials | null { } function writeToKeyring(creds: Credentials): boolean { + if (forceInsecureStorage) return false; try { const entry = getKeyringEntry(); entry.setPassword(JSON.stringify(creds)); @@ -138,6 +140,7 @@ function writeToKeyring(creds: Credentials): boolean { } function deleteFromKeyring(): void { + if (forceInsecureStorage) return; try { const entry = getKeyringEntry(); entry.deletePassword(); From c6450a63fd4b031487b2b0236d75558c6b666631 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 5 Mar 2026 15:36:18 -0600 Subject: [PATCH 05/12] Revert "fix: guard keyring operations behind insecure storage flag" This reverts commit e8b74d1cd4f8097fd597bcd8c4b904f8f5d0aa51. --- src/lib/config-store.ts | 3 --- src/lib/credential-store.ts | 5 +---- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/lib/config-store.ts b/src/lib/config-store.ts index 007a987..4c35f88 100644 --- a/src/lib/config-store.ts +++ b/src/lib/config-store.ts @@ -84,7 +84,6 @@ function getKeyringEntry(): Entry { } function readFromKeyring(): CliConfig | null { - if (forceInsecureStorage) return null; try { const entry = getKeyringEntry(); const data = entry.getPassword(); @@ -97,7 +96,6 @@ function readFromKeyring(): CliConfig | null { } function writeToKeyring(config: CliConfig): boolean { - if (forceInsecureStorage) return false; try { const entry = getKeyringEntry(); entry.setPassword(JSON.stringify(config)); @@ -109,7 +107,6 @@ function writeToKeyring(config: CliConfig): boolean { } function deleteFromKeyring(): void { - if (forceInsecureStorage) return; try { const entry = getKeyringEntry(); entry.deletePassword(); diff --git a/src/lib/credential-store.ts b/src/lib/credential-store.ts index 8972eb8..1360bd4 100644 --- a/src/lib/credential-store.ts +++ b/src/lib/credential-store.ts @@ -40,7 +40,7 @@ function getAuditLogPath(): string { } function audit(action: string, detail?: string): void { - if (!auditEnabled || process.env.VITEST || process.env.NODE_ENV === 'test') return; + if (!auditEnabled || process.env.VITEST) return; try { const dir = path.join(os.homedir(), '.workos'); if (!fs.existsSync(dir)) { @@ -110,7 +110,6 @@ function getKeyringEntry(): Entry { } function readFromKeyring(): Credentials | null { - if (forceInsecureStorage) return null; try { const entry = getKeyringEntry(); const data = entry.getPassword(); @@ -127,7 +126,6 @@ function readFromKeyring(): Credentials | null { } function writeToKeyring(creds: Credentials): boolean { - if (forceInsecureStorage) return false; try { const entry = getKeyringEntry(); entry.setPassword(JSON.stringify(creds)); @@ -140,7 +138,6 @@ function writeToKeyring(creds: Credentials): boolean { } function deleteFromKeyring(): void { - if (forceInsecureStorage) return; try { const entry = getKeyringEntry(); entry.deletePassword(); From 421393eb10be141902b18564af2651019fe8e107 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 5 Mar 2026 15:36:18 -0600 Subject: [PATCH 06/12] Revert "feat: add credential audit log for debugging auth loss" This reverts commit 2d78cf5fa926bae406230f39581573929a215bc0. --- src/lib/credential-store.ts | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/src/lib/credential-store.ts b/src/lib/credential-store.ts index 1360bd4..169c448 100644 --- a/src/lib/credential-store.ts +++ b/src/lib/credential-store.ts @@ -33,33 +33,6 @@ const ACCOUNT_NAME = 'credentials'; let fallbackWarningShown = false; let forceInsecureStorage = false; let migrationAttempted = false; -let auditEnabled = true; - -function getAuditLogPath(): string { - return path.join(os.homedir(), '.workos', 'audit.log'); -} - -function audit(action: string, detail?: string): void { - if (!auditEnabled || process.env.VITEST) return; - try { - const dir = path.join(os.homedir(), '.workos'); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - const timestamp = new Date().toISOString(); - const caller = new Error().stack?.split('\n')[2]?.trim() ?? 'unknown'; - const line = `[${timestamp}] ${action}${detail ? ` — ${detail}` : ''} (${caller})\n`; - fs.appendFileSync(getAuditLogPath(), line, { mode: 0o600 }); - } catch { - // Never fail on audit logging - } -} - -export function setAuditEnabled(value: boolean): void { - auditEnabled = value; -} - -export { getAuditLogPath }; export function setInsecureStorage(value: boolean): void { forceInsecureStorage = value; @@ -181,25 +154,19 @@ export function getCredentials(): Credentials | null { return fileCreds; } - audit('GET_CREDENTIALS', 'result=null (no credentials found)'); return null; } export function saveCredentials(creds: Credentials): void { - const backend = forceInsecureStorage ? 'file' : 'keyring'; - audit('SAVE_CREDENTIALS', `user=${creds.email ?? creds.userId}, expiresAt=${new Date(creds.expiresAt).toISOString()}, hasRefreshToken=${!!creds.refreshToken}, backend=${backend}`); - if (forceInsecureStorage) return writeToFile(creds); if (!writeToKeyring(creds)) { - audit('SAVE_CREDENTIALS', 'keyring write failed, falling back to file'); showFallbackWarning(); writeToFile(creds); } } export function clearCredentials(): void { - audit('CLEAR_CREDENTIALS', 'deleting from keyring and file'); deleteFromKeyring(); deleteFile(); migrationAttempted = false; @@ -208,12 +175,9 @@ export function clearCredentials(): void { export function updateTokens(accessToken: string, expiresAt: number, refreshToken?: string): void { const creds = getCredentials(); if (!creds) { - audit('UPDATE_TOKENS', 'failed — no existing credentials'); throw new Error('No existing credentials to update'); } - audit('UPDATE_TOKENS', `user=${creds.email ?? creds.userId}, newExpiresAt=${new Date(expiresAt).toISOString()}, tokenRotated=${!!refreshToken}`); - const updated: Credentials = { ...creds, accessToken, From 546a2614473604b331d97aabb91e2920fc6a5a07 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 5 Mar 2026 15:37:50 -0600 Subject: [PATCH 07/12] docs: update README auth commands to use auth subcommand --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7a4571c..d1b6e1f 100644 --- a/README.md +++ b/README.md @@ -470,10 +470,13 @@ The CLI uses WorkOS Connect OAuth device flow for authentication: ```bash # Login (opens browser for authentication) -workos login +workos auth login + +# Check current auth status +workos auth status # Logout (clears stored credentials) -workos logout +workos auth logout ``` OAuth credentials are stored in the system keychain (with `~/.workos/credentials.json` fallback). Access tokens are not persisted long-term for security - users re-authenticate when tokens expire. From 8a32c8e882217478ec53da0245e76aebed7b765b Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 5 Mar 2026 15:39:34 -0600 Subject: [PATCH 08/12] fix: use plain colored output for auth status instead of clack --- src/commands/auth-status.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/commands/auth-status.ts b/src/commands/auth-status.ts index cd5a156..c59e03b 100644 --- a/src/commands/auth-status.ts +++ b/src/commands/auth-status.ts @@ -1,4 +1,4 @@ -import clack from '../utils/clack.js'; +import chalk from 'chalk'; import { getCredentials, isTokenExpired } from '../lib/credentials.js'; import { getActiveEnvironment } from '../lib/config-store.js'; import { isJsonMode, outputJson } from '../utils/output.js'; @@ -22,8 +22,8 @@ export async function runAuthStatus(): Promise { outputJson({ authenticated: false }); return; } - clack.log.info('Not logged in'); - clack.log.info('Run `workos auth login` to authenticate'); + console.log(chalk.yellow('Not logged in')); + console.log(chalk.dim('Run `workos auth login` to authenticate')); return; } @@ -44,17 +44,17 @@ export async function runAuthStatus(): Promise { return; } - clack.log.info(`Logged in as ${creds.email ?? creds.userId}`); + console.log(chalk.green(`Logged in as ${creds.email ?? creds.userId}`)); if (expired) { - clack.log.warn(`Token expired ${formatTimeRemaining(-timeRemaining)} ago`); + console.log(chalk.yellow(`Token expired ${formatTimeRemaining(-timeRemaining)} ago`)); } else { - clack.log.info(`Token expires in ${formatTimeRemaining(timeRemaining)}`); + console.log(chalk.dim(`Token expires in ${formatTimeRemaining(timeRemaining)}`)); } - clack.log.info(`Refresh token: ${creds.refreshToken ? 'present' : 'absent'}`); + console.log(chalk.dim(`Refresh token: ${creds.refreshToken ? 'present' : 'absent'}`)); if (activeEnv) { - clack.log.info(`Environment: ${activeEnv.name} (${activeEnv.type})`); + console.log(chalk.dim(`Environment: ${activeEnv.name} (${activeEnv.type})`)); } } From cb34b0776dff427d7d1c4a596efccbaabd086da3 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 5 Mar 2026 15:43:08 -0600 Subject: [PATCH 09/12] fix: add blank line after update notice to separate from command output --- src/lib/version-check.spec.ts | 2 +- src/lib/version-check.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/version-check.spec.ts b/src/lib/version-check.spec.ts index 5c1d617..859bd26 100644 --- a/src/lib/version-check.spec.ts +++ b/src/lib/version-check.spec.ts @@ -33,7 +33,7 @@ describe('version-check', () => { await checkForUpdates(); expect(yellow).toHaveBeenCalledWith(expect.stringContaining('0.3.0 → 0.4.0')); - expect(dim).toHaveBeenCalledWith('Run: npx workos@latest'); + expect(dim).toHaveBeenCalledWith(expect.stringContaining('Run: npx workos@latest')); }); it('no warning when up to date', async () => { diff --git a/src/lib/version-check.ts b/src/lib/version-check.ts index 357f0d6..aca672a 100644 --- a/src/lib/version-check.ts +++ b/src/lib/version-check.ts @@ -37,7 +37,7 @@ export async function checkForUpdates(): Promise { if (lt(currentVersion, latestVersion)) { hasWarned = true; yellow(`Update available: ${currentVersion} → ${latestVersion}`); - dim(`Run: npx workos@latest`); + dim(`Run: npx workos@latest\n`); } } catch { // Silently ignore all errors (timeout, network, parse, etc.) From 0f5667b84514a0e0ba3c3d50e0a915f0f52ac952 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 5 Mar 2026 15:46:40 -0600 Subject: [PATCH 10/12] fix: use plain colored output for login already-authenticated messages --- src/commands/login.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/commands/login.ts b/src/commands/login.ts index bb2ff6a..6c612b0 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,4 +1,5 @@ import open from 'opn'; +import chalk from 'chalk'; import clack from '../utils/clack.js'; import { saveCredentials, getCredentials, getAccessToken, isTokenExpired, updateTokens } from '../lib/credentials.js'; import { getCliAuthClientId, getAuthkitDomain } from '../lib/settings.js'; @@ -76,8 +77,8 @@ export async function runLogin(): Promise { // Check if already logged in with valid token if (getAccessToken()) { const creds = getCredentials(); - clack.log.info(`Already logged in as ${creds?.email ?? 'unknown'}`); - clack.log.info('Run `workos auth logout` to log out'); + console.log(chalk.green(`Already logged in as ${creds?.email ?? 'unknown'}`)); + console.log(chalk.dim('Run `workos auth logout` to log out')); return; } @@ -90,8 +91,8 @@ export async function runLogin(): Promise { if (result.accessToken && result.expiresAt) { updateTokens(result.accessToken, result.expiresAt, result.refreshToken); logInfo('[login] Session refreshed via refresh token'); - clack.log.info(`Already logged in as ${existingCreds.email ?? 'unknown'}`); - clack.log.info('Run `workos auth logout` to log out'); + console.log(chalk.green(`Already logged in as ${existingCreds.email ?? 'unknown'}`)); + console.log(chalk.dim('Run `workos auth logout` to log out')); return; } } catch { From 6def86968702e4e7e07b0e77c28704d7660b77f6 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 5 Mar 2026 15:48:05 -0600 Subject: [PATCH 11/12] fix: suppress update notice in JSON mode to keep stdout clean --- src/bin.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 274a426..b9235a7 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -177,8 +177,8 @@ const installerOptions = { }, }; -// Check for updates (blocks up to 500ms) -await checkForUpdates(); +// Check for updates (blocks up to 500ms, skip in JSON mode to keep stdout clean) +if (!hasJsonFlag) await checkForUpdates(); yargs(rawArgs) .env('WORKOS_INSTALLER') From a700748fdb7da33a7d80e45baa8362ccda78139e Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 5 Mar 2026 16:08:10 -0600 Subject: [PATCH 12/12] =?UTF-8?q?fix:=20review=20fixes=20=E2=80=94=20isJso?= =?UTF-8?q?nMode=20guard,=20missed=20string=20ref,=20double=20newline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bin.ts: use isJsonMode() instead of hasJsonFlag to suppress update notice in all JSON contexts (including non-TTY auto-detection) - ensure-auth.ts: fix missed workos login → workos auth login at line 94 - version-check.ts: fix double blank line (console.log already adds \n) - Update CLAUDE.md, DEVELOPMENT.md, SKILL.md references --- CLAUDE.md | 2 +- DEVELOPMENT.md | 5 +++-- skills/workos-management/SKILL.md | 2 +- src/bin.ts | 4 ++-- src/lib/ensure-auth.ts | 2 +- src/lib/version-check.spec.ts | 2 +- src/lib/version-check.ts | 3 ++- 7 files changed, 11 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0651b85..1bf1b3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ WorkOS CLI for installing AuthKit integrations and managing WorkOS resources (or ## Non-TTY Behavior - **Output**: Auto-switches to JSON when piped or `--json` flag. `WORKOS_FORCE_TTY=1` overrides. -- **Auth**: Exits code 4 instead of opening browser. Requires prior `workos login` or `WORKOS_API_KEY` env var. +- **Auth**: Exits code 4 instead of opening browser. Requires prior `workos auth login` or `WORKOS_API_KEY` env var. - **Errors**: Structured JSON to stderr: `{ "error": { "code": "...", "message": "..." } }` - **Exit codes**: 0=success, 1=error, 2=cancelled, 4=auth required (follows `gh` CLI convention) - **Headless flags**: `--no-branch`, `--no-commit`, `--create-pr`, `--no-git-check` diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f4eadac..2eb83bd 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -26,8 +26,9 @@ src/ │ ├── user.ts # workos user (get/list/update/delete) │ ├── install.ts # workos install │ ├── install-skill.ts # workos install-skill -│ ├── login.ts # workos login -│ └── logout.ts # workos logout +│ ├── auth-status.ts # workos auth status +│ ├── login.ts # workos auth login +│ └── logout.ts # workos auth logout ├── dashboard/ # Ink/React TUI components ├── nextjs/ # Next.js installer agent ├── react/ # React SPA installer agent diff --git a/skills/workos-management/SKILL.md b/skills/workos-management/SKILL.md index 62bb81d..4d9a605 100644 --- a/skills/workos-management/SKILL.md +++ b/skills/workos-management/SKILL.md @@ -5,7 +5,7 @@ description: Manage WorkOS resources (orgs, users, roles, SSO, directories, webh # WorkOS Management Commands -Use these commands to manage WorkOS resources directly from the terminal. The CLI must be authenticated via `workos login` or `WORKOS_API_KEY` env var. +Use these commands to manage WorkOS resources directly from the terminal. The CLI must be authenticated via `workos auth login` or `WORKOS_API_KEY` env var. All commands support `--json` for structured output. Use `--json` when you need to parse output (e.g., extract an ID). diff --git a/src/bin.ts b/src/bin.ts index b9235a7..5891d0f 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -28,7 +28,7 @@ if (!satisfies(process.version, NODE_VERSION_RANGE)) { } import { isNonInteractiveEnvironment } from './utils/environment.js'; -import { resolveOutputMode, setOutputMode, outputJson, exitWithError } from './utils/output.js'; +import { resolveOutputMode, setOutputMode, isJsonMode, outputJson, exitWithError } from './utils/output.js'; import clack from './utils/clack.js'; import { registerSubcommand } from './utils/register-subcommand.js'; @@ -178,7 +178,7 @@ const installerOptions = { }; // Check for updates (blocks up to 500ms, skip in JSON mode to keep stdout clean) -if (!hasJsonFlag) await checkForUpdates(); +if (!isJsonMode()) await checkForUpdates(); yargs(rawArgs) .env('WORKOS_INSTALLER') diff --git a/src/lib/ensure-auth.ts b/src/lib/ensure-auth.ts index f680635..ba47ba8 100644 --- a/src/lib/ensure-auth.ts +++ b/src/lib/ensure-auth.ts @@ -91,7 +91,7 @@ export async function ensureAuthenticated(): Promise { // Network or server error - keep credentials intact for retry if (isNonInteractiveEnvironment()) { exitWithAuthRequired( - `Authentication refresh failed (${refreshResult.errorType}). Run \`workos login\` in an interactive terminal.`, + `Authentication refresh failed (${refreshResult.errorType}). Run \`workos auth login\` in an interactive terminal.`, ); } logInfo(`[ensure-auth] Refresh failed (${refreshResult.errorType}), triggering login`); diff --git a/src/lib/version-check.spec.ts b/src/lib/version-check.spec.ts index 859bd26..5c1d617 100644 --- a/src/lib/version-check.spec.ts +++ b/src/lib/version-check.spec.ts @@ -33,7 +33,7 @@ describe('version-check', () => { await checkForUpdates(); expect(yellow).toHaveBeenCalledWith(expect.stringContaining('0.3.0 → 0.4.0')); - expect(dim).toHaveBeenCalledWith(expect.stringContaining('Run: npx workos@latest')); + expect(dim).toHaveBeenCalledWith('Run: npx workos@latest'); }); it('no warning when up to date', async () => { diff --git a/src/lib/version-check.ts b/src/lib/version-check.ts index aca672a..4fc9c4e 100644 --- a/src/lib/version-check.ts +++ b/src/lib/version-check.ts @@ -37,7 +37,8 @@ export async function checkForUpdates(): Promise { if (lt(currentVersion, latestVersion)) { hasWarned = true; yellow(`Update available: ${currentVersion} → ${latestVersion}`); - dim(`Run: npx workos@latest\n`); + dim('Run: npx workos@latest'); + console.log(); } } catch { // Silently ignore all errors (timeout, network, parse, etc.)