← back to Ventura Claw

scripts/populate-credentials.js

190 lines

#!/usr/bin/env node
// populate-credentials.js
// One-stop credential populator for ventura-claw connectors. Reads from your
// canonical ~/Projects/secrets-manager/.env file (NEVER prints values), maps
// each env var to the matching connector ID, writes credentials.json locally,
// and optionally rsync+restarts on Kamatera.
//
//   node scripts/populate-credentials.js              # dry-run: shows what would be written
//   node scripts/populate-credentials.js --write      # writes locally
//   node scripts/populate-credentials.js --write --deploy   # writes + rsync + pm2 restart on prod
//
// Add new mappings as you onboard tokens via /secrets.

const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');

const SECRETS_ENV = path.join(process.env.HOME, 'Projects/secrets-manager/.env');
const CONNECTORS_JSON = path.join(__dirname, '..', 'server', 'connectors.json');
const CREDENTIALS_JSON = path.join(__dirname, '..', 'server', 'data', 'credentials.json');
// Server reads credentials[userId][connectorId]. Steve's admin user is id 1.
const TARGET_USER_ID = '1';

// ── Map env-var keys → connector credentials. Add entries as you acquire tokens.
// Each entry says: "if these env vars are present, the connector goes live."
// payload is what gets written into credentials[connectorId] — usually an api_key
// shape but sometimes oauth tokens, smtp, basic-auth, etc.
const MAPPINGS = {
  stripe: {
    requires: ['STRIPE_SECRET_KEY'],
    build: e => ({ api_key: e.STRIPE_SECRET_KEY, publishable_key: e.STRIPE_PUBLISHABLE_KEY || null }),
  },
  cloudflare: {
    requires: ['CLOUDFLARE_API_TOKEN'],
    build: e => ({ api_key: e.CLOUDFLARE_API_TOKEN, account_id: e.CLOUDFLARE_ACCOUNT_ID || null }),
  },
  godaddy: {
    requires: ['GODADDY_API_KEY', 'GODADDY_API_SECRET'],
    build: e => ({ api_key: e.GODADDY_API_KEY, api_secret: e.GODADDY_API_SECRET }),
  },
  browserbase: {
    requires: ['BROWSERBASE_API_KEY'],
    build: e => ({ api_key: e.BROWSERBASE_API_KEY, project_id: e.BROWSERBASE_PROJECT_ID || null }),
  },
  // Add more as you acquire tokens. Examples for when you onboard:
  // shopify:    { requires: ['SHOPIFY_ACCESS_TOKEN', 'SHOPIFY_STORE'], build: e => ({ access_token: e.SHOPIFY_ACCESS_TOKEN, store: e.SHOPIFY_STORE }) },
  // mailchimp:  { requires: ['MAILCHIMP_API_KEY'],          build: e => ({ api_key: e.MAILCHIMP_API_KEY }) },
  // slack:      { requires: ['SLACK_BOT_TOKEN'],            build: e => ({ bot_token: e.SLACK_BOT_TOKEN }) },
  // twilio:     { requires: ['TWILIO_ACCOUNT_SID', 'TWILIO_AUTH_TOKEN'], build: e => ({ account_sid: e.TWILIO_ACCOUNT_SID, auth_token: e.TWILIO_AUTH_TOKEN }) },
  // klaviyo:    { requires: ['KLAVIYO_API_KEY'],            build: e => ({ api_key: e.KLAVIYO_API_KEY }) },
  // intercom:   { requires: ['INTERCOM_ACCESS_TOKEN'],      build: e => ({ access_token: e.INTERCOM_ACCESS_TOKEN }) },
  // notion:     { requires: ['NOTION_API_KEY'],             build: e => ({ api_key: e.NOTION_API_KEY }) },
  // airtable:   { requires: ['AIRTABLE_PAT'],               build: e => ({ api_key: e.AIRTABLE_PAT }) },
  // discord:    { requires: ['DISCORD_BOT_TOKEN'],          build: e => ({ bot_token: e.DISCORD_BOT_TOKEN }) },
  // hubspot:    { requires: ['HUBSPOT_ACCESS_TOKEN'],       build: e => ({ access_token: e.HUBSPOT_ACCESS_TOKEN }) },
};

function parseEnv(filePath) {
  if (!fs.existsSync(filePath)) {
    console.error(`✗ secrets-manager .env not found at ${filePath}`);
    process.exit(1);
  }
  const text = fs.readFileSync(filePath, 'utf8');
  const env = {};
  for (const line of text.split('\n')) {
    const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
    if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '');
  }
  return env;
}

function main() {
  const args = process.argv.slice(2);
  const WRITE = args.includes('--write');
  const DEPLOY = args.includes('--deploy');

  const env = parseEnv(SECRETS_ENV);
  const connectors = JSON.parse(fs.readFileSync(CONNECTORS_JSON, 'utf8'));
  const existing = fs.existsSync(CREDENTIALS_JSON) ? JSON.parse(fs.readFileSync(CREDENTIALS_JSON, 'utf8')) : {};

  const matched = [], waiting = [], partial = [];
  for (const c of connectors) {
    const map = MAPPINGS[c.id];
    if (!map) { waiting.push(c.id); continue; }
    const missing = map.requires.filter(k => !env[k]);
    if (missing.length === 0) {
      matched.push(c.id);
    } else {
      partial.push({ id: c.id, missing });
    }
  }

  console.log(`\n=== VenturaClaw credential populator ===`);
  console.log(`  ${matched.length} connector(s) ready to populate from your secrets-manager`);
  console.log(`  ${partial.length} connector(s) partially mapped (missing some env vars)`);
  console.log(`  ${waiting.length} connector(s) waiting on token onboarding\n`);

  if (matched.length > 0) {
    console.log(`✓ Will populate:`);
    matched.forEach(id => console.log(`    ${id}`));
  }
  if (partial.length > 0) {
    console.log(`\n⚠ Mapped but missing env vars (run /secrets to add):`);
    partial.forEach(p => console.log(`    ${p.id} — needs ${p.missing.join(', ')}`));
  }
  if (waiting.length > 0 && process.env.SHOW_WAITING) {
    console.log(`\n… ${waiting.length} connectors not yet mapped: ${waiting.slice(0, 20).join(', ')}${waiting.length > 20 ? '…' : ''}`);
    console.log(`   (Edit MAPPINGS in this script as you onboard tokens.)`);
  }

  if (!WRITE) {
    console.log(`\n→ Dry-run only. Add --write to commit changes locally.`);
    console.log(`→ Add --deploy to also rsync + pm2-restart on Kamatera production.\n`);
    return;
  }

  // Build the merged credentials.json — server reads credentials[userId][connectorId].
  // Strip stale top-level connector keys from earlier (broken-shape) populator runs.
  const next = {};
  for (const [k, v] of Object.entries(existing)) {
    if (typeof v === 'object' && v !== null && !v._populated_at && !v._source) next[k] = v;
  }
  if (!next[TARGET_USER_ID]) next[TARGET_USER_ID] = {};
  for (const id of matched) {
    // CRITICAL (claude-codex 8-way 2026-05-05 finding #3): spread existing entry FIRST
    // so OAuth-obtained access_token/refresh_token survive a re-import from .env. The
    // env-derived fields override only the API-key-shaped keys, never OAuth tokens.
    const existingEntry = (next[TARGET_USER_ID] && next[TARGET_USER_ID][id]) || {};
    next[TARGET_USER_ID][id] = {
      ...existingEntry,
      ...MAPPINGS[id].build(env),
      _populated_at: new Date().toISOString(),
      _source: 'secrets-manager',
    };
  }

  fs.mkdirSync(path.dirname(CREDENTIALS_JSON), { recursive: true });
  const tmp = CREDENTIALS_JSON + '.tmp.' + process.pid;
  fs.writeFileSync(tmp, JSON.stringify(next, null, 2));
  fs.chmodSync(tmp, 0o600);
  fs.renameSync(tmp, CREDENTIALS_JSON);
  console.log(`\n✓ Wrote ${Object.keys(next[TARGET_USER_ID] || {}).length} connectors for user ${TARGET_USER_ID} to ${CREDENTIALS_JSON} (chmod 600)`);

  if (DEPLOY) {
    console.log(`\n→ Pushing to Kamatera (safe merge — preserves other users' OAuth tokens)...`);
    try {
      // Push our local creds to a /tmp staging path on prod
      execSync(`rsync -av ${CREDENTIALS_JSON} root@45.61.58.125:/tmp/cc-incoming-creds.json`, { stdio: 'inherit' });
      // Server-side merge: read prod, merge user-by-user, atomic-rename, chmod 600, backup the old file out-of-tree
      const mergeScript = `
const fs = require("fs");
const path = require("path");
const PROD = "/root/public-projects/ventura-claw/data/credentials.json";
const INCOMING = "/tmp/cc-incoming-creds.json";
const BACKUP_DIR = "/var/backups/ventura-claw";
fs.mkdirSync(BACKUP_DIR, { recursive: true });
const prod = fs.existsSync(PROD) ? JSON.parse(fs.readFileSync(PROD, "utf8")) : {};
const incoming = JSON.parse(fs.readFileSync(INCOMING, "utf8"));
// Backup current prod creds (chmod 600, retention 14d via cron)
const bak = path.join(BACKUP_DIR, "credentials." + Date.now() + ".json");
fs.writeFileSync(bak, JSON.stringify(prod, null, 2));
fs.chmodSync(bak, 0o600);
// Merge: incoming user overrides at connector-level; preserves all other users
const merged = { ...prod };
for (const [uid, conns] of Object.entries(incoming)) {
  merged[uid] = { ...(merged[uid] || {}), ...conns };
}
const tmp = PROD + ".tmp." + process.pid;
fs.writeFileSync(tmp, JSON.stringify(merged, null, 2));
fs.chmodSync(tmp, 0o600);
fs.renameSync(tmp, PROD);
fs.unlinkSync(INCOMING);
const stats = Object.entries(merged).map(([u,c])=>u+":"+Object.keys(c).length).join(", ");
console.log("merged users -> " + stats + "  (backup: " + bak + ")");
`.replace(/"/g, '\\"');
      execSync(`ssh root@45.61.58.125 "node -e \\"${mergeScript}\\""`, { stdio: 'inherit' });
      // Restart + post-deploy health check (200 or 401 = healthy; 502 = process didn't bind)
      execSync(`ssh root@45.61.58.125 "pm2 restart ventura-claw --update-env >/dev/null && sleep 8 && S=\\$(curl -o /dev/null -s -w '%{http_code}' http://127.0.0.1:9788/api/connectors); if [ \\"\\$S\\" != \\"200\\" ] && [ \\"\\$S\\" != \\"401\\" ]; then echo \\"DEPLOY HEALTH FAIL: HTTP \\$S\\" >&2; exit 1; fi; echo Health: \\$S"`, { stdio: 'inherit' });
      console.log(`\n✓ Live on https://venturaclaw.com`);
    } catch (e) {
      console.error(`\n✗ Deploy failed: ${e.message}`);
      console.error(`  Backup is at /var/backups/ventura-claw/credentials.<ts>.json on Kamatera.`);
    }
  } else {
    console.log(`→ Local only. Add --deploy to merge-deploy + health-check on Kamatera.`);
  }
}

main();