|
| 1 | +import { spawnSync } from 'node:child_process'; |
| 2 | +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; |
| 3 | +import { tmpdir } from 'node:os'; |
| 4 | +import { join } from 'node:path'; |
| 5 | + |
| 6 | +/** |
| 7 | + * Read API keys out of a logicsrc team vault. |
| 8 | + * |
| 9 | + * The vault is the authority; {@link ../credentials.ts} is a cache of the few |
| 10 | + * keys these commands actually use. That direction matters — copying the whole |
| 11 | + * vault down would make the local file a second, silently drifting copy of |
| 12 | + * every team secret, which is the thing the vault exists to avoid. |
| 13 | + * |
| 14 | + * `logicsrc teams pull` can only write a decrypted `.env` to a path, so the |
| 15 | + * plaintext exists on disk for the length of one read. It goes to a 0700 |
| 16 | + * temporary directory and is removed in a `finally`, including when the parse |
| 17 | + * throws. |
| 18 | + */ |
| 19 | + |
| 20 | +export interface VaultTarget { |
| 21 | + team: string; |
| 22 | + project: string; |
| 23 | + env: string; |
| 24 | +} |
| 25 | + |
| 26 | +/** The team vault holding account-level keys shared across the org. */ |
| 27 | +export const DEFAULT_TARGET: VaultTarget = { |
| 28 | + team: 'profullstack', |
| 29 | + project: 'profullstack-sharable-keys', |
| 30 | + env: 'prod', |
| 31 | +}; |
| 32 | + |
| 33 | +/** Resolve the target, letting the environment point at a different vault. */ |
| 34 | +export function vaultTarget(env: NodeJS.ProcessEnv = process.env): VaultTarget { |
| 35 | + return { |
| 36 | + team: env.CLI_TOOLS_VAULT_TEAM || DEFAULT_TARGET.team, |
| 37 | + project: env.CLI_TOOLS_VAULT_PROJECT || DEFAULT_TARGET.project, |
| 38 | + env: env.CLI_TOOLS_VAULT_ENV || DEFAULT_TARGET.env, |
| 39 | + }; |
| 40 | +} |
| 41 | + |
| 42 | +/** Parse a dotenv file. Only what a vault actually contains: KEY=value lines. */ |
| 43 | +export function parseEnvFile(text: string): Record<string, string> { |
| 44 | + const parsed: Record<string, string> = {}; |
| 45 | + for (const raw of text.split('\n')) { |
| 46 | + const line = raw.trim(); |
| 47 | + if (!line || line.startsWith('#')) continue; |
| 48 | + const at = line.indexOf('='); |
| 49 | + if (at <= 0) continue; |
| 50 | + |
| 51 | + const key = line.slice(0, at).trim(); |
| 52 | + let value = line.slice(at + 1).trim(); |
| 53 | + if ( |
| 54 | + (value.startsWith('"') && value.endsWith('"') && value.length > 1) || |
| 55 | + (value.startsWith("'") && value.endsWith("'") && value.length > 1) |
| 56 | + ) { |
| 57 | + value = value.slice(1, -1); |
| 58 | + } |
| 59 | + if (key && value) parsed[key] = value; |
| 60 | + } |
| 61 | + return parsed; |
| 62 | +} |
| 63 | + |
| 64 | +export type Runner = (args: readonly string[], envPath: string) => { status: number; stderr: string }; |
| 65 | + |
| 66 | +/** Shell out to the real logicsrc CLI. */ |
| 67 | +export const logicsrcRunner: Runner = (args, envPath) => { |
| 68 | + const result = spawnSync('logicsrc', [...args, '--env', envPath], { encoding: 'utf8' }); |
| 69 | + if (result.error) { |
| 70 | + const code = (result.error as NodeJS.ErrnoException).code; |
| 71 | + if (code === 'ENOENT') { |
| 72 | + return { |
| 73 | + status: 127, |
| 74 | + stderr: |
| 75 | + 'logicsrc is not installed. Install it with `moshcode install secrets`, ' + |
| 76 | + 'or see https://logicsrc.com', |
| 77 | + }; |
| 78 | + } |
| 79 | + return { status: 1, stderr: result.error.message }; |
| 80 | + } |
| 81 | + return { status: result.status ?? 1, stderr: result.stderr ?? '' }; |
| 82 | +}; |
| 83 | + |
| 84 | +/** |
| 85 | + * Pull a vault and return its keys. |
| 86 | + * |
| 87 | + * The decrypted file never leaves this function, and the caller receives only |
| 88 | + * the parsed record — so nothing downstream has a path it could accidentally |
| 89 | + * leave lying around. |
| 90 | + */ |
| 91 | +export function pullVault( |
| 92 | + target: VaultTarget = vaultTarget(), |
| 93 | + run: Runner = logicsrcRunner, |
| 94 | +): Record<string, string> { |
| 95 | + const dir = mkdtempSync(join(tmpdir(), 'cli-tools-vault-')); |
| 96 | + const envPath = join(dir, 'vault.env'); |
| 97 | + |
| 98 | + try { |
| 99 | + const { status, stderr } = run( |
| 100 | + ['teams', 'pull', target.team, target.project, target.env], |
| 101 | + envPath, |
| 102 | + ); |
| 103 | + if (status !== 0) { |
| 104 | + const detail = stderr.trim().split('\n').slice(-3).join('\n'); |
| 105 | + throw new Error( |
| 106 | + `logicsrc teams pull ${target.team} ${target.project} ${target.env} failed` + |
| 107 | + (detail ? `:\n${detail}` : '.'), |
| 108 | + ); |
| 109 | + } |
| 110 | + |
| 111 | + let text: string; |
| 112 | + try { |
| 113 | + text = readFileSync(envPath, 'utf8'); |
| 114 | + } catch { |
| 115 | + throw new Error('logicsrc reported success but wrote no file — nothing imported.'); |
| 116 | + } |
| 117 | + return parseEnvFile(text); |
| 118 | + } finally { |
| 119 | + // Recursive so the temp directory goes with it, and force so a failure |
| 120 | + // before the file existed is not itself an error. |
| 121 | + rmSync(dir, { recursive: true, force: true }); |
| 122 | + } |
| 123 | +} |
0 commit comments