← 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); });