← back to Secrets Manager
cli.js
434 lines
#!/usr/bin/env node
// Secrets Manager CLI — single source of truth for Steve's API tokens.
//
// Usage:
// node cli.js list
// node cli.js add <KEY> <VALUE>
// node cli.js import-paste # reads KEY=VALUE block from stdin
// node cli.js check <KEY>
// node cli.js sync # re-fan master → all destinations
// node cli.js audit # scan home for leaked secrets
//
// No deps. Pure node, https module, fs, child_process for grep.
const fs = require('fs');
const path = require('path');
const os = require('os');
const https = require('https');
const crypto = require('crypto');
const { execSync } = require('child_process');
const { spawnSync } = require('child_process');
const ROOT = path.dirname(__filename);
const HOME = os.homedir();
const ROUTES = JSON.parse(fs.readFileSync(path.join(ROOT, 'routes.json'), 'utf8'));
// routes.json carries some routes under .services AND some at top-level (legacy).
// cli.js historically read ONLY .services[key], so ~22 top-level routes
// (PG_DW_ADMIN_PASSWORD, SHOPIFY_ORDERS_TOKEN, TWILIO_*, GOOGLE_DRIVE_*, the
// ANTHROPIC_* keys…) were INVISIBLE to the fan — fanning them wrote only
// master+desktop and silently skipped every destination. routeFor() falls back
// to the top-level entry so all routes fan. (Root cause of the 2026-06-03
// dw_admin rotation half-fire.) Prefer .services on name clash.
function routeFor(key) { return (ROUTES.services && ROUTES.services[key]) || ROUTES[key] || null; }
// DB-password keys whose consumers connect via a full DSN, not a bare var — the
// fan must rewrite the password INSIDE that DSN in each env_file dest.
const DSN_REWRITE = {
PG_DW_ADMIN_PASSWORD: { dsn: 'DATABASE_URL', user: 'dw_admin' },
};
const MASTER_ENV = path.join(ROOT, '.env');
// DESKTOP_ENV (~/Desktop/site-factory.env) retired 2026-06-11 (DTD verdict A) —
// no longer a fan-out destination. See fanOut() for rationale.
const REGISTRY = path.join(ROOT, 'registry.json');
// ─── helpers ────────────────────────────────────────────────────────────
function expand(p) { return p.replace(/^~/, HOME); }
function digest(v) { return v.slice(-4) + ':' + crypto.createHash('sha256').update(v).digest('hex').slice(0, 8); }
function loadRegistry() { return fs.existsSync(REGISTRY) ? JSON.parse(fs.readFileSync(REGISTRY, 'utf8')) : { secrets: {} }; }
function saveRegistry(r) { fs.writeFileSync(REGISTRY, JSON.stringify(r, null, 2)); }
function loadEnvFile(p) {
if (!fs.existsSync(p)) return {};
const out = {};
for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/);
if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
return out;
}
// dotenv treats `#` as a comment delimiter unless the value is quoted, and
// chokes on space-edge values + literal newlines. Auto-quote anything that
// would parse short. Bug: previously a sync stripped quotes off
// MAILING_ADDRESS="…#102…" → dotenv parsed only "Designer Wallcoverings…Bl "
// and the CAN-SPAM compliance gate fail-closed in production.
function envEscape(v) {
const s = String(v);
if (s === '') return '';
if (/[#"'\n\r]|^\s|\s$/.test(s)) return '"' + s.replace(/(["\\])/g, '\\$1') + '"';
return s;
}
function writeEnvFile(p, kv, preserveComments = true) {
fs.mkdirSync(path.dirname(p), { recursive: true });
let body = '';
if (preserveComments && fs.existsSync(p)) {
// Preserve existing lines, replacing matching keys
const seen = new Set();
for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=/);
if (m && kv[m[1]] !== undefined) { body += `${m[1]}=${envEscape(kv[m[1]])}\n`; seen.add(m[1]); }
else body += line + '\n';
}
for (const k of Object.keys(kv)) if (!seen.has(k)) body += `${k}=${envEscape(kv[k])}\n`;
body = body.replace(/\n+$/, '\n');
} else {
for (const [k, v] of Object.entries(kv)) body += `${k}=${envEscape(v)}\n`;
}
fs.writeFileSync(p, body);
try { fs.chmodSync(p, 0o600); } catch {}
}
// Rewrite the password segment of a DSN var (e.g. DATABASE_URL=postgres://user:PW@host)
// inside an env-file body, for the given DB user. The new pw is URL-encoded so base64
// chars (+ / =) don't corrupt the URL userinfo. Returns {body, changed}.
//
// WHY this exists: dw_admin consumers connect via a full DATABASE_URL DSN, NOT a bare
// *_PASSWORD var. A fan that only sets the bare var leaves DATABASE_URL holding the OLD
// pw → restart → FATAL auth. That was the 2026-06-03 rotation half-fire. The pg-rotation
// route now carries `dsn: "DATABASE_URL"` so the fan rewrites the live DSN too.
function rewriteDsnPassword(text, dsnVar, dbUser, newPw) {
if (!text) return { body: text, changed: 0 };
const enc = encodeURIComponent(newPw);
let changed = 0;
const re = new RegExp('^(\\s*' + dsnVar + '\\s*=\\s*["\']?)(postgres(?:ql)?:\\/\\/' + dbUser + ':)([^@]*)(@)', 'i');
const body = text.split('\n').map((line) => {
const m = line.match(re);
if (m) { changed++; return m[1] + m[2] + enc + m[4] + line.slice(m[0].length); }
return line;
}).join('\n');
return { body, changed };
}
// Remote env via SSH. Hosts must have SSH key auth set up; we never prompt
// for a password (BatchMode=yes). Connect timeout caps stalls at 8s.
function readRemoteEnvFile(user, host, p) {
const r = spawnSync('ssh', [
'-o', 'BatchMode=yes',
'-o', 'ConnectTimeout=8',
'-o', 'StrictHostKeyChecking=accept-new',
`${user}@${host}`,
`cat ${p} 2>/dev/null || true`,
], { encoding: 'utf8' });
return r.status === 0 ? (r.stdout || '') : '';
}
function parseEnvString(s) {
const out = {};
for (const line of s.split('\n')) {
const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/);
if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
return out;
}
function serializeEnvBody(existingText, kv) {
// Preserve comments + ordering, replace matching keys, append new ones.
// envEscape() handles `#`/quote/space-edge values so dotenv parses them whole.
let body = '';
const seen = new Set();
if (existingText) {
for (const line of existingText.split('\n')) {
const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=/);
if (m && kv[m[1]] !== undefined) { body += `${m[1]}=${envEscape(kv[m[1]])}\n`; seen.add(m[1]); }
else body += line + '\n';
}
}
for (const k of Object.keys(kv)) if (!seen.has(k)) body += `${k}=${envEscape(kv[k])}\n`;
return body.replace(/\n+$/, '\n');
}
function writeRemoteEnvFile(user, host, p, body) {
// Use ssh + tee with stdin so we never pass the secret on the command line.
// Remote dir is ensured first; permissions tightened to 600 after write.
const dir = p.replace(/\/[^/]+$/, '') || '.';
const remoteCmd = `mkdir -p '${dir}' && cat > '${p}' && chmod 600 '${p}'`;
const r = spawnSync('ssh', [
'-o', 'BatchMode=yes',
'-o', 'ConnectTimeout=8',
'-o', 'StrictHostKeyChecking=accept-new',
`${user}@${host}`,
remoteCmd,
], { input: body, encoding: 'utf8' });
if (r.status !== 0) {
throw new Error(`ssh write failed (${user}@${host}:${p}): ${(r.stderr || '').slice(0, 200)}`);
}
}
function httpsReq(url, opts = {}) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const req = https.request({
hostname: u.hostname, path: u.pathname + u.search, method: opts.method || 'GET',
headers: opts.headers || {},
}, res => {
let body = '';
res.on('data', c => body += c);
res.on('end', () => resolve({ status: res.statusCode, body }));
});
req.on('error', reject);
if (opts.body) req.write(opts.body);
req.end();
});
}
// ─── verify ─────────────────────────────────────────────────────────────
async function verifyToken(key, value) {
const cfg = routeFor(key)?.verify;
if (!cfg) return { ok: true, skipped: true };
const headers = {};
if (cfg.auth === 'bearer') headers['Authorization'] = `Bearer ${value}`;
if (cfg.auth === 'basic-sk') headers['Authorization'] = 'Basic ' + Buffer.from(value + ':').toString('base64');
if (cfg.auth === 'x-api-key') { headers['x-api-key'] = value; headers['anthropic-version'] = '2023-06-01'; }
// generic 'header' mode: caller specifies cfg.headerName (e.g. 'xi-api-key' for ElevenLabs)
if (cfg.auth === 'header' && cfg.headerName) headers[cfg.headerName] = value;
if (cfg.body) headers['Content-Type'] = 'application/json';
// 'url' / 'query' auth: substitute {value} in the URL (for APIs that take the key as a query param)
const url = (cfg.auth === 'url' || cfg.auth === 'query')
? cfg.url.replace('{value}', encodeURIComponent(value))
: cfg.url;
try {
const r = await httpsReq(url, { method: cfg.method, headers, body: cfg.body });
// Anthropic-specific: 400 with "credit balance" means the key auth'd but the account is out of credits.
// That's still a valid key; persist but flag with status.
const creditOk = r.status === 400 && /credit balance|insufficient/i.test(r.body);
let httpOk = (r.status >= 200 && r.status < 300) || creditOk;
// Body-content guards (e.g. Purelymail returns HTTP 200 + {"type":"error"} on bad token).
// Cap the probe at 4096 bytes to bound regex worst-case.
const probe = (r.body || '').slice(0, 4096);
if (httpOk && cfg.bodyMustNotContain && new RegExp(cfg.bodyMustNotContain, 'i').test(probe)) httpOk = false;
if (httpOk && cfg.bodyMustContain && !new RegExp(cfg.bodyMustContain, 'i').test(probe)) httpOk = false;
return { ok: httpOk, status: r.status, body: r.body.slice(0, 200) };
} catch (e) {
return { ok: false, error: e.message };
}
}
// ─── fan-out ────────────────────────────────────────────────────────────
function fanOut(key, value) {
const destinations = routeFor(key)?.destinations || [];
const written = [];
// Master always
const master = loadEnvFile(MASTER_ENV); master[key] = value;
writeEnvFile(MASTER_ENV, master, false); written.push(MASTER_ENV);
// Desktop master mirror REMOVED 2026-06-11 (DTD verdict A): the Desktop is a
// high-exposure location (Time Machine / iCloud Desktop-sync / screenshots),
// chmod 600 gives no protection against code running as the user (the
// prompt-injection threat that prompted this), and the mirror was pure
// redundancy with MASTER_ENV. Canonical master at ~/Projects/secrets-manager/.env
// remains the single source of truth. Do NOT reintroduce a Desktop copy.
for (const d of destinations) {
if (d.type === 'project') {
// Local project (no host) — original behavior.
// Remote project (d.host set) — push via SSH; failures log + skip,
// they don't blow up the rest of the fan-out.
if (d.host) {
const user = d.user || 'root';
try {
const existing = readRemoteEnvFile(user, d.host, d.path);
const cur = parseEnvString(existing); cur[key] = value;
const body = serializeEnvBody(existing, cur);
writeRemoteEnvFile(user, d.host, d.path, body);
written.push(`ssh:${user}@${d.host}:${d.path}`);
} catch (e) {
console.error(` ⚠ remote skip ${user}@${d.host}:${d.path} — ${e.message}`);
}
} else {
const p = expand(d.path);
const cur = loadEnvFile(p); cur[key] = value;
writeEnvFile(p, cur, true); written.push(p);
}
} else if (d.type === 'env_file') {
// Local env file. Sets the bare key, AND — when this key feeds a DSN
// (per-dest `d.dsn`, or the code-driven DSN_REWRITE map) — surgically
// rewrites the password inside that DSN var (e.g. DATABASE_URL) for the
// DB user. The dw_admin rotation path NEEDS this: consumers read
// DATABASE_URL, not the bare *_PASSWORD var. (Pre-2026-06-09 the fan had
// no env_file branch at all → these dests were silently skipped.)
const p = expand(d.path);
if (!fs.existsSync(p)) { console.error(` ⚠ env_file skip (missing): ${p}`); continue; }
let text = fs.readFileSync(p, 'utf8');
let note = '';
const dsnSpec = d.dsn ? { dsn: d.dsn, user: d.dsnUser || 'dw_admin' } : DSN_REWRITE[key];
if (dsnSpec) {
const { body, changed } = rewriteDsnPassword(text, dsnSpec.dsn, dsnSpec.user, value);
text = body;
note = changed ? ` [DSN ${dsnSpec.dsn}×${changed}]` : ` [⚠ DSN ${dsnSpec.dsn} NOT FOUND]`;
}
// serializeEnvBody only rewrites keys present in the kv map, so the
// already-rewritten DSN line is preserved verbatim (not re-parsed).
const outKey = d.key || key; // honor per-destination key remap (e.g. SHOPIFY_ADMIN_TOKEN -> SHOPIFY_ADMIN_ACCESS_TOKEN)
const finalBody = serializeEnvBody(text, d.dsnOnly ? {} : { [outKey]: value });
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, finalBody);
try { fs.chmodSync(p, 0o600); } catch {}
written.push(p + note);
} else if (d.type === 'skill') {
const p = path.join(HOME, '.claude/skills', d.name, '.env');
const cur = loadEnvFile(p); cur[key] = value;
writeEnvFile(p, cur, true); written.push(p);
} else if (d.type === 'site_local') {
for (const dom of d.domains) {
const p = expand(`~/Projects/site-factory/sites/${dom}/app/.env.local`);
const cur = loadEnvFile(p); cur[key] = value;
writeEnvFile(p, cur, true); written.push(p);
}
} else if (d.type === 'mcp') {
const cfgPath = path.join(HOME, '.claude.json');
if (!fs.existsSync(cfgPath)) continue;
const bak = `${cfgPath}.bak.${Date.now()}`;
fs.copyFileSync(cfgPath, bak);
const j = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
const sv = (j.mcpServers && j.mcpServers[d.server]) || null;
if (sv) {
sv.env = sv.env || {};
sv.env[d.key || key] = value;
fs.writeFileSync(cfgPath, JSON.stringify(j, null, 2));
written.push(`${cfgPath}#mcpServers.${d.server}.env.${d.key || key}`);
}
}
}
return written;
}
// ─── commands ───────────────────────────────────────────────────────────
async function cmdAdd(key, value) {
if (!key || !value) { console.error('usage: add <KEY> <VALUE>'); process.exit(2); }
const v = await verifyToken(key, value);
if (!v.ok) {
console.error(`✗ verify failed for ${key}: ${v.status || v.error || ''}`);
if (v.body) console.error(' ' + v.body);
process.exit(1);
}
const written = fanOut(key, value);
const r = loadRegistry();
r.secrets[key] = {
digest: digest(value),
label: routeFor(key)?.label || key,
last_updated: new Date().toISOString(),
validated: !v.skipped,
verify_status: v.status || null,
written_to: written,
};
saveRegistry(r);
console.log(`[secrets-manager]`);
console.log(` service: ${key}`);
console.log(` action: add`);
console.log(` validated: ${v.skipped ? 'skipped (no verify endpoint)' : 'yes'}`);
console.log(` digest: …${r.secrets[key].digest}`);
console.log(` written_to: ${written.length} destinations`);
for (const w of written) console.log(` - ${w}`);
}
async function cmdImportPaste() {
const stdin = fs.readFileSync(0, 'utf8');
const lines = stdin.split('\n').map(l => l.trim()).filter(Boolean);
const adds = [];
for (const line of lines) {
const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/);
if (m && m[2] && !m[2].startsWith('#')) adds.push([m[1], m[2].replace(/^["']|["']$/g, '')]);
}
if (!adds.length) { console.error('no KEY=value lines found in stdin'); process.exit(2); }
console.log(`importing ${adds.length} key(s): ${adds.map(([k]) => k).join(', ')}`);
for (const [k, v] of adds) {
try { await cmdAdd(k, v); } catch (e) { console.error(` ${k}: ${e.message}`); }
}
}
function cmdList() {
const r = loadRegistry();
console.log('Known services:');
for (const k of Object.keys(ROUTES.services)) {
const e = r.secrets[k];
if (e) console.log(` ✓ ${k.padEnd(28)} ${e.label.padEnd(28)} …${e.digest} ${e.last_updated.slice(0,10)}`);
else console.log(` ✗ ${k.padEnd(28)} ${ROUTES.services[k].label.padEnd(28)} <not set>`);
}
}
async function cmdCheck(key) {
if (!key) { console.error('usage: check <KEY>'); process.exit(2); }
const master = loadEnvFile(MASTER_ENV);
const v = master[key];
if (!v) { console.error(`${key}: not in master`); process.exit(1); }
const r = await verifyToken(key, v);
console.log(`${key}: ${r.ok ? 'VALID' : 'INVALID'} status=${r.status || r.error || 'n/a'}`);
}
function cmdSync() {
const master = loadEnvFile(MASTER_ENV);
let total = 0;
for (const k of Object.keys(ROUTES.services)) {
if (master[k]) total += fanOut(k, master[k]).length;
}
console.log(`sync: re-wrote ${total} destination entries from master`);
// REMOTE fan-out is a SEPARATE, EXPLICIT step (never an automatic side-effect of sync):
// node scripts/push-remote.js # SSH remote-routed secrets to their server(s) + pm2 reload
// Kept opt-in on purpose — a routine local sync must not silently write to a prod host.
if (fs.existsSync(path.join(ROOT, 'remote-routes.json'))) {
console.log('sync: remote destinations exist — run `node scripts/push-remote.js` to push them to prod.');
}
}
function cmdAudit() {
const patterns = ROUTES.leak_patterns || [];
const roots = [
path.join(HOME, '.claude/skills'),
path.join(HOME, '.claude/agents'),
path.join(HOME, 'Projects'),
path.join(HOME, 'cncp-starter'),
];
const exts = '\\.(md|json|js|ts|tsx|sh|py|env|local|toml|yaml|yml)$';
const findings = [];
for (const root of roots) {
if (!fs.existsSync(root)) continue;
let files;
try {
files = execSync(
`find "${root}" -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/dist/*" -not -path "*/.next/*" 2>/dev/null | grep -E '${exts}'`,
{ encoding: 'utf8', maxBuffer: 1024 * 1024 * 50 }
).split('\n').filter(Boolean);
} catch { continue; }
for (const f of files) {
let text;
try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
// Skip our own .env files (they SHOULD have secrets, that's their job)
if (f.endsWith('/.env') || f.endsWith('/.env.local')) continue;
// Skip the master + registry (the manager itself)
if (f === MASTER_ENV || f === REGISTRY || f === path.join(ROOT, 'routes.json')) continue;
for (const p of patterns) {
const re = new RegExp(p.regex, 'g');
let m;
while ((m = re.exec(text)) !== null) {
const captured = m[1] || m[0];
const line = text.slice(0, m.index).split('\n').length;
findings.push({ file: f, line, pattern: p.name, last4: captured.slice(-4) });
}
}
}
}
if (!findings.length) { console.log('audit: no leaked secrets found'); return; }
console.log(`audit: ${findings.length} finding(s):`);
for (const f of findings) console.log(` ${f.pattern.padEnd(24)} ${f.file}:${f.line} …${f.last4}`);
}
// ─── dispatch ───────────────────────────────────────────────────────────
const [, , cmd, ...rest] = process.argv;
(async () => {
switch (cmd) {
case 'add': await cmdAdd(rest[0], rest[1]); break;
case 'import-paste': await cmdImportPaste(); break;
case 'list': cmdList(); break;
case 'check': await cmdCheck(rest[0]); break;
case 'sync': cmdSync(); break;
case 'audit': cmdAudit(); break;
default:
console.error('usage: cli.js <add|import-paste|list|check|sync|audit> [args]');
process.exit(2);
}
})().catch(e => { console.error('ERR:', e.message); process.exit(1); });