From 2e19e35e25c774c530e25dc061158c4c409daa7c Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 28 May 2026 09:46:21 -0500 Subject: [PATCH 1/8] feat: add `vault run` for injecting Vault secrets into child processes Adds a new `workos vault run --secret ENV=name -- ` subcommand that fetches secrets from WorkOS Vault by name and injects them as environment variables into a spawned child process. The wrapper itself never prints secret values, so it can be used safely from AI coding agents (Codex, Claude Code, Cursor) where the value would otherwise end up in the model context. - Sequential fetch with fail-fast (no partial injection on error) - Error messages reference vault object names only, never values - `--dry-run` prints the env-var to vault-object mapping (no fetch) - `--env`, `--org`, and JSON mode supported - Forwards SIGINT/SIGTERM (and SIGBREAK on Windows) to the child - Exit code of the child is propagated to the wrapper --- src/bin.ts | 37 ++- src/commands/vault-run.spec.ts | 489 +++++++++++++++++++++++++++++++++ src/commands/vault-run.ts | 316 +++++++++++++++++++++ src/commands/vault.spec.ts | 24 +- src/commands/vault.ts | 32 ++- src/utils/help-json.ts | 35 +++ 6 files changed, 916 insertions(+), 17 deletions(-) create mode 100644 src/commands/vault-run.spec.ts create mode 100644 src/commands/vault-run.ts diff --git a/src/bin.ts b/src/bin.ts index 338f3e44..c579faae 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1990,7 +1990,7 @@ async function runCli(): Promise { y.options({ name: { type: 'string', demandOption: true }, value: { type: 'string', demandOption: true }, - org: { type: 'string' }, + org: { type: 'string', demandOption: true, describe: 'Organization ID (required for key context)' }, }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); @@ -2063,6 +2063,41 @@ async function runCli(): Promise { await runVaultListVersions(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); }, ); + registerSubcommand( + yargs, + 'run', + 'Run a command with Vault secrets injected as environment variables', + (y) => + y.options({ + secret: { + type: 'string', + array: true, + describe: 'Map a vault object to an env var: ENV_VAR=vault-name (repeatable)', + demandOption: true, + }, + env: { type: 'string', describe: 'Environment name to read API key from (defaults to active)' }, + org: { type: 'string', describe: 'Organization ID for org-scoped secrets' }, + 'dry-run': { type: 'boolean', default: false, describe: 'Print which secrets would be injected, no fetch' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + + const { resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runVaultRun } = await import('./commands/vault-run.js'); + const childCommand = (argv['--'] as string[] | undefined) ?? []; + await runVaultRun( + { + secrets: argv.secret as string[], + command: childCommand, + env: argv.env, + org: argv.org, + dryRun: argv.dryRun, + }, + argv.apiKey as string | undefined, + resolveApiBaseUrl(), + ); + }, + ); return yargs.demandCommand(1, 'Please specify a vault subcommand').strict(); }) .command('api-key', 'Manage API keys', (yargs) => { diff --git a/src/commands/vault-run.spec.ts b/src/commands/vault-run.spec.ts new file mode 100644 index 00000000..bcfa3b98 --- /dev/null +++ b/src/commands/vault-run.spec.ts @@ -0,0 +1,489 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { EventEmitter } from 'node:events'; + +// ---------- Mocks ---------- + +const mockSdk = { + vault: { + readObjectByName: vi.fn(), + }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), +})); + +const mockResolveApiKey = vi.fn(() => 'sk_test_resolved'); +const mockResolveApiBaseUrl = vi.fn(() => 'https://api.workos.com'); +vi.mock('../lib/api-key.js', () => ({ + resolveApiKey: (...args: unknown[]) => mockResolveApiKey(...(args as [])), + resolveApiBaseUrl: (...args: unknown[]) => mockResolveApiBaseUrl(...(args as [])), +})); + +const mockConfig = { + environments: {} as Record, +}; +vi.mock('../lib/config-store.js', () => ({ + getConfig: () => mockConfig, +})); + +vi.mock('child_process', () => ({ + spawn: vi.fn(), +})); +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), +})); + +// Replace exitWithError with a throwing mock so we can assert on the structured error. +// Keep the other exports intact (isJsonMode, outputJson, outputSuccess, outputError). +let outputModeState: 'human' | 'json' = 'human'; +const exitErrors: Array<{ code: string; message: string }> = []; +const successCalls: Array<{ message: string; data?: unknown }> = []; + +vi.mock('../utils/output.js', () => ({ + isJsonMode: () => outputModeState === 'json', + setOutputMode: (mode: 'human' | 'json') => { + outputModeState = mode; + }, + getOutputMode: () => outputModeState, + outputJson: vi.fn((data: unknown) => console.log(JSON.stringify(data))), + outputSuccess: vi.fn((message: string, data?: object) => { + successCalls.push({ message, data }); + if (outputModeState === 'json') { + const out: Record = { status: 'ok', message }; + if (data) out.data = data; + console.log(JSON.stringify(out)); + } else { + console.log(message); + if (data) console.log(JSON.stringify(data, null, 2)); + } + }), + outputError: vi.fn((err: { code: string; message: string }) => { + console.error(err.message); + }), + exitWithError: vi.fn((err: { code: string; message: string }) => { + exitErrors.push({ code: err.code, message: err.message }); + console.error(err.message); + throw new Error(`__EXIT__:${err.code}`); + }), +})); + +// ---------- Module under test ---------- + +const { spawn } = await import('node:child_process'); +const mockSpawn = vi.mocked(spawn); + +const { parseSecretMappings, fetchSecrets, runVaultRun } = await import('./vault-run.js'); + +// ---------- Helpers ---------- + +/** + * Mock child process. Does NOT auto-emit 'exit' — the test triggers it after + * spawn has been called so the child.on('exit') handler is registered first. + * + * `fireExit` swallows the throw from the mocked `process.exit` so the test + * call site doesn't see it escape synchronously. + */ +function createMockChild() { + const proc = new EventEmitter() as EventEmitter & { + kill: ReturnType; + killed: boolean; + fireExit: (code: number) => void; + }; + proc.kill = vi.fn(); + proc.killed = false; + proc.fireExit = (code: number) => { + try { + proc.emit('exit', code, null); + } catch (err) { + if (err instanceof Error && err.message.startsWith('__PROCESS_EXIT__:')) return; + throw err; + } + }; + return proc; +} + +/** + * Some code paths end with `process.exit(code)`. We install a per-test spy + * that records the code and resolves a deferred promise so the test can await + * the synchronous exit without the spy's throw escaping unhandled. + */ +function withSpawnExitCapture(): { + exitSpy: ReturnType; + exited: Promise; + restore: () => void; +} { + let resolveExit!: (code: number) => void; + const exited = new Promise((resolve) => { + resolveExit = resolve; + }); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + resolveExit(code ?? 0); + // Throw so callers don't continue past process.exit — same shape as real. + // The throw is caught by the synchronous `swallow`/awaited promise. + throw new Error(`__PROCESS_EXIT__:${code ?? 0}`); + }) as never); + return { + exitSpy, + exited, + restore: () => exitSpy.mockRestore(), + }; +} + +async function swallow(promise: Promise | unknown): Promise { + try { + await promise; + } catch (err) { + if (err instanceof Error && (err.message.startsWith('__EXIT__:') || err.message.startsWith('__PROCESS_EXIT__:'))) { + return; + } + throw err; + } +} + +// ---------- Tests ---------- + +describe('vault-run', () => { + let consoleLog: string[]; + let consoleErr: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleLog = []; + consoleErr = []; + exitErrors.length = 0; + successCalls.length = 0; + outputModeState = 'human'; + mockConfig.environments = {}; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleLog.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErr.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + outputModeState = 'human'; + }); + + describe('parseSecretMappings', () => { + it('parses a single valid mapping', () => { + const result = parseSecretMappings(['DB_URL=my-db-secret']); + expect(result).toEqual([{ envVar: 'DB_URL', vaultName: 'my-db-secret' }]); + }); + + it('parses multiple valid mappings', () => { + const result = parseSecretMappings(['DB_URL=db', 'API_KEY=api-key-name']); + expect(result).toEqual([ + { envVar: 'DB_URL', vaultName: 'db' }, + { envVar: 'API_KEY', vaultName: 'api-key-name' }, + ]); + }); + + it('preserves vault names that contain unusual characters (but not =)', () => { + const result = parseSecretMappings(['TOKEN=my/scoped-name.v2']); + expect(result).toEqual([{ envVar: 'TOKEN', vaultName: 'my/scoped-name.v2' }]); + }); + + it('exits on missing = separator', () => { + expect(() => parseSecretMappings(['DB_URL'])).toThrow(/__EXIT__/); + expect(exitErrors[0]).toMatchObject({ code: 'invalid_secret_format' }); + expect(exitErrors[0].message).toMatch(/Invalid secret mapping/); + }); + + it('exits on empty env var name', () => { + expect(() => parseSecretMappings(['=value'])).toThrow(/__EXIT__/); + expect(exitErrors[0]).toMatchObject({ code: 'invalid_secret_format' }); + }); + + it('exits on empty vault name', () => { + expect(() => parseSecretMappings(['DB_URL='])).toThrow(/__EXIT__/); + expect(exitErrors[0]).toMatchObject({ code: 'invalid_secret_format' }); + }); + + it('exits on duplicate env var names', () => { + expect(() => parseSecretMappings(['DB_URL=a', 'DB_URL=b'])).toThrow(/__EXIT__/); + expect(exitErrors[0]).toMatchObject({ code: 'duplicate_env_var' }); + expect(exitErrors[0].message).toMatch(/'DB_URL'/); + }); + + it('exits when no secrets provided', () => { + expect(() => parseSecretMappings([])).toThrow(/__EXIT__/); + expect(exitErrors[0]).toMatchObject({ code: 'missing_secrets' }); + }); + }); + + describe('fetchSecrets', () => { + it('fetches a single secret', async () => { + mockSdk.vault.readObjectByName.mockResolvedValueOnce({ + id: 'obj_1', + name: 'db', + value: 'secret-db-value', + metadata: {}, + }); + const result = await fetchSecrets([{ envVar: 'DB_URL', vaultName: 'db' }], 'sk_test'); + expect(result.get('DB_URL')).toBe('secret-db-value'); + expect(mockSdk.vault.readObjectByName).toHaveBeenCalledWith('db'); + }); + + it('fetches multiple secrets sequentially', async () => { + mockSdk.vault.readObjectByName + .mockResolvedValueOnce({ id: 'a', name: 'db', value: 'val-a', metadata: {} }) + .mockResolvedValueOnce({ id: 'b', name: 'api', value: 'val-b', metadata: {} }); + + const result = await fetchSecrets( + [ + { envVar: 'DB_URL', vaultName: 'db' }, + { envVar: 'API_KEY', vaultName: 'api' }, + ], + 'sk_test', + ); + expect(result.get('DB_URL')).toBe('val-a'); + expect(result.get('API_KEY')).toBe('val-b'); + expect(mockSdk.vault.readObjectByName).toHaveBeenCalledTimes(2); + }); + + it('exits when vault object lookup fails, naming the object but not the value', async () => { + mockSdk.vault.readObjectByName + .mockResolvedValueOnce({ id: 'a', name: 'db', value: 'leaky-value', metadata: {} }) + .mockRejectedValueOnce(Object.assign(new Error('Not Found'), { status: 404, requestID: 'req_1' })); + + await expect( + fetchSecrets( + [ + { envVar: 'DB_URL', vaultName: 'db' }, + { envVar: 'API_KEY', vaultName: 'missing-name' }, + ], + 'sk_test', + ), + ).rejects.toThrow(/__EXIT__/); + + const stderr = consoleErr.join('\n'); + expect(stderr).toMatch(/missing-name/); + expect(stderr).not.toMatch(/leaky-value/); + }); + + it('exits when readObjectByName returns no value', async () => { + mockSdk.vault.readObjectByName.mockResolvedValueOnce({ + id: 'obj_1', + name: 'db', + metadata: {}, + }); + await expect(fetchSecrets([{ envVar: 'DB_URL', vaultName: 'db' }], 'sk_test')).rejects.toThrow(/__EXIT__/); + expect(exitErrors[0]).toMatchObject({ code: 'vault_value_missing' }); + expect(exitErrors[0].message).toMatch(/db/); + }); + }); + + describe('runVaultRun — dry run', () => { + it('prints metadata table in human mode without spawning', async () => { + await runVaultRun({ + secrets: ['DB_URL=db', 'API_KEY=api-key'], + command: [], + dryRun: true, + }); + + expect(mockSpawn).not.toHaveBeenCalled(); + expect(mockSdk.vault.readObjectByName).not.toHaveBeenCalled(); + + const stdout = consoleLog.join('\n'); + expect(stdout).toMatch(/DB_URL/); + expect(stdout).toMatch(/db/); + expect(stdout).toMatch(/API_KEY/); + expect(stdout).toMatch(/api-key/); + }); + + it('emits JSON metadata in JSON mode without spawning', async () => { + outputModeState = 'json'; + await runVaultRun({ + secrets: ['DB_URL=db'], + command: [], + dryRun: true, + env: 'production', + org: 'org_123', + }); + + expect(mockSpawn).not.toHaveBeenCalled(); + const parsed = JSON.parse(consoleLog[0]); + expect(parsed.dryRun).toBe(true); + expect(parsed.env).toBe('production'); + expect(parsed.org).toBe('org_123'); + expect(parsed.mappings).toEqual([{ envVar: 'DB_URL', vaultName: 'db' }]); + }); + }); + + describe('runVaultRun — execution', () => { + it('spawns child with injected env vars and resolves the active env API key', async () => { + mockSdk.vault.readObjectByName.mockResolvedValueOnce({ + id: 'a', + name: 'db', + value: 'real-db-value', + metadata: {}, + }); + const child = createMockChild(); + mockSpawn.mockReturnValueOnce(child as never); + const { exited, restore } = withSpawnExitCapture(); + + await swallow( + runVaultRun({ + secrets: ['DB_URL=db'], + command: ['printenv', 'DB_URL'], + }), + ); + // runVaultRun has registered the spawn handler — now fire exit. + child.fireExit(0); + const code = await exited; + + expect(code).toBe(0); + expect(mockSpawn).toHaveBeenCalledTimes(1); + const [cmd, args, opts] = mockSpawn.mock.calls[0]; + expect(cmd).toBe('printenv'); + expect(args).toEqual(['DB_URL']); + const spawnOpts = opts as { env: NodeJS.ProcessEnv; stdio: string }; + expect(spawnOpts.stdio).toBe('inherit'); + expect(spawnOpts.env.DB_URL).toBe('real-db-value'); + restore(); + }); + + it('forwards child non-zero exit code', async () => { + mockSdk.vault.readObjectByName.mockResolvedValueOnce({ + id: 'a', + name: 'db', + value: 'val', + metadata: {}, + }); + const child = createMockChild(); + mockSpawn.mockReturnValueOnce(child as never); + const { exited, restore } = withSpawnExitCapture(); + + await swallow( + runVaultRun({ + secrets: ['DB_URL=db'], + command: ['some-tool'], + }), + ); + child.fireExit(42); + const code = await exited; + + expect(code).toBe(42); + restore(); + }); + + it('exits with usage error when no command is supplied', async () => { + await expect( + runVaultRun({ + secrets: ['DB_URL=db'], + command: [], + }), + ).rejects.toThrow(/__EXIT__/); + + expect(exitErrors[0]).toMatchObject({ code: 'missing_command' }); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('looks up the API key for a named environment via --env', async () => { + mockConfig.environments['staging'] = { apiKey: 'sk_staging' }; + mockSdk.vault.readObjectByName.mockResolvedValueOnce({ + id: 'a', + name: 'db', + value: 'val', + metadata: {}, + }); + const child = createMockChild(); + mockSpawn.mockReturnValueOnce(child as never); + const { exited, restore } = withSpawnExitCapture(); + + await swallow( + runVaultRun({ + secrets: ['DB_URL=db'], + command: ['echo'], + env: 'staging', + }), + ); + child.fireExit(0); + await exited; + + // resolveApiKey from api-key.js mock should not be called when --env is set. + expect(mockResolveApiKey).not.toHaveBeenCalled(); + restore(); + }); + + it('exits when --env names an unknown environment', async () => { + await expect( + runVaultRun({ + secrets: ['DB_URL=db'], + command: ['echo'], + env: 'no-such-env', + }), + ).rejects.toThrow(/__EXIT__/); + + expect(exitErrors[0]).toMatchObject({ code: 'env_not_found' }); + expect(exitErrors[0].message).toMatch(/no-such-env/); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + }); + + describe('JSON mode metadata on execution', () => { + beforeEach(() => { + outputModeState = 'json'; + }); + afterEach(() => { + outputModeState = 'human'; + }); + + it('emits an ok status with the injection metadata (never the value)', async () => { + mockSdk.vault.readObjectByName.mockResolvedValueOnce({ + id: 'a', + name: 'db', + value: 'top-secret-db', + metadata: {}, + }); + const child = createMockChild(); + mockSpawn.mockReturnValueOnce(child as never); + const { exited, restore } = withSpawnExitCapture(); + + await swallow( + runVaultRun({ + secrets: ['DB_URL=db'], + command: ['true'], + }), + ); + child.fireExit(0); + await exited; + + const meta = successCalls[0]; + expect(meta).toBeDefined(); + expect((meta.data as { injected: unknown }).injected).toEqual([{ envVar: 'DB_URL', vaultName: 'db' }]); + const serialized = JSON.stringify(meta); + expect(serialized).not.toMatch(/top-secret-db/); + restore(); + }); + }); + + describe('security boundary', () => { + it('never prints a secret value to stdout or stderr across success and failure paths', async () => { + const SECRET = 'super-secret-value-12345'; + + // Path 1: successful fetch + spawn + mockSdk.vault.readObjectByName.mockResolvedValueOnce({ id: 'a', name: 'db', value: SECRET, metadata: {} }); + const child = createMockChild(); + mockSpawn.mockReturnValueOnce(child as never); + const { exited, restore } = withSpawnExitCapture(); + await swallow(runVaultRun({ secrets: ['DB_URL=db'], command: ['true'] })); + child.fireExit(0); + await exited; + restore(); + + // Path 2: fetch failure + mockSdk.vault.readObjectByName.mockRejectedValueOnce( + Object.assign(new Error('boom'), { status: 500, requestID: 'r' }), + ); + await swallow(runVaultRun({ secrets: ['DB_URL=db'], command: ['true'] })); + + const allOutput = [...consoleLog, ...consoleErr].join('\n'); + expect(allOutput).not.toMatch(new RegExp(SECRET)); + }); + }); +}); diff --git a/src/commands/vault-run.ts b/src/commands/vault-run.ts new file mode 100644 index 00000000..ac6e55f0 --- /dev/null +++ b/src/commands/vault-run.ts @@ -0,0 +1,316 @@ +/** + * `workos vault run` fetches secrets from WorkOS Vault and injects them as + * environment variables into a child process. Secret values never appear + * in this wrapper's stdout/stderr; error messages reference vault object + * names only. + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import chalk from 'chalk'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; +import { isJsonMode, outputJson, outputSuccess, exitWithError } from '../utils/output.js'; +import { formatTable } from '../utils/table.js'; +import { SPAWN_OPTS, IS_WINDOWS } from '../utils/platform.js'; + +const handleApiError = createApiErrorHandler('Vault'); + +/** + * Duck-type check for `@workos-inc/node` SDK exceptions, which carry a + * numeric `status` and a `requestID`. Mirrors the check in `api-error-handler`, + * inlined here so we can react to status codes before the generic handler + * substitutes its own messages. + */ +function isSdkLikeException(error: unknown): error is { status: number; message: string; requestID: string } { + if (!(error instanceof Error)) return false; + const e = error as Error & { status?: unknown; requestID?: unknown }; + return typeof e.status === 'number' && typeof e.requestID === 'string'; +} + +export interface SecretMapping { + envVar: string; + vaultName: string; +} + +export interface VaultRunOptions { + secrets: string[]; + command: string[]; + env?: string; + org?: string; + dryRun?: boolean; +} + +/** + * Parse `--secret ENV_VAR=vault-name` flags into structured mappings. + * + * Splits on the first `=` so vault names containing `=` are not supported + * (env var names cannot contain `=` either, so the first split is unambiguous). + * Throws with a clear error on invalid format or duplicate env var names. + */ +export function parseSecretMappings(secrets: string[]): SecretMapping[] { + if (!secrets || secrets.length === 0) { + exitWithError({ + code: 'missing_secrets', + message: 'At least one --secret ENV=name mapping is required', + }); + } + + const result: SecretMapping[] = []; + const seen = new Set(); + + for (const raw of secrets) { + const eqIndex = raw.indexOf('='); + if (eqIndex <= 0 || eqIndex === raw.length - 1) { + exitWithError({ + code: 'invalid_secret_format', + message: `Invalid secret mapping '${raw}'. Expected format: ENV_VAR=vault-name`, + }); + } + + const envVar = raw.slice(0, eqIndex); + const vaultName = raw.slice(eqIndex + 1); + + if (!envVar || !vaultName) { + exitWithError({ + code: 'invalid_secret_format', + message: `Invalid secret mapping '${raw}'. Expected format: ENV_VAR=vault-name`, + }); + } + + if (seen.has(envVar)) { + exitWithError({ + code: 'duplicate_env_var', + message: `Duplicate environment variable '${envVar}' in --secret mappings`, + }); + } + seen.add(envVar); + + result.push({ envVar, vaultName }); + } + + return result; +} + +/** + * Fetch each secret from Vault, sequentially. Stops on the first failure + * so a partial set of values is never injected into the child process. + * + * Error messages include the vault object name, never the value. + */ +export async function fetchSecrets( + mappings: SecretMapping[], + apiKey: string, + baseUrl?: string, +): Promise> { + const client = createWorkOSClient(apiKey, baseUrl); + const values = new Map(); + + for (const { envVar, vaultName } of mappings) { + try { + const obj = await client.sdk.vault.readObjectByName(vaultName); + if (typeof obj.value !== 'string') { + exitWithError({ + code: 'vault_value_missing', + message: `Vault object '${vaultName}' has no readable value`, + }); + } + values.set(envVar, obj.value); + } catch (error) { + // The error path must always reference the vault object name, never + // the value. For SDK exceptions we handle 404/401 explicitly so the + // message includes the name; for everything else we wrap and delegate + // to the shared API error handler. + if (isSdkLikeException(error)) { + const status = error.status; + if (status === 404) { + exitWithError({ + code: 'vault_object_not_found', + message: `Vault object '${vaultName}' not found`, + }); + } + if (status === 401) { + exitWithError({ + code: 'unauthorized', + message: "Invalid API key. Check your environment configuration with 'workos auth status'", + }); + } + exitWithError({ + code: `http_${status}`, + message: `Failed to fetch vault object '${vaultName}': ${error.message ?? 'request failed'}`, + }); + } + if (error instanceof Error) { + exitWithError({ + code: 'vault_fetch_failed', + message: `Failed to fetch vault object '${vaultName}': ${error.message}`, + }); + } + // Fallback: never expose the value via raw error. + handleApiError(error); + } + } + + return values; +} + +/** + * Resolve the API key to use for this invocation. + * + * If `--env` is provided, look up that environment's stored API key. + * Otherwise fall back to the standard resolution chain + * (--api-key flag > WORKOS_API_KEY env var > active environment). + */ +async function resolveRunApiKey(envName: string | undefined, flagApiKey?: string): Promise { + if (!envName) { + const { resolveApiKey } = await import('../lib/api-key.js'); + return resolveApiKey({ apiKey: flagApiKey }); + } + + const { getConfig } = await import('../lib/config-store.js'); + const config = getConfig(); + const env = config?.environments[envName]; + if (!env || !env.apiKey) { + exitWithError({ + code: 'env_not_found', + message: `Environment '${envName}' not found or has no API key. Run 'workos env list' to see available environments.`, + }); + } + return env.apiKey; +} + +/** + * Print dry-run metadata (env var -> vault object name mapping). + * Never prints secret values; only the names of the targeted vault objects. + */ +function printDryRun(mappings: SecretMapping[], envName?: string, org?: string): void { + if (isJsonMode()) { + outputJson({ + dryRun: true, + env: envName ?? null, + org: org ?? null, + mappings: mappings.map(({ envVar, vaultName }) => ({ envVar, vaultName })), + }); + return; + } + + console.log(chalk.dim('Dry run (no secrets will be fetched and no child process will be spawned).')); + if (envName) console.log(chalk.dim(`Environment: ${envName}`)); + if (org) console.log(chalk.dim(`Organization: ${org}`)); + console.log(); + const rows = mappings.map(({ envVar, vaultName }) => [envVar, vaultName]); + console.log(formatTable([{ header: 'Environment Variable' }, { header: 'Vault Object' }], rows)); +} + +/** + * Spawn the child process with the injected environment. + * Forwards SIGINT/SIGTERM (and SIGBREAK on Windows) to the child and exits + * with the child's exit code so the wrapper is transparent in shell pipelines. + */ +function spawnChild(command: string, args: string[], childEnv: NodeJS.ProcessEnv): never { + let child: ChildProcess; + try { + child = spawn(command, args, { + stdio: 'inherit', + env: childEnv, + ...SPAWN_OPTS, + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + exitWithError({ + code: 'spawn_failed', + message: `Failed to start: ${command}: ${message}`, + }); + } + + child.on('error', (err) => { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + exitWithError({ + code: 'command_not_found', + message: `Command not found: ${command}`, + }); + } + exitWithError({ + code: 'spawn_error', + message: `Failed to start: ${command}: ${err.message}`, + }); + }); + + // Forward signals to the child so Ctrl+C, kill, etc. stop the wrapped process. + const forward = (signal: NodeJS.Signals) => { + if (!child.killed) child.kill(signal); + }; + process.on('SIGINT', () => forward('SIGINT')); + process.on('SIGTERM', () => forward('SIGTERM')); + if (IS_WINDOWS) { + process.on('SIGBREAK', () => forward('SIGINT')); + } + + child.on('exit', (code, signal) => { + if (signal) { + // Mirror the shell convention: 128 + signal number, fall back to 1. + const num = typeof signal === 'string' ? signalToNumber(signal) : 0; + process.exit(num ? 128 + num : 1); + } + process.exit(code ?? 0); + }); + + // Keep TypeScript happy: the handlers above always terminate the process. + return undefined as never; +} + +function signalToNumber(signal: NodeJS.Signals): number { + const map: Record = { + SIGHUP: 1, + SIGINT: 2, + SIGQUIT: 3, + SIGKILL: 9, + SIGTERM: 15, + }; + return map[signal] ?? 0; +} + +/** + * Entry point for `workos vault run`. + * + * 1. Validate inputs (secrets, child command). + * 2. On --dry-run: print metadata and return without fetching. + * 3. Resolve API key (per --env or active environment). + * 4. Fetch all secrets sequentially (fail fast). + * 5. Spawn the child with `{ ...process.env, ...injected }`. + */ +export async function runVaultRun(options: VaultRunOptions, flagApiKey?: string, baseUrl?: string): Promise { + const mappings = parseSecretMappings(options.secrets); + + if (options.dryRun) { + printDryRun(mappings, options.env, options.org); + return; + } + + if (!options.command || options.command.length === 0) { + exitWithError({ + code: 'missing_command', + message: 'No command specified. Usage: workos vault run --secret ENV=name -- command', + }); + } + + const apiKey = await resolveRunApiKey(options.env, flagApiKey); + const secretValues = await fetchSecrets(mappings, apiKey, baseUrl); + + // JSON mode: emit metadata about what was injected (no values) before exec. + if (isJsonMode()) { + outputSuccess('Injected secrets into child process', { + env: options.env ?? null, + org: options.org ?? null, + injected: mappings.map(({ envVar, vaultName }) => ({ envVar, vaultName })), + }); + } + + const childEnv: NodeJS.ProcessEnv = { ...process.env }; + for (const [envVar, value] of secretValues) { + childEnv[envVar] = value; + } + + const [cmd, ...args] = options.command; + spawnChild(cmd, args, childEnv); +} diff --git a/src/commands/vault.spec.ts b/src/commands/vault.spec.ts index 4d384191..5b67f2cd 100644 --- a/src/commands/vault.spec.ts +++ b/src/commands/vault.spec.ts @@ -105,25 +105,27 @@ describe('vault commands', () => { }); describe('runVaultCreate', () => { - it('creates object with name and value', async () => { + it('creates object with org context', async () => { mockSdk.vault.createObject.mockResolvedValue(mockMetadata); - await runVaultCreate({ name: 'my-secret', value: 'secret-val' }, 'sk_test'); + await runVaultCreate({ name: 'my-secret', value: 'secret-val', org: 'org_456' }, 'sk_test'); expect(mockSdk.vault.createObject).toHaveBeenCalledWith({ name: 'my-secret', value: 'secret-val', - context: {}, + context: { organizationId: 'org_456' }, }); expect(consoleOutput.some((l) => l.includes('Created vault object'))).toBe(true); }); - it('maps --org to context.organizationId', async () => { - mockSdk.vault.createObject.mockResolvedValue(mockMetadata); - await runVaultCreate({ name: 'my-secret', value: 'secret-val', org: 'org_456' }, 'sk_test'); - expect(mockSdk.vault.createObject).toHaveBeenCalledWith({ - name: 'my-secret', - value: 'secret-val', - context: { organizationId: 'org_456' }, + it('exits with error when --org is not provided', async () => { + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + const errOutput: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errOutput.push(args.map(String).join(' ')); }); + await runVaultCreate({ name: 'my-secret', value: 'secret-val' }, 'sk_test'); + expect(mockExit).toHaveBeenCalledWith(1); + expect(errOutput.some((l) => l.includes('--org'))).toBe(true); + mockExit.mockRestore(); }); }); @@ -196,7 +198,7 @@ describe('vault commands', () => { it('create outputs JSON success', async () => { mockSdk.vault.createObject.mockResolvedValue(mockMetadata); - await runVaultCreate({ name: 'my-secret', value: 'val' }, 'sk_test'); + await runVaultCreate({ name: 'my-secret', value: 'val', org: 'org_456' }, 'sk_test'); const output = JSON.parse(consoleOutput[0]); expect(output.status).toBe('ok'); expect(output.data.id).toBe('obj_123'); diff --git a/src/commands/vault.ts b/src/commands/vault.ts index 19d18d81..ddf6f0d4 100644 --- a/src/commands/vault.ts +++ b/src/commands/vault.ts @@ -1,11 +1,27 @@ import chalk from 'chalk'; import { createWorkOSClient } from '../lib/workos-client.js'; import { formatTable } from '../utils/table.js'; -import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; +import { outputSuccess, outputJson, isJsonMode, exitWithError } from '../utils/output.js'; import { createApiErrorHandler } from '../lib/api-error-handler.js'; const handleApiError = createApiErrorHandler('Vault'); +/** + * The Vault API returns 422 errors as `{"errors": {"field": ["msg"]}}` (an object), + * but the SDK's UnprocessableEntityException expects an array and crashes with + * "errors is not iterable". This wrapper catches that TypeError and extracts + * a readable message from the underlying response. + */ +function handleVaultSdkError(error: unknown, fallback: (e: unknown) => never): never { + if (error instanceof TypeError && error.message === 'errors is not iterable') { + exitWithError({ + code: 'unprocessable_entity', + message: 'Vault API rejected the request. Check that all required fields (--org, --name, --value) are provided.', + }); + } + fallback(error); +} + export interface VaultListOptions { limit?: number; before?: string; @@ -80,18 +96,24 @@ export interface VaultCreateOptions { } export async function runVaultCreate(options: VaultCreateOptions, apiKey: string, baseUrl?: string): Promise { + if (!options.org) { + exitWithError({ + code: 'missing_org', + message: 'The --org flag is required. Vault objects must be scoped to an organization.', + }); + } + const client = createWorkOSClient(apiKey, baseUrl); try { - const context = options.org ? { organizationId: options.org } : {}; const result = await client.sdk.vault.createObject({ name: options.name, value: options.value, - context, + context: { organizationId: options.org }, }); outputSuccess('Created vault object', result); } catch (error) { - handleApiError(error); + handleVaultSdkError(error, handleApiError); } } @@ -112,7 +134,7 @@ export async function runVaultUpdate(options: VaultUpdateOptions, apiKey: string }); outputSuccess('Updated vault object', result); } catch (error) { - handleApiError(error); + handleVaultSdkError(error, handleApiError); } } diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 88831499..5752d47d 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -1053,6 +1053,41 @@ const commands: CommandSchema[] = [ description: 'List vault object versions', positionals: [{ name: 'id', type: 'string', description: 'Object ID', required: true }], }, + { + name: 'run', + description: 'Run a command with Vault secrets injected as environment variables', + options: [ + { + name: 'secret', + type: 'array', + description: 'Map a vault object to an env var: ENV_VAR=vault-name (repeatable)', + required: true, + hidden: false, + }, + { + name: 'env', + type: 'string', + description: 'Environment name to read API key from (defaults to active)', + required: false, + hidden: false, + }, + { + name: 'org', + type: 'string', + description: 'Organization ID for org-scoped secrets', + required: false, + hidden: false, + }, + { + name: 'dry-run', + type: 'boolean', + description: 'Print which secrets would be injected, no fetch', + required: false, + default: false, + hidden: false, + }, + ], + }, ], }, { From 7acdb9ae211523d2f885bed7d07cdfb0c90f99d1 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 1 Jun 2026 16:35:05 -0500 Subject: [PATCH 2/8] refactor: consolidate vault error handling, fix stdout/stderr routing, harden SDK workaround - Export isSdkException from api-error-handler and add context param for enriched 404 messages, eliminating the duplicated type guard in vault-run - Move handleVaultSdkError into createApiErrorHandler with .includes() match to survive V8 message format changes - Remove --org from vault run (was accepted but never threaded to SDK) - Mark --org as required in vault create help-json schema - Replace sequential secret fetching with Promise.all (same fail-fast, better latency) - Use process.once for signal forwarding to prevent listener accumulation - spawnChild returns Promise instead of calling process.exit directly, simplifying tests and making the function composable - Dry-run JSON goes to stdout (primary output); execution-path metadata goes to stderr (child owns stdout) - Resolve base URL from named environment when --env is set - Remove dead code: unused imports, unreachable guards, stale test infra --- README.md | 3 +- src/bin.ts | 7 +- src/commands/vault-run.spec.ts | 182 ++++++++----------------- src/commands/vault-run.ts | 242 +++++++++++---------------------- src/commands/vault.ts | 20 +-- src/lib/api-error-handler.ts | 33 +++-- src/utils/help-json.ts | 9 +- 7 files changed, 159 insertions(+), 337 deletions(-) diff --git a/README.md b/README.md index aaa96227..c969b86b 100644 --- a/README.md +++ b/README.md @@ -538,11 +538,12 @@ workos portal generate-link --intent --org [--return-url] [--su workos vault list [--limit] workos vault get workos vault get-by-name -workos vault create --name --value [--org ] +workos vault create --name --value --org workos vault update --value [--version-check] workos vault delete workos vault describe workos vault list-versions +workos vault run --secret ENV_VAR=vault-name [...] [--env ] [--dry-run] -- ``` #### api-key diff --git a/src/bin.ts b/src/bin.ts index c579faae..d73c4c78 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -2076,26 +2076,23 @@ async function runCli(): Promise { demandOption: true, }, env: { type: 'string', describe: 'Environment name to read API key from (defaults to active)' }, - org: { type: 'string', describe: 'Organization ID for org-scoped secrets' }, 'dry-run': { type: 'boolean', default: false, describe: 'Print which secrets would be injected, no fetch' }, }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - const { resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runVaultRun } = await import('./commands/vault-run.js'); const childCommand = (argv['--'] as string[] | undefined) ?? []; - await runVaultRun( + const exitCode = await runVaultRun( { secrets: argv.secret as string[], command: childCommand, env: argv.env, - org: argv.org, dryRun: argv.dryRun, }, argv.apiKey as string | undefined, - resolveApiBaseUrl(), ); + if (typeof exitCode === 'number') process.exit(exitCode); }, ); return yargs.demandCommand(1, 'Please specify a vault subcommand').strict(); diff --git a/src/commands/vault-run.spec.ts b/src/commands/vault-run.spec.ts index bcfa3b98..6edd7921 100644 --- a/src/commands/vault-run.spec.ts +++ b/src/commands/vault-run.spec.ts @@ -14,31 +14,23 @@ vi.mock('../lib/workos-client.js', () => ({ })); const mockResolveApiKey = vi.fn(() => 'sk_test_resolved'); -const mockResolveApiBaseUrl = vi.fn(() => 'https://api.workos.com'); vi.mock('../lib/api-key.js', () => ({ resolveApiKey: (...args: unknown[]) => mockResolveApiKey(...(args as [])), - resolveApiBaseUrl: (...args: unknown[]) => mockResolveApiBaseUrl(...(args as [])), + resolveApiBaseUrl: () => 'https://api.workos.com', })); const mockConfig = { - environments: {} as Record, + environments: {} as Record, }; vi.mock('../lib/config-store.js', () => ({ getConfig: () => mockConfig, })); -vi.mock('child_process', () => ({ - spawn: vi.fn(), -})); -vi.mock('node:child_process', () => ({ - spawn: vi.fn(), -})); +vi.mock('child_process', () => ({ spawn: vi.fn() })); +vi.mock('node:child_process', () => ({ spawn: vi.fn() })); -// Replace exitWithError with a throwing mock so we can assert on the structured error. -// Keep the other exports intact (isJsonMode, outputJson, outputSuccess, outputError). let outputModeState: 'human' | 'json' = 'human'; const exitErrors: Array<{ code: string; message: string }> = []; -const successCalls: Array<{ message: string; data?: unknown }> = []; vi.mock('../utils/output.js', () => ({ isJsonMode: () => outputModeState === 'json', @@ -47,17 +39,7 @@ vi.mock('../utils/output.js', () => ({ }, getOutputMode: () => outputModeState, outputJson: vi.fn((data: unknown) => console.log(JSON.stringify(data))), - outputSuccess: vi.fn((message: string, data?: object) => { - successCalls.push({ message, data }); - if (outputModeState === 'json') { - const out: Record = { status: 'ok', message }; - if (data) out.data = data; - console.log(JSON.stringify(out)); - } else { - console.log(message); - if (data) console.log(JSON.stringify(data, null, 2)); - } - }), + outputSuccess: vi.fn(), outputError: vi.fn((err: { code: string; message: string }) => { console.error(err.message); }), @@ -77,66 +59,30 @@ const { parseSecretMappings, fetchSecrets, runVaultRun } = await import('./vault // ---------- Helpers ---------- -/** - * Mock child process. Does NOT auto-emit 'exit' — the test triggers it after - * spawn has been called so the child.on('exit') handler is registered first. - * - * `fireExit` swallows the throw from the mocked `process.exit` so the test - * call site doesn't see it escape synchronously. - */ function createMockChild() { const proc = new EventEmitter() as EventEmitter & { kill: ReturnType; killed: boolean; - fireExit: (code: number) => void; }; proc.kill = vi.fn(); proc.killed = false; - proc.fireExit = (code: number) => { - try { - proc.emit('exit', code, null); - } catch (err) { - if (err instanceof Error && err.message.startsWith('__PROCESS_EXIT__:')) return; - throw err; - } - }; return proc; } -/** - * Some code paths end with `process.exit(code)`. We install a per-test spy - * that records the code and resolves a deferred promise so the test can await - * the synchronous exit without the spy's throw escaping unhandled. - */ -function withSpawnExitCapture(): { - exitSpy: ReturnType; - exited: Promise; - restore: () => void; -} { - let resolveExit!: (code: number) => void; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { - resolveExit(code ?? 0); - // Throw so callers don't continue past process.exit — same shape as real. - // The throw is caught by the synchronous `swallow`/awaited promise. - throw new Error(`__PROCESS_EXIT__:${code ?? 0}`); - }) as never); - return { - exitSpy, - exited, - restore: () => exitSpy.mockRestore(), - }; +function exitChildAfterSpawn(child: EventEmitter, code: number): void { + const poll = setInterval(() => { + if (mockSpawn.mock.calls.length > 0) { + clearInterval(poll); + child.emit('exit', code, null); + } + }, 1); } async function swallow(promise: Promise | unknown): Promise { try { await promise; } catch (err) { - if (err instanceof Error && (err.message.startsWith('__EXIT__:') || err.message.startsWith('__PROCESS_EXIT__:'))) { - return; - } + if (err instanceof Error && err.message.startsWith('__EXIT__:')) return; throw err; } } @@ -152,7 +98,6 @@ describe('vault-run', () => { consoleLog = []; consoleErr = []; exitErrors.length = 0; - successCalls.length = 0; outputModeState = 'human'; mockConfig.environments = {}; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { @@ -228,7 +173,7 @@ describe('vault-run', () => { expect(mockSdk.vault.readObjectByName).toHaveBeenCalledWith('db'); }); - it('fetches multiple secrets sequentially', async () => { + it('fetches multiple secrets in parallel', async () => { mockSdk.vault.readObjectByName .mockResolvedValueOnce({ id: 'a', name: 'db', value: 'val-a', metadata: {} }) .mockResolvedValueOnce({ id: 'b', name: 'api', value: 'val-b', metadata: {} }); @@ -302,20 +247,18 @@ describe('vault-run', () => { command: [], dryRun: true, env: 'production', - org: 'org_123', }); expect(mockSpawn).not.toHaveBeenCalled(); const parsed = JSON.parse(consoleLog[0]); expect(parsed.dryRun).toBe(true); expect(parsed.env).toBe('production'); - expect(parsed.org).toBe('org_123'); expect(parsed.mappings).toEqual([{ envVar: 'DB_URL', vaultName: 'db' }]); }); }); describe('runVaultRun — execution', () => { - it('spawns child with injected env vars and resolves the active env API key', async () => { + it('spawns child with injected env vars and returns exit code', async () => { mockSdk.vault.readObjectByName.mockResolvedValueOnce({ id: 'a', name: 'db', @@ -324,19 +267,15 @@ describe('vault-run', () => { }); const child = createMockChild(); mockSpawn.mockReturnValueOnce(child as never); - const { exited, restore } = withSpawnExitCapture(); - await swallow( - runVaultRun({ - secrets: ['DB_URL=db'], - command: ['printenv', 'DB_URL'], - }), - ); - // runVaultRun has registered the spawn handler — now fire exit. - child.fireExit(0); - const code = await exited; + const promise = runVaultRun({ + secrets: ['DB_URL=db'], + command: ['printenv', 'DB_URL'], + }); + exitChildAfterSpawn(child, 0); + const exitCode = await promise; - expect(code).toBe(0); + expect(exitCode).toBe(0); expect(mockSpawn).toHaveBeenCalledTimes(1); const [cmd, args, opts] = mockSpawn.mock.calls[0]; expect(cmd).toBe('printenv'); @@ -344,7 +283,6 @@ describe('vault-run', () => { const spawnOpts = opts as { env: NodeJS.ProcessEnv; stdio: string }; expect(spawnOpts.stdio).toBe('inherit'); expect(spawnOpts.env.DB_URL).toBe('real-db-value'); - restore(); }); it('forwards child non-zero exit code', async () => { @@ -356,19 +294,15 @@ describe('vault-run', () => { }); const child = createMockChild(); mockSpawn.mockReturnValueOnce(child as never); - const { exited, restore } = withSpawnExitCapture(); - await swallow( - runVaultRun({ - secrets: ['DB_URL=db'], - command: ['some-tool'], - }), - ); - child.fireExit(42); - const code = await exited; + const promise = runVaultRun({ + secrets: ['DB_URL=db'], + command: ['some-tool'], + }); + exitChildAfterSpawn(child, 42); + const exitCode = await promise; - expect(code).toBe(42); - restore(); + expect(exitCode).toBe(42); }); it('exits with usage error when no command is supplied', async () => { @@ -393,21 +327,16 @@ describe('vault-run', () => { }); const child = createMockChild(); mockSpawn.mockReturnValueOnce(child as never); - const { exited, restore } = withSpawnExitCapture(); - await swallow( - runVaultRun({ - secrets: ['DB_URL=db'], - command: ['echo'], - env: 'staging', - }), - ); - child.fireExit(0); - await exited; + const promise = runVaultRun({ + secrets: ['DB_URL=db'], + command: ['echo'], + env: 'staging', + }); + exitChildAfterSpawn(child, 0); + await promise; - // resolveApiKey from api-key.js mock should not be called when --env is set. expect(mockResolveApiKey).not.toHaveBeenCalled(); - restore(); }); it('exits when --env names an unknown environment', async () => { @@ -433,7 +362,7 @@ describe('vault-run', () => { outputModeState = 'human'; }); - it('emits an ok status with the injection metadata (never the value)', async () => { + it('emits metadata to stderr (never the value)', async () => { mockSdk.vault.readObjectByName.mockResolvedValueOnce({ id: 'a', name: 'db', @@ -442,23 +371,20 @@ describe('vault-run', () => { }); const child = createMockChild(); mockSpawn.mockReturnValueOnce(child as never); - const { exited, restore } = withSpawnExitCapture(); - await swallow( - runVaultRun({ - secrets: ['DB_URL=db'], - command: ['true'], - }), - ); - child.fireExit(0); - await exited; - - const meta = successCalls[0]; - expect(meta).toBeDefined(); - expect((meta.data as { injected: unknown }).injected).toEqual([{ envVar: 'DB_URL', vaultName: 'db' }]); - const serialized = JSON.stringify(meta); - expect(serialized).not.toMatch(/top-secret-db/); - restore(); + const promise = runVaultRun({ + secrets: ['DB_URL=db'], + command: ['true'], + }); + exitChildAfterSpawn(child, 0); + await promise; + + const metaLine = consoleErr.find((l) => l.includes('"injected"')); + expect(metaLine).toBeDefined(); + const parsed = JSON.parse(metaLine!); + expect(parsed.status).toBe('ok'); + expect(parsed.injected).toEqual([{ envVar: 'DB_URL', vaultName: 'db' }]); + expect(metaLine).not.toMatch(/top-secret-db/); }); }); @@ -470,11 +396,9 @@ describe('vault-run', () => { mockSdk.vault.readObjectByName.mockResolvedValueOnce({ id: 'a', name: 'db', value: SECRET, metadata: {} }); const child = createMockChild(); mockSpawn.mockReturnValueOnce(child as never); - const { exited, restore } = withSpawnExitCapture(); - await swallow(runVaultRun({ secrets: ['DB_URL=db'], command: ['true'] })); - child.fireExit(0); - await exited; - restore(); + const promise = runVaultRun({ secrets: ['DB_URL=db'], command: ['true'] }); + exitChildAfterSpawn(child, 0); + await promise; // Path 2: fetch failure mockSdk.vault.readObjectByName.mockRejectedValueOnce( diff --git a/src/commands/vault-run.ts b/src/commands/vault-run.ts index ac6e55f0..6af0f290 100644 --- a/src/commands/vault-run.ts +++ b/src/commands/vault-run.ts @@ -1,32 +1,13 @@ -/** - * `workos vault run` fetches secrets from WorkOS Vault and injects them as - * environment variables into a child process. Secret values never appear - * in this wrapper's stdout/stderr; error messages reference vault object - * names only. - */ - import { spawn, type ChildProcess } from 'node:child_process'; import chalk from 'chalk'; import { createWorkOSClient } from '../lib/workos-client.js'; import { createApiErrorHandler } from '../lib/api-error-handler.js'; -import { isJsonMode, outputJson, outputSuccess, exitWithError } from '../utils/output.js'; +import { isJsonMode, outputJson, exitWithError } from '../utils/output.js'; import { formatTable } from '../utils/table.js'; import { SPAWN_OPTS, IS_WINDOWS } from '../utils/platform.js'; const handleApiError = createApiErrorHandler('Vault'); -/** - * Duck-type check for `@workos-inc/node` SDK exceptions, which carry a - * numeric `status` and a `requestID`. Mirrors the check in `api-error-handler`, - * inlined here so we can react to status codes before the generic handler - * substitutes its own messages. - */ -function isSdkLikeException(error: unknown): error is { status: number; message: string; requestID: string } { - if (!(error instanceof Error)) return false; - const e = error as Error & { status?: unknown; requestID?: unknown }; - return typeof e.status === 'number' && typeof e.requestID === 'string'; -} - export interface SecretMapping { envVar: string; vaultName: string; @@ -36,17 +17,9 @@ export interface VaultRunOptions { secrets: string[]; command: string[]; env?: string; - org?: string; dryRun?: boolean; } -/** - * Parse `--secret ENV_VAR=vault-name` flags into structured mappings. - * - * Splits on the first `=` so vault names containing `=` are not supported - * (env var names cannot contain `=` either, so the first split is unambiguous). - * Throws with a clear error on invalid format or duplicate env var names. - */ export function parseSecretMappings(secrets: string[]): SecretMapping[] { if (!secrets || secrets.length === 0) { exitWithError({ @@ -70,13 +43,6 @@ export function parseSecretMappings(secrets: string[]): SecretMapping[] { const envVar = raw.slice(0, eqIndex); const vaultName = raw.slice(eqIndex + 1); - if (!envVar || !vaultName) { - exitWithError({ - code: 'invalid_secret_format', - message: `Invalid secret mapping '${raw}'. Expected format: ENV_VAR=vault-name`, - }); - } - if (seen.has(envVar)) { exitWithError({ code: 'duplicate_env_var', @@ -91,75 +57,34 @@ export function parseSecretMappings(secrets: string[]): SecretMapping[] { return result; } -/** - * Fetch each secret from Vault, sequentially. Stops on the first failure - * so a partial set of values is never injected into the child process. - * - * Error messages include the vault object name, never the value. - */ export async function fetchSecrets( mappings: SecretMapping[], apiKey: string, baseUrl?: string, ): Promise> { const client = createWorkOSClient(apiKey, baseUrl); - const values = new Map(); - for (const { envVar, vaultName } of mappings) { - try { - const obj = await client.sdk.vault.readObjectByName(vaultName); + const entries = await Promise.all( + mappings.map(async ({ envVar, vaultName }): Promise<[string, string]> => { + let obj: { value?: unknown }; + try { + obj = await client.sdk.vault.readObjectByName(vaultName); + } catch (error) { + return handleApiError(error, vaultName); + } if (typeof obj.value !== 'string') { exitWithError({ code: 'vault_value_missing', message: `Vault object '${vaultName}' has no readable value`, }); } - values.set(envVar, obj.value); - } catch (error) { - // The error path must always reference the vault object name, never - // the value. For SDK exceptions we handle 404/401 explicitly so the - // message includes the name; for everything else we wrap and delegate - // to the shared API error handler. - if (isSdkLikeException(error)) { - const status = error.status; - if (status === 404) { - exitWithError({ - code: 'vault_object_not_found', - message: `Vault object '${vaultName}' not found`, - }); - } - if (status === 401) { - exitWithError({ - code: 'unauthorized', - message: "Invalid API key. Check your environment configuration with 'workos auth status'", - }); - } - exitWithError({ - code: `http_${status}`, - message: `Failed to fetch vault object '${vaultName}': ${error.message ?? 'request failed'}`, - }); - } - if (error instanceof Error) { - exitWithError({ - code: 'vault_fetch_failed', - message: `Failed to fetch vault object '${vaultName}': ${error.message}`, - }); - } - // Fallback: never expose the value via raw error. - handleApiError(error); - } - } + return [envVar, obj.value]; + }), + ); - return values; + return new Map(entries); } -/** - * Resolve the API key to use for this invocation. - * - * If `--env` is provided, look up that environment's stored API key. - * Otherwise fall back to the standard resolution chain - * (--api-key flag > WORKOS_API_KEY env var > active environment). - */ async function resolveRunApiKey(envName: string | undefined, flagApiKey?: string): Promise { if (!envName) { const { resolveApiKey } = await import('../lib/api-key.js'); @@ -178,16 +103,24 @@ async function resolveRunApiKey(envName: string | undefined, flagApiKey?: string return env.apiKey; } -/** - * Print dry-run metadata (env var -> vault object name mapping). - * Never prints secret values; only the names of the targeted vault objects. - */ -function printDryRun(mappings: SecretMapping[], envName?: string, org?: string): void { +async function resolveRunBaseUrl(envName: string | undefined): Promise { + if (envName) { + const { getConfig } = await import('../lib/config-store.js'); + const config = getConfig(); + const env = config?.environments[envName]; + if (env?.endpoint) { + return env.endpoint; + } + } + const { resolveApiBaseUrl } = await import('../lib/api-key.js'); + return resolveApiBaseUrl(); +} + +function printDryRun(mappings: SecretMapping[], envName?: string): void { if (isJsonMode()) { outputJson({ dryRun: true, env: envName ?? null, - org: org ?? null, mappings: mappings.map(({ envVar, vaultName }) => ({ envVar, vaultName })), }); return; @@ -195,68 +128,58 @@ function printDryRun(mappings: SecretMapping[], envName?: string, org?: string): console.log(chalk.dim('Dry run (no secrets will be fetched and no child process will be spawned).')); if (envName) console.log(chalk.dim(`Environment: ${envName}`)); - if (org) console.log(chalk.dim(`Organization: ${org}`)); console.log(); const rows = mappings.map(({ envVar, vaultName }) => [envVar, vaultName]); console.log(formatTable([{ header: 'Environment Variable' }, { header: 'Vault Object' }], rows)); } -/** - * Spawn the child process with the injected environment. - * Forwards SIGINT/SIGTERM (and SIGBREAK on Windows) to the child and exits - * with the child's exit code so the wrapper is transparent in shell pipelines. - */ -function spawnChild(command: string, args: string[], childEnv: NodeJS.ProcessEnv): never { - let child: ChildProcess; - try { - child = spawn(command, args, { - stdio: 'inherit', - env: childEnv, - ...SPAWN_OPTS, - }); - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - exitWithError({ - code: 'spawn_failed', - message: `Failed to start: ${command}: ${message}`, - }); - } - - child.on('error', (err) => { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'ENOENT') { +function spawnChild(command: string, args: string[], childEnv: NodeJS.ProcessEnv): Promise { + return new Promise((resolve, reject) => { + let child: ChildProcess; + try { + child = spawn(command, args, { + stdio: 'inherit', + env: childEnv, + ...SPAWN_OPTS, + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; exitWithError({ - code: 'command_not_found', - message: `Command not found: ${command}`, + code: 'spawn_failed', + message: `Failed to start: ${command}: ${message}`, }); } - exitWithError({ - code: 'spawn_error', - message: `Failed to start: ${command}: ${err.message}`, - }); - }); - // Forward signals to the child so Ctrl+C, kill, etc. stop the wrapped process. - const forward = (signal: NodeJS.Signals) => { - if (!child.killed) child.kill(signal); - }; - process.on('SIGINT', () => forward('SIGINT')); - process.on('SIGTERM', () => forward('SIGTERM')); - if (IS_WINDOWS) { - process.on('SIGBREAK', () => forward('SIGINT')); - } + child.on('error', (err) => { + try { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + exitWithError({ code: 'command_not_found', message: `Command not found: ${command}` }); + } + exitWithError({ code: 'spawn_error', message: `Failed to start: ${command}: ${err.message}` }); + } catch (e) { + reject(e); + } + }); - child.on('exit', (code, signal) => { - if (signal) { - // Mirror the shell convention: 128 + signal number, fall back to 1. - const num = typeof signal === 'string' ? signalToNumber(signal) : 0; - process.exit(num ? 128 + num : 1); + const forward = (signal: NodeJS.Signals) => { + if (!child.killed) child.kill(signal); + }; + process.once('SIGINT', () => forward('SIGINT')); + process.once('SIGTERM', () => forward('SIGTERM')); + if (IS_WINDOWS) { + process.once('SIGBREAK', () => forward('SIGINT')); } - process.exit(code ?? 0); - }); - // Keep TypeScript happy: the handlers above always terminate the process. - return undefined as never; + child.on('exit', (code, signal) => { + if (signal) { + const num = signalToNumber(signal); + resolve(num ? 128 + num : 1); + } else { + resolve(code ?? 0); + } + }); + }); } function signalToNumber(signal: NodeJS.Signals): number { @@ -270,20 +193,11 @@ function signalToNumber(signal: NodeJS.Signals): number { return map[signal] ?? 0; } -/** - * Entry point for `workos vault run`. - * - * 1. Validate inputs (secrets, child command). - * 2. On --dry-run: print metadata and return without fetching. - * 3. Resolve API key (per --env or active environment). - * 4. Fetch all secrets sequentially (fail fast). - * 5. Spawn the child with `{ ...process.env, ...injected }`. - */ -export async function runVaultRun(options: VaultRunOptions, flagApiKey?: string, baseUrl?: string): Promise { +export async function runVaultRun(options: VaultRunOptions, flagApiKey?: string): Promise { const mappings = parseSecretMappings(options.secrets); if (options.dryRun) { - printDryRun(mappings, options.env, options.org); + printDryRun(mappings, options.env); return; } @@ -295,15 +209,19 @@ export async function runVaultRun(options: VaultRunOptions, flagApiKey?: string, } const apiKey = await resolveRunApiKey(options.env, flagApiKey); + const baseUrl = await resolveRunBaseUrl(options.env); const secretValues = await fetchSecrets(mappings, apiKey, baseUrl); - // JSON mode: emit metadata about what was injected (no values) before exec. + // Metadata to stderr so the child process owns stdout. if (isJsonMode()) { - outputSuccess('Injected secrets into child process', { - env: options.env ?? null, - org: options.org ?? null, - injected: mappings.map(({ envVar, vaultName }) => ({ envVar, vaultName })), - }); + console.error( + JSON.stringify({ + status: 'ok', + message: 'Injected secrets into child process', + env: options.env ?? null, + injected: mappings.map(({ envVar, vaultName }) => ({ envVar, vaultName })), + }), + ); } const childEnv: NodeJS.ProcessEnv = { ...process.env }; @@ -312,5 +230,5 @@ export async function runVaultRun(options: VaultRunOptions, flagApiKey?: string, } const [cmd, ...args] = options.command; - spawnChild(cmd, args, childEnv); + return spawnChild(cmd, args, childEnv); } diff --git a/src/commands/vault.ts b/src/commands/vault.ts index ddf6f0d4..36bf0938 100644 --- a/src/commands/vault.ts +++ b/src/commands/vault.ts @@ -6,22 +6,6 @@ import { createApiErrorHandler } from '../lib/api-error-handler.js'; const handleApiError = createApiErrorHandler('Vault'); -/** - * The Vault API returns 422 errors as `{"errors": {"field": ["msg"]}}` (an object), - * but the SDK's UnprocessableEntityException expects an array and crashes with - * "errors is not iterable". This wrapper catches that TypeError and extracts - * a readable message from the underlying response. - */ -function handleVaultSdkError(error: unknown, fallback: (e: unknown) => never): never { - if (error instanceof TypeError && error.message === 'errors is not iterable') { - exitWithError({ - code: 'unprocessable_entity', - message: 'Vault API rejected the request. Check that all required fields (--org, --name, --value) are provided.', - }); - } - fallback(error); -} - export interface VaultListOptions { limit?: number; before?: string; @@ -113,7 +97,7 @@ export async function runVaultCreate(options: VaultCreateOptions, apiKey: string }); outputSuccess('Created vault object', result); } catch (error) { - handleVaultSdkError(error, handleApiError); + handleApiError(error); } } @@ -134,7 +118,7 @@ export async function runVaultUpdate(options: VaultUpdateOptions, apiKey: string }); outputSuccess('Updated vault object', result); } catch (error) { - handleVaultSdkError(error, handleApiError); + handleApiError(error); } } diff --git a/src/lib/api-error-handler.ts b/src/lib/api-error-handler.ts index be4d782e..702d298d 100644 --- a/src/lib/api-error-handler.ts +++ b/src/lib/api-error-handler.ts @@ -1,14 +1,7 @@ import { WorkOSApiError } from './workos-api.js'; import { exitWithError } from '../utils/output.js'; -/** - * Duck-type check for @workos-inc/node SDK exceptions. - * - * The SDK throws typed errors (UnauthorizedException, NotFoundException, etc.) - * that implement the RequestException interface: { status, message, requestID }. - * We duck-type rather than instanceof to avoid coupling to the SDK's class hierarchy. - */ -function isSdkException( +export function isSdkException( error: unknown, ): error is { status: number; message: string; requestID: string; code?: string; errors?: Array<{ message: string }> } { if (!(error instanceof Error)) return false; @@ -45,26 +38,38 @@ function normalizeApiError(error: unknown): NormalizedApiError | null { return null; } -function getApiErrorMessage(error: NormalizedApiError, resourceName: string): string { +function getApiErrorMessage(error: NormalizedApiError, label: string): string { if (error.status === 401) return 'Invalid API key. Check your environment configuration.'; - if (error.status === 404) return `${resourceName} not found.`; + if (error.status === 404) return `${label} not found.`; if (error.status === 422 && error.errors?.length) return error.errors.map((e) => e.message).join(', '); return error.message; } /** * Create a resource-specific API error handler. - * Handles both raw fetch errors (WorkOSApiError) and SDK exceptions. - * Returns a `never` function that writes structured errors and exits. + * Handles raw fetch errors (WorkOSApiError), SDK exceptions, and the SDK's + * "errors is not iterable" TypeError from malformed 422 responses. + * + * `context` optionally names the specific resource instance (e.g. a vault + * object name) so 404 messages can be more specific. */ export function createApiErrorHandler(resourceName: string) { - return (error: unknown): never => { + return (error: unknown, context?: string): never => { + if (error instanceof TypeError && error.message.includes('is not iterable')) { + exitWithError({ + code: 'unprocessable_entity', + message: `${resourceName} API rejected the request. Check that all required fields are provided.`, + apiContext: { resource: resourceName }, + }); + } + + const label = context ? `${resourceName} '${context}'` : resourceName; const apiError = normalizeApiError(error); if (apiError) { const code = apiError.code ?? `http_${apiError.status}`; exitWithError({ code, - message: getApiErrorMessage(apiError, resourceName), + message: getApiErrorMessage(apiError, label), details: apiError.errors, apiContext: { status: apiError.status, diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 5752d47d..c1d0df8d 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -1026,7 +1026,7 @@ const commands: CommandSchema[] = [ options: [ { name: 'name', type: 'string', description: 'Object name', required: true, hidden: false }, { name: 'value', type: 'string', description: 'Secret value', required: true, hidden: false }, - { name: 'org', type: 'string', description: 'Organization ID', required: false, hidden: false }, + { name: 'org', type: 'string', description: 'Organization ID (required)', required: true, hidden: false }, ], }, { @@ -1071,13 +1071,6 @@ const commands: CommandSchema[] = [ required: false, hidden: false, }, - { - name: 'org', - type: 'string', - description: 'Organization ID for org-scoped secrets', - required: false, - hidden: false, - }, { name: 'dry-run', type: 'boolean', From 3e44803d58cf0cdda785aee6fd2eee64e813ff81 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 1 Jun 2026 16:41:47 -0500 Subject: [PATCH 3/8] fix: use default base URL when --env has no custom endpoint When --env selects an environment without a custom endpoint, resolveRunBaseUrl fell through to resolveApiBaseUrl() which returns the active environment's endpoint. This sent the selected env's API key to the wrong host (e.g. production key to localhost). Now returns the default WorkOS API base URL when the named env has no endpoint, instead of leaking the active env's endpoint. --- src/commands/vault-run.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/commands/vault-run.ts b/src/commands/vault-run.ts index 6af0f290..9cc890b9 100644 --- a/src/commands/vault-run.ts +++ b/src/commands/vault-run.ts @@ -108,9 +108,10 @@ async function resolveRunBaseUrl(envName: string | undefined): Promise { const { getConfig } = await import('../lib/config-store.js'); const config = getConfig(); const env = config?.environments[envName]; - if (env?.endpoint) { - return env.endpoint; - } + // Use the named env's endpoint, or the default. Never fall through to + // the active env's endpoint -- that would send the wrong API key to + // the wrong host when active != selected. + return env?.endpoint ?? 'https://api.workos.com'; } const { resolveApiBaseUrl } = await import('../lib/api-key.js'); return resolveApiBaseUrl(); From 73c222dc3a97db0be2936d6bd9b468f427a37173 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 1 Jun 2026 16:55:42 -0500 Subject: [PATCH 4/8] feat: stdin support for vault value, metadata-only get by default vault create/update: --value is now optional. When omitted or set to -, the value is read from stdin. This keeps secrets out of shell history and ps output. vault get/get-by-name: returns metadata only by default. Pass --decrypt to include the decrypted secret value. vault get uses describeObject (never requests decryption); vault get-by-name strips the value from the response before output. --- README.md | 8 ++++---- src/bin.ts | 41 +++++++++++++++++++++++++------------- src/commands/vault.spec.ts | 32 ++++++++++++++++++++++------- src/commands/vault.ts | 40 ++++++++++++++++++++++++++++++++----- src/utils/help-json.ts | 18 +++++++++++------ 5 files changed, 103 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index c969b86b..facf85cc 100644 --- a/README.md +++ b/README.md @@ -536,10 +536,10 @@ workos portal generate-link --intent --org [--return-url] [--su ```bash workos vault list [--limit] -workos vault get -workos vault get-by-name -workos vault create --name --value --org -workos vault update --value [--version-check] +workos vault get [--decrypt] +workos vault get-by-name [--decrypt] +workos vault create --name --org [--value ] # omit --value to read from stdin +workos vault update [--value ] [--version-check] # omit --value to read from stdin workos vault delete workos vault describe workos vault list-versions diff --git a/src/bin.ts b/src/bin.ts index d73c4c78..ab36ab4d 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1959,46 +1959,54 @@ async function runCli(): Promise { registerSubcommand( yargs, 'get ', - 'Get a vault object', - (y) => y.positional('id', { type: 'string', demandOption: true }), + 'Get a vault object (metadata only; use --decrypt to include value)', + (y) => + y + .positional('id', { type: 'string', demandOption: true }) + .option('decrypt', { type: 'boolean', default: false, describe: 'Include the decrypted secret value' }), 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()); + await runVaultGet(argv.id, argv.decrypt, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); }, ); registerSubcommand( yargs, 'get-by-name ', - 'Get a vault object by name', - (y) => y.positional('name', { type: 'string', demandOption: true }), + 'Get a vault object by name (metadata only; use --decrypt to include value)', + (y) => + y + .positional('name', { type: 'string', demandOption: true }) + .option('decrypt', { type: 'boolean', default: false, describe: 'Include the decrypted secret value' }), 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()); + await runVaultGetByName(argv.name, argv.decrypt, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); }, ); registerSubcommand( yargs, 'create', - 'Create a vault object', + 'Create a vault object (reads value from stdin when --value is omitted or -)', (y) => y.options({ name: { type: 'string', demandOption: true }, - value: { type: 'string', demandOption: true }, + value: { type: 'string', describe: 'Secret value (omit or use - to read from stdin)' }, org: { type: 'string', demandOption: true, describe: 'Organization ID (required for key context)' }, }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); - const { runVaultCreate } = await import('./commands/vault.js'); + const { runVaultCreate, readValueFromStdin } = await import('./commands/vault.js'); + const value = + argv.value === undefined || argv.value === '-' ? await readValueFromStdin() : argv.value; await runVaultCreate( - { name: argv.name, value: argv.value, org: argv.org }, + { name: argv.name, value, org: argv.org }, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl(), ); @@ -2007,18 +2015,23 @@ async function runCli(): Promise { registerSubcommand( yargs, 'update ', - 'Update a vault object', + 'Update a vault object (reads value from stdin when --value is omitted or -)', (y) => y .positional('id', { type: 'string', demandOption: true }) - .options({ value: { type: 'string', demandOption: true }, 'version-check': { type: 'string' } }), + .options({ + value: { type: 'string', describe: 'New value (omit or use - to read from stdin)' }, + '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'); + const { runVaultUpdate, readValueFromStdin } = await import('./commands/vault.js'); + const value = + argv.value === undefined || argv.value === '-' ? await readValueFromStdin() : argv.value; await runVaultUpdate( - { id: argv.id, value: argv.value, versionCheck: argv.versionCheck }, + { id: argv.id, value, versionCheck: argv.versionCheck }, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl(), ); diff --git a/src/commands/vault.spec.ts b/src/commands/vault.spec.ts index 5b67f2cd..502e9bb4 100644 --- a/src/commands/vault.spec.ts +++ b/src/commands/vault.spec.ts @@ -88,19 +88,37 @@ describe('vault commands', () => { }); describe('runVaultGet', () => { - it('reads object by ID', async () => { + it('returns metadata only by default', async () => { + mockSdk.vault.describeObject.mockResolvedValue({ id: 'obj_123', name: 'my-secret', metadata: {} }); + await runVaultGet('obj_123', false, 'sk_test'); + expect(mockSdk.vault.describeObject).toHaveBeenCalledWith({ id: 'obj_123' }); + expect(mockSdk.vault.readObject).not.toHaveBeenCalled(); + const output = consoleOutput.join(''); + expect(output).not.toMatch(/secret-value/); + }); + + it('includes decrypted value with --decrypt', async () => { mockSdk.vault.readObject.mockResolvedValue(mockObject); - await runVaultGet('obj_123', 'sk_test'); + await runVaultGet('obj_123', true, 'sk_test'); expect(mockSdk.vault.readObject).toHaveBeenCalledWith({ id: 'obj_123' }); - expect(consoleOutput.some((l) => l.includes('obj_123'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('secret-value'))).toBe(true); }); }); describe('runVaultGetByName', () => { - it('reads object by name', async () => { + it('strips value by default', async () => { mockSdk.vault.readObjectByName.mockResolvedValue(mockObject); - await runVaultGetByName('my-secret', 'sk_test'); + await runVaultGetByName('my-secret', false, 'sk_test'); expect(mockSdk.vault.readObjectByName).toHaveBeenCalledWith('my-secret'); + const output = consoleOutput.join(''); + expect(output).toMatch(/obj_123/); + expect(output).not.toMatch(/secret-value/); + }); + + it('includes value with --decrypt', async () => { + mockSdk.vault.readObjectByName.mockResolvedValue(mockObject); + await runVaultGetByName('my-secret', true, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('secret-value'))).toBe(true); }); }); @@ -188,9 +206,9 @@ describe('vault commands', () => { expect(output.listMetadata.after).toBe('cursor_a'); }); - it('get outputs raw JSON', async () => { + it('get --decrypt outputs value in JSON', async () => { mockSdk.vault.readObject.mockResolvedValue(mockObject); - await runVaultGet('obj_123', 'sk_test'); + await runVaultGet('obj_123', true, 'sk_test'); const output = JSON.parse(consoleOutput[0]); expect(output.id).toBe('obj_123'); expect(output.value).toBe('secret-value'); diff --git a/src/commands/vault.ts b/src/commands/vault.ts index 36bf0938..0aeca440 100644 --- a/src/commands/vault.ts +++ b/src/commands/vault.ts @@ -51,23 +51,38 @@ export async function runVaultList(options: VaultListOptions, apiKey: string, ba } } -export async function runVaultGet(id: string, apiKey: string, baseUrl?: string): Promise { +export async function runVaultGet(id: string, decrypt: boolean, apiKey: string, baseUrl?: string): Promise { const client = createWorkOSClient(apiKey, baseUrl); try { - const result = await client.sdk.vault.readObject({ id }); - outputJson(result); + if (decrypt) { + const result = await client.sdk.vault.readObject({ id }); + outputJson(result); + } else { + const result = await client.sdk.vault.describeObject({ id }); + outputJson(result); + } } catch (error) { handleApiError(error); } } -export async function runVaultGetByName(name: string, apiKey: string, baseUrl?: string): Promise { +export async function runVaultGetByName( + name: string, + decrypt: boolean, + apiKey: string, + baseUrl?: string, +): Promise { const client = createWorkOSClient(apiKey, baseUrl); try { const result = await client.sdk.vault.readObjectByName(name); - outputJson(result); + if (decrypt) { + outputJson(result); + } else { + const { value: _stripped, ...metadata } = result; + outputJson(metadata); + } } catch (error) { handleApiError(error); } @@ -154,3 +169,18 @@ export async function runVaultListVersions(id: string, apiKey: string, baseUrl?: handleApiError(error); } } + +export async function readValueFromStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + const value = Buffer.concat(chunks).toString('utf-8').trimEnd(); + if (value.length === 0) { + exitWithError({ + code: 'empty_stdin', + message: 'No value provided on stdin. Pipe a value or pass --value directly.', + }); + } + return value; +} diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index c1d0df8d..b4341a42 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -1012,29 +1012,35 @@ const commands: CommandSchema[] = [ { name: 'list', description: 'List vault objects', options: [...paginationOpts] }, { name: 'get', - description: 'Get a vault object', + description: 'Get a vault object (metadata only; use --decrypt to include value)', positionals: [{ name: 'id', type: 'string', description: 'Object ID', required: true }], + options: [ + { name: 'decrypt', type: 'boolean', description: 'Include the decrypted secret value', required: false, default: false, hidden: false }, + ], }, { name: 'get-by-name', - description: 'Get a vault object by name', + description: 'Get a vault object by name (metadata only; use --decrypt to include value)', positionals: [{ name: 'name', type: 'string', description: 'Object name', required: true }], + options: [ + { name: 'decrypt', type: 'boolean', description: 'Include the decrypted secret value', required: false, default: false, hidden: false }, + ], }, { name: 'create', - description: 'Create a vault object', + description: 'Create a vault object (reads value from stdin when --value is omitted or -)', options: [ { name: 'name', type: 'string', description: 'Object name', required: true, hidden: false }, - { name: 'value', type: 'string', description: 'Secret value', required: true, hidden: false }, + { name: 'value', type: 'string', description: 'Secret value (omit or use - to read from stdin)', required: false, hidden: false }, { name: 'org', type: 'string', description: 'Organization ID (required)', required: true, hidden: false }, ], }, { name: 'update', - description: 'Update a vault object', + description: 'Update a vault object (reads value from stdin when --value is omitted or -)', positionals: [{ name: 'id', type: 'string', description: 'Object ID', required: true }], options: [ - { name: 'value', type: 'string', description: 'New value', required: true, hidden: false }, + { name: 'value', type: 'string', description: 'New value (omit or use - to read from stdin)', required: false, hidden: false }, { name: 'version-check', type: 'string', description: 'Version check ID', required: false, hidden: false }, ], }, From c7da7f3ef3a45a1ce4a5565fc49dbf4d60254be2 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 1 Jun 2026 16:59:03 -0500 Subject: [PATCH 5/8] fix: preserve stdin value verbatim without trimming readValueFromStdin was calling trimEnd() which silently strips trailing whitespace. The --value flag preserves the exact string, so stdin should too. --- src/commands/vault.spec.ts | 5 +---- src/commands/vault.ts | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/commands/vault.spec.ts b/src/commands/vault.spec.ts index 502e9bb4..b7fe7655 100644 --- a/src/commands/vault.spec.ts +++ b/src/commands/vault.spec.ts @@ -135,15 +135,12 @@ describe('vault commands', () => { }); it('exits with error when --org is not provided', async () => { - const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); const errOutput: string[] = []; vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { errOutput.push(args.map(String).join(' ')); }); - await runVaultCreate({ name: 'my-secret', value: 'secret-val' }, 'sk_test'); - expect(mockExit).toHaveBeenCalledWith(1); + await expect(runVaultCreate({ name: 'my-secret', value: 'secret-val' }, 'sk_test')).rejects.toThrow(); expect(errOutput.some((l) => l.includes('--org'))).toBe(true); - mockExit.mockRestore(); }); }); diff --git a/src/commands/vault.ts b/src/commands/vault.ts index 0aeca440..973ada7d 100644 --- a/src/commands/vault.ts +++ b/src/commands/vault.ts @@ -175,7 +175,7 @@ export async function readValueFromStdin(): Promise { for await (const chunk of process.stdin) { chunks.push(chunk); } - const value = Buffer.concat(chunks).toString('utf-8').trimEnd(); + const value = Buffer.concat(chunks).toString('utf-8'); if (value.length === 0) { exitWithError({ code: 'empty_stdin', From 9393571a3da98fdbca1d411d98f3571840ac7131 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 1 Jun 2026 19:58:09 -0500 Subject: [PATCH 6/8] chore: formatting --- src/bin.ts | 16 ++++++---------- src/utils/help-json.ts | 34 ++++++++++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index ab36ab4d..76f0508f 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -2003,8 +2003,7 @@ async function runCli(): Promise { const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runVaultCreate, readValueFromStdin } = await import('./commands/vault.js'); - const value = - argv.value === undefined || argv.value === '-' ? await readValueFromStdin() : argv.value; + const value = argv.value === undefined || argv.value === '-' ? await readValueFromStdin() : argv.value; await runVaultCreate( { name: argv.name, value, org: argv.org }, resolveApiKey({ apiKey: argv.apiKey }), @@ -2017,19 +2016,16 @@ async function runCli(): Promise { 'update ', 'Update a vault object (reads value from stdin when --value is omitted or -)', (y) => - y - .positional('id', { type: 'string', demandOption: true }) - .options({ - value: { type: 'string', describe: 'New value (omit or use - to read from stdin)' }, - 'version-check': { type: 'string' }, - }), + y.positional('id', { type: 'string', demandOption: true }).options({ + value: { type: 'string', describe: 'New value (omit or use - to read from stdin)' }, + 'version-check': { type: 'string' }, + }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runVaultUpdate, readValueFromStdin } = await import('./commands/vault.js'); - const value = - argv.value === undefined || argv.value === '-' ? await readValueFromStdin() : argv.value; + const value = argv.value === undefined || argv.value === '-' ? await readValueFromStdin() : argv.value; await runVaultUpdate( { id: argv.id, value, versionCheck: argv.versionCheck }, resolveApiKey({ apiKey: argv.apiKey }), diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index b4341a42..799f760c 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -1015,7 +1015,14 @@ const commands: CommandSchema[] = [ description: 'Get a vault object (metadata only; use --decrypt to include value)', positionals: [{ name: 'id', type: 'string', description: 'Object ID', required: true }], options: [ - { name: 'decrypt', type: 'boolean', description: 'Include the decrypted secret value', required: false, default: false, hidden: false }, + { + name: 'decrypt', + type: 'boolean', + description: 'Include the decrypted secret value', + required: false, + default: false, + hidden: false, + }, ], }, { @@ -1023,7 +1030,14 @@ const commands: CommandSchema[] = [ description: 'Get a vault object by name (metadata only; use --decrypt to include value)', positionals: [{ name: 'name', type: 'string', description: 'Object name', required: true }], options: [ - { name: 'decrypt', type: 'boolean', description: 'Include the decrypted secret value', required: false, default: false, hidden: false }, + { + name: 'decrypt', + type: 'boolean', + description: 'Include the decrypted secret value', + required: false, + default: false, + hidden: false, + }, ], }, { @@ -1031,7 +1045,13 @@ const commands: CommandSchema[] = [ description: 'Create a vault object (reads value from stdin when --value is omitted or -)', options: [ { name: 'name', type: 'string', description: 'Object name', required: true, hidden: false }, - { name: 'value', type: 'string', description: 'Secret value (omit or use - to read from stdin)', required: false, hidden: false }, + { + name: 'value', + type: 'string', + description: 'Secret value (omit or use - to read from stdin)', + required: false, + hidden: false, + }, { name: 'org', type: 'string', description: 'Organization ID (required)', required: true, hidden: false }, ], }, @@ -1040,7 +1060,13 @@ const commands: CommandSchema[] = [ description: 'Update a vault object (reads value from stdin when --value is omitted or -)', positionals: [{ name: 'id', type: 'string', description: 'Object ID', required: true }], options: [ - { name: 'value', type: 'string', description: 'New value (omit or use - to read from stdin)', required: false, hidden: false }, + { + name: 'value', + type: 'string', + description: 'New value (omit or use - to read from stdin)', + required: false, + hidden: false, + }, { name: 'version-check', type: 'string', description: 'Version check ID', required: false, hidden: false }, ], }, From dbd9224b60543a2a05ec41680f78b7c7ae4903fa Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 1 Jun 2026 20:18:32 -0500 Subject: [PATCH 7/8] fix: strip single trailing newline from stdin, narrow TypeError match Strip only a trailing \r?\n from stdin values so `echo "secret" |` works as expected without silently altering intentional whitespace. Narrow the SDK TypeError match from .includes('is not iterable') to .includes('errors is not iterable') to avoid misclassifying unrelated TypeErrors like "undefined is not iterable". --- src/commands/vault.ts | 2 +- src/lib/api-error-handler.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/vault.ts b/src/commands/vault.ts index 973ada7d..a1831c31 100644 --- a/src/commands/vault.ts +++ b/src/commands/vault.ts @@ -175,7 +175,7 @@ export async function readValueFromStdin(): Promise { for await (const chunk of process.stdin) { chunks.push(chunk); } - const value = Buffer.concat(chunks).toString('utf-8'); + const value = Buffer.concat(chunks).toString('utf-8').replace(/\r?\n$/, ''); if (value.length === 0) { exitWithError({ code: 'empty_stdin', diff --git a/src/lib/api-error-handler.ts b/src/lib/api-error-handler.ts index 702d298d..5711e8a0 100644 --- a/src/lib/api-error-handler.ts +++ b/src/lib/api-error-handler.ts @@ -55,7 +55,7 @@ function getApiErrorMessage(error: NormalizedApiError, label: string): string { */ export function createApiErrorHandler(resourceName: string) { return (error: unknown, context?: string): never => { - if (error instanceof TypeError && error.message.includes('is not iterable')) { + if (error instanceof TypeError && error.message.includes('errors is not iterable')) { exitWithError({ code: 'unprocessable_entity', message: `${resourceName} API rejected the request. Check that all required fields are provided.`, From 6baf5daa1d6ed9c181bb05de211d217603f39bb9 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 1 Jun 2026 20:24:14 -0500 Subject: [PATCH 8/8] chore: formatting --- src/commands/vault.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/vault.ts b/src/commands/vault.ts index a1831c31..9219b053 100644 --- a/src/commands/vault.ts +++ b/src/commands/vault.ts @@ -175,7 +175,9 @@ export async function readValueFromStdin(): Promise { for await (const chunk of process.stdin) { chunks.push(chunk); } - const value = Buffer.concat(chunks).toString('utf-8').replace(/\r?\n$/, ''); + const value = Buffer.concat(chunks) + .toString('utf-8') + .replace(/\r?\n$/, ''); if (value.length === 0) { exitWithError({ code: 'empty_stdin',