diff --git a/README.md b/README.md index aaa96227..facf85cc 100644 --- a/README.md +++ b/README.md @@ -536,13 +536,14 @@ 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 +workos vault run --secret ENV_VAR=vault-name [...] [--env ] [--dry-run] -- ``` #### api-key diff --git a/src/bin.ts b/src/bin.ts index 338f3e44..76f0508f 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1959,46 +1959,53 @@ 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 }, - org: { type: 'string' }, + 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 +2014,20 @@ 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' } }), + 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 } = 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(), ); @@ -2063,6 +2072,38 @@ 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)' }, + 'dry-run': { type: 'boolean', default: false, describe: 'Print which secrets would be injected, no fetch' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + + const { runVaultRun } = await import('./commands/vault-run.js'); + const childCommand = (argv['--'] as string[] | undefined) ?? []; + const exitCode = await runVaultRun( + { + secrets: argv.secret as string[], + command: childCommand, + env: argv.env, + dryRun: argv.dryRun, + }, + argv.apiKey as string | undefined, + ); + if (typeof exitCode === 'number') process.exit(exitCode); + }, + ); 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..6edd7921 --- /dev/null +++ b/src/commands/vault-run.spec.ts @@ -0,0 +1,413 @@ +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'); +vi.mock('../lib/api-key.js', () => ({ + resolveApiKey: (...args: unknown[]) => mockResolveApiKey(...(args as [])), + resolveApiBaseUrl: () => 'https://api.workos.com', +})); + +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() })); + +let outputModeState: 'human' | 'json' = 'human'; +const exitErrors: Array<{ code: string; message: string }> = []; + +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(), + 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 ---------- + +function createMockChild() { + const proc = new EventEmitter() as EventEmitter & { + kill: ReturnType; + killed: boolean; + }; + proc.kill = vi.fn(); + proc.killed = false; + return proc; +} + +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__:')) return; + throw err; + } +} + +// ---------- Tests ---------- + +describe('vault-run', () => { + let consoleLog: string[]; + let consoleErr: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleLog = []; + consoleErr = []; + exitErrors.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 in parallel', 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', + }); + + expect(mockSpawn).not.toHaveBeenCalled(); + const parsed = JSON.parse(consoleLog[0]); + expect(parsed.dryRun).toBe(true); + expect(parsed.env).toBe('production'); + expect(parsed.mappings).toEqual([{ envVar: 'DB_URL', vaultName: 'db' }]); + }); + }); + + describe('runVaultRun — execution', () => { + it('spawns child with injected env vars and returns exit code', async () => { + mockSdk.vault.readObjectByName.mockResolvedValueOnce({ + id: 'a', + name: 'db', + value: 'real-db-value', + metadata: {}, + }); + const child = createMockChild(); + mockSpawn.mockReturnValueOnce(child as never); + + const promise = runVaultRun({ + secrets: ['DB_URL=db'], + command: ['printenv', 'DB_URL'], + }); + exitChildAfterSpawn(child, 0); + const exitCode = await promise; + + expect(exitCode).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'); + }); + + 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 promise = runVaultRun({ + secrets: ['DB_URL=db'], + command: ['some-tool'], + }); + exitChildAfterSpawn(child, 42); + const exitCode = await promise; + + expect(exitCode).toBe(42); + }); + + 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 promise = runVaultRun({ + secrets: ['DB_URL=db'], + command: ['echo'], + env: 'staging', + }); + exitChildAfterSpawn(child, 0); + await promise; + + expect(mockResolveApiKey).not.toHaveBeenCalled(); + }); + + 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 metadata to stderr (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 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/); + }); + }); + + 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 promise = runVaultRun({ secrets: ['DB_URL=db'], command: ['true'] }); + exitChildAfterSpawn(child, 0); + await promise; + + // 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..9cc890b9 --- /dev/null +++ b/src/commands/vault-run.ts @@ -0,0 +1,235 @@ +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, exitWithError } from '../utils/output.js'; +import { formatTable } from '../utils/table.js'; +import { SPAWN_OPTS, IS_WINDOWS } from '../utils/platform.js'; + +const handleApiError = createApiErrorHandler('Vault'); + +export interface SecretMapping { + envVar: string; + vaultName: string; +} + +export interface VaultRunOptions { + secrets: string[]; + command: string[]; + env?: string; + dryRun?: boolean; +} + +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 (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; +} + +export async function fetchSecrets( + mappings: SecretMapping[], + apiKey: string, + baseUrl?: string, +): Promise> { + const client = createWorkOSClient(apiKey, baseUrl); + + 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`, + }); + } + return [envVar, obj.value]; + }), + ); + + return new Map(entries); +} + +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; +} + +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]; + // 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(); +} + +function printDryRun(mappings: SecretMapping[], envName?: string): void { + if (isJsonMode()) { + outputJson({ + dryRun: true, + env: envName ?? 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}`)); + console.log(); + const rows = mappings.map(({ envVar, vaultName }) => [envVar, vaultName]); + console.log(formatTable([{ header: 'Environment Variable' }, { header: 'Vault Object' }], rows)); +} + +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: 'spawn_failed', + message: `Failed to start: ${command}: ${message}`, + }); + } + + 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); + } + }); + + 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')); + } + + 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 { + const map: Record = { + SIGHUP: 1, + SIGINT: 2, + SIGQUIT: 3, + SIGKILL: 9, + SIGTERM: 15, + }; + return map[signal] ?? 0; +} + +export async function runVaultRun(options: VaultRunOptions, flagApiKey?: string): Promise { + const mappings = parseSecretMappings(options.secrets); + + if (options.dryRun) { + printDryRun(mappings, options.env); + 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 baseUrl = await resolveRunBaseUrl(options.env); + const secretValues = await fetchSecrets(mappings, apiKey, baseUrl); + + // Metadata to stderr so the child process owns stdout. + if (isJsonMode()) { + 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 }; + for (const [envVar, value] of secretValues) { + childEnv[envVar] = value; + } + + const [cmd, ...args] = options.command; + return spawnChild(cmd, args, childEnv); +} diff --git a/src/commands/vault.spec.ts b/src/commands/vault.spec.ts index 4d384191..b7fe7655 100644 --- a/src/commands/vault.spec.ts +++ b/src/commands/vault.spec.ts @@ -88,42 +88,59 @@ 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); }); }); 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 errOutput: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errOutput.push(args.map(String).join(' ')); }); + await expect(runVaultCreate({ name: 'my-secret', value: 'secret-val' }, 'sk_test')).rejects.toThrow(); + expect(errOutput.some((l) => l.includes('--org'))).toBe(true); }); }); @@ -186,9 +203,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'); @@ -196,7 +213,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..9219b053 100644 --- a/src/commands/vault.ts +++ b/src/commands/vault.ts @@ -1,7 +1,7 @@ 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'); @@ -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); } @@ -80,14 +95,20 @@ 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) { @@ -148,3 +169,20 @@ 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') + .replace(/\r?\n$/, ''); + 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/lib/api-error-handler.ts b/src/lib/api-error-handler.ts index be4d782e..5711e8a0 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('errors 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 88831499..799f760c 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -1012,29 +1012,61 @@ 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: 'org', type: 'string', description: 'Organization ID', 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 }, ], }, { 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 }, ], }, @@ -1053,6 +1085,34 @@ 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: 'dry-run', + type: 'boolean', + description: 'Print which secrets would be injected, no fetch', + required: false, + default: false, + hidden: false, + }, + ], + }, ], }, {