|
| 1 | +/** |
| 2 | + * `sealed-env deploy [--health-url <url>] -- <command> [args...]` |
| 3 | + * |
| 4 | + * Production deploy wrapper around `exec`. Adds the safety rails that |
| 5 | + * a hand-rolled deploy.sh would otherwise have to implement: |
| 6 | + * |
| 7 | + * - Auto-detects `deploy_id` from `git rev-parse HEAD`. |
| 8 | + * - Refuses to deploy with a dirty working tree (uncommitted changes |
| 9 | + * would silently NOT be in the build, since the deploy_id binds to |
| 10 | + * the committed sha). |
| 11 | + * - Prompts the operator for the TOTP code (hidden from logs). |
| 12 | + * - Mints the unseal token IN MEMORY and injects only the resulting |
| 13 | + * plaintext env vars into the child. The master/signing/TOTP |
| 14 | + * credentials never reach the child process or stdout. |
| 15 | + * - Optionally polls a health endpoint after the command exits. |
| 16 | + * |
| 17 | + * $ sealed-env deploy -- docker compose up -d --build status |
| 18 | + * $ sealed-env deploy --health-url http://127.0.0.1:8090/actuator/health -- ./up.sh |
| 19 | + * |
| 20 | + * For the file path, defaults to `.env.sealed`. Override with --file. |
| 21 | + * |
| 22 | + * Compared to a hand-written deploy.sh: ~5 lines instead of ~130, and |
| 23 | + * the token never appears in stdout (no fragile grep). |
| 24 | + */ |
| 25 | + |
| 26 | +import { execSync } from 'node:child_process'; |
| 27 | + |
| 28 | +import { SealedEnvError } from '../../core/errors.js'; |
| 29 | +import { execCommand } from './exec.js'; |
| 30 | +import { parseFlags } from '../utils/flags.js'; |
| 31 | + |
| 32 | +export async function deployCommand(argv: string[]): Promise<void> { |
| 33 | + const sepIndex = argv.indexOf('--'); |
| 34 | + if (sepIndex === -1) { |
| 35 | + throw new SealedEnvError( |
| 36 | + 'CONFIG_ERROR', |
| 37 | + 'usage: sealed-env deploy [--file <path>] [--health-url <url>] [--health-timeout <s>] [--allow-dirty] -- <command> [args...]\n' + |
| 38 | + '\nThe `--` separator marks where sealed-env flags end.', |
| 39 | + ); |
| 40 | + } |
| 41 | + |
| 42 | + const sealedArgs = argv.slice(0, sepIndex); |
| 43 | + const childArgs = argv.slice(sepIndex + 1); |
| 44 | + |
| 45 | + if (childArgs.length === 0) { |
| 46 | + throw new SealedEnvError('CONFIG_ERROR', 'no command given after `--`'); |
| 47 | + } |
| 48 | + |
| 49 | + const { values } = parseFlags(sealedArgs, { |
| 50 | + file: { type: 'string', default: '.env.sealed' }, |
| 51 | + 'health-url': { type: 'string', default: '' }, |
| 52 | + 'health-timeout': { type: 'string', default: '30' }, |
| 53 | + 'allow-dirty': { type: 'boolean', default: false }, |
| 54 | + totp: { type: 'string', default: '' }, |
| 55 | + 'deploy-id': { type: 'string', default: '' }, |
| 56 | + }); |
| 57 | + |
| 58 | + // ── Pre-flight ──────────────────────────────────────────────── |
| 59 | + let deployId = (values['deploy-id'] as string).trim(); |
| 60 | + if (!deployId) { |
| 61 | + deployId = tryGitHead(); |
| 62 | + } |
| 63 | + |
| 64 | + const allowDirty = values['allow-dirty'] as boolean; |
| 65 | + if (!allowDirty && isInGitRepo() && isWorkingTreeDirty()) { |
| 66 | + throw new SealedEnvError( |
| 67 | + 'CONFIG_ERROR', |
| 68 | + 'working tree is dirty — refusing to deploy.\n' + |
| 69 | + 'Commit or stash your changes first, or pass --allow-dirty (NOT recommended for prod).\n' + |
| 70 | + 'The deploy_id binds to the committed sha; uncommitted changes would silently not be in the build.', |
| 71 | + ); |
| 72 | + } |
| 73 | + |
| 74 | + // ── Banner ──────────────────────────────────────────────────── |
| 75 | + const banner = buildBanner(deployId); |
| 76 | + process.stderr.write(banner); |
| 77 | + |
| 78 | + // ── Delegate to exec ────────────────────────────────────────── |
| 79 | + // exec handles: enterprise mode detection, TOTP prompt, token mint |
| 80 | + // in memory, plaintext injection, signal forwarding, exit code |
| 81 | + // propagation. We just feed it the right arguments. |
| 82 | + const execArgs: string[] = [ |
| 83 | + '--file', |
| 84 | + values.file as string, |
| 85 | + ]; |
| 86 | + if (deployId) execArgs.push('--deploy-id', deployId); |
| 87 | + if (values.totp) execArgs.push('--totp', values.totp as string); |
| 88 | + execArgs.push('--', ...childArgs); |
| 89 | + |
| 90 | + await execCommand(execArgs); |
| 91 | + |
| 92 | + // If the child exited non-zero, exec already set process.exitCode. |
| 93 | + // No point health-checking a failed deploy. |
| 94 | + if (process.exitCode && process.exitCode !== 0) { |
| 95 | + return; |
| 96 | + } |
| 97 | + |
| 98 | + // ── Optional health check ──────────────────────────────────── |
| 99 | + const healthUrl = (values['health-url'] as string).trim(); |
| 100 | + if (healthUrl) { |
| 101 | + const timeoutS = Math.max(Number(values['health-timeout']) || 30, 1); |
| 102 | + process.stderr.write(`\n▸ Waiting for ${healthUrl} (up to ${timeoutS}s)...\n`); |
| 103 | + const ok = await pollHealth(healthUrl, timeoutS * 1000); |
| 104 | + if (!ok) { |
| 105 | + process.stderr.write(`✗ health check failed after ${timeoutS}s\n`); |
| 106 | + process.exitCode = 1; |
| 107 | + return; |
| 108 | + } |
| 109 | + process.stderr.write(`✓ ${healthUrl} returned 200\n`); |
| 110 | + } |
| 111 | + |
| 112 | + process.stderr.write( |
| 113 | + `\n✓ Deploy successful${deployId ? ` (${deployId.substring(0, 7)})` : ''}\n`, |
| 114 | + ); |
| 115 | +} |
| 116 | + |
| 117 | +function tryGitHead(): string { |
| 118 | + try { |
| 119 | + return execSync('git rev-parse HEAD', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) |
| 120 | + .trim(); |
| 121 | + } catch { |
| 122 | + return ''; |
| 123 | + } |
| 124 | +} |
| 125 | + |
| 126 | +function isInGitRepo(): boolean { |
| 127 | + try { |
| 128 | + execSync('git rev-parse --is-inside-work-tree', { |
| 129 | + stdio: ['ignore', 'ignore', 'ignore'], |
| 130 | + }); |
| 131 | + return true; |
| 132 | + } catch { |
| 133 | + return false; |
| 134 | + } |
| 135 | +} |
| 136 | + |
| 137 | +function isWorkingTreeDirty(): boolean { |
| 138 | + try { |
| 139 | + execSync('git diff-index --quiet HEAD --', { |
| 140 | + stdio: ['ignore', 'ignore', 'ignore'], |
| 141 | + }); |
| 142 | + return false; |
| 143 | + } catch { |
| 144 | + return true; |
| 145 | + } |
| 146 | +} |
| 147 | + |
| 148 | +function buildBanner(deployId: string): string { |
| 149 | + const short = deployId ? deployId.substring(0, 7) : '(no git)'; |
| 150 | + let branch = ''; |
| 151 | + let subject = ''; |
| 152 | + try { |
| 153 | + branch = execSync('git rev-parse --abbrev-ref HEAD', { |
| 154 | + encoding: 'utf8', |
| 155 | + stdio: ['ignore', 'pipe', 'ignore'], |
| 156 | + }).trim(); |
| 157 | + subject = execSync('git log -1 --pretty=%s', { |
| 158 | + encoding: 'utf8', |
| 159 | + stdio: ['ignore', 'pipe', 'ignore'], |
| 160 | + }) |
| 161 | + .trim() |
| 162 | + .substring(0, 60); |
| 163 | + } catch { |
| 164 | + /* not a git repo, skip */ |
| 165 | + } |
| 166 | + return [ |
| 167 | + '', |
| 168 | + ' ┌─ sealed-env deploy ──────────────────────────────────────┐', |
| 169 | + ` │ branch: ${branch || '(unknown)'}`, |
| 170 | + ` │ commit: ${short}`, |
| 171 | + ...(subject ? [` │ message: ${subject}`] : []), |
| 172 | + ' └──────────────────────────────────────────────────────────┘', |
| 173 | + '', |
| 174 | + ].join('\n'); |
| 175 | +} |
| 176 | + |
| 177 | +/** |
| 178 | + * Poll an HTTP endpoint until it returns 2xx or the timeout elapses. |
| 179 | + * Uses Node's built-in fetch (Node 18+). 1s between attempts. |
| 180 | + */ |
| 181 | +async function pollHealth(url: string, timeoutMs: number): Promise<boolean> { |
| 182 | + const deadline = Date.now() + timeoutMs; |
| 183 | + while (Date.now() < deadline) { |
| 184 | + try { |
| 185 | + const res = await fetch(url, { signal: AbortSignal.timeout(2000) }); |
| 186 | + if (res.ok) return true; |
| 187 | + } catch { |
| 188 | + /* swallow, keep retrying */ |
| 189 | + } |
| 190 | + await new Promise((r) => setTimeout(r, 1000)); |
| 191 | + } |
| 192 | + return false; |
| 193 | +} |
0 commit comments