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