← back to Ventura Claw
scripts/preload-fleet.js
216 lines
#!/usr/bin/env node
// preload-fleet.js
// Seeds users.json + credentials.json so the platform is "67 connectors pre-wired,
// ready on day one" for five canonical logins: admin, info@dw, steve@dw, info@vcl,
// steve@vcl. Pulls real values from ~/Projects/secrets-manager/.env, maps via the
// same ENV_KEY_TO_CONNECTOR table the live server uses for /api/me/connections/import,
// and writes plaintext credentials.json. The server's boot-migration block re-encrypts
// it at-rest on next start (AES-256-GCM under CC_SECRET).
//
// node scripts/preload-fleet.js # dry-run (default)
// node scripts/preload-fleet.js --write # commit changes
//
// Existing encrypted credentials.json is moved to credentials.json.bak.<ts> first.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const bcrypt = require(require.resolve('bcrypt', { paths: [path.join(__dirname, '..', 'server')] }));
const ROOT = path.join(__dirname, '..', 'server');
const DATA = path.join(ROOT, 'data');
const SECRETS_ENV = path.join(process.env.HOME, 'Projects/secrets-manager/.env');
const USERS_JSON = path.join(DATA, 'users.json');
const CREDS_JSON = path.join(DATA, 'credentials.json');
// Same map the live server uses (server.js line 1931). Duplicated here so this
// script doesn't have to import server.js (which would boot it). Keep in sync.
const ENV_KEY_TO_CONNECTOR = {
STRIPE_SECRET_KEY: { connector: 'stripe', field: 'STRIPE_SECRET_KEY' },
STRIPE_PUBLISHABLE_KEY: { connector: 'stripe', field: 'STRIPE_PUBLISHABLE_KEY' },
STRIPE_API_KEY: { connector: 'stripe', field: 'STRIPE_SECRET_KEY' },
CLOUDFLARE_API_TOKEN: { connector: 'cloudflare', field: 'CF_API_TOKEN' },
CLOUDFLARE_ACCOUNT_ID: { connector: 'cloudflare', field: 'CF_ACCOUNT_ID' },
CLOUDFLARE_TOKEN: { connector: 'cloudflare', field: 'CF_API_TOKEN' },
GODADDY_API_KEY: { connector: 'godaddy', field: 'GODADDY_API_KEY' },
GODADDY_API_SECRET: { connector: 'godaddy', field: 'GODADDY_API_SECRET' },
BROWSERBASE_API_KEY: { connector: 'browserbase', field: 'BROWSERBASE_API_KEY' },
BROWSERBASE_PROJECT_ID: { connector: 'browserbase', field: 'BROWSERBASE_PROJECT_ID' },
SHOPIFY_ACCESS_TOKEN: { connector: 'shopify', field: 'SHOPIFY_ADMIN_TOKEN' },
SHOPIFY_ADMIN_TOKEN: { connector: 'shopify', field: 'SHOPIFY_ADMIN_TOKEN' },
SHOPIFY_STORE: { connector: 'shopify', field: 'SHOPIFY_STORE' },
SHOPIFY_TOKEN: { connector: 'shopify', field: 'SHOPIFY_ADMIN_TOKEN' },
ETSY_API_KEY: { connector: 'etsy', field: 'ETSY_KEYSTRING' },
ETSY_KEYSTRING: { connector: 'etsy', field: 'ETSY_KEYSTRING' },
ETSY_ACCESS_TOKEN: { connector: 'etsy', field: 'ETSY_ACCESS_TOKEN' },
PAYPAL_CLIENT_ID: { connector: 'paypal', field: 'PAYPAL_CLIENT_ID' },
PAYPAL_SECRET: { connector: 'paypal', field: 'PAYPAL_SECRET' },
SQUARE_ACCESS_TOKEN: { connector: 'square', field: 'SQUARE_ACCESS_TOKEN' },
TWILIO_ACCOUNT_SID: { connector: 'twilio', field: 'TWILIO_ACCOUNT_SID' },
TWILIO_AUTH_TOKEN: { connector: 'twilio', field: 'TWILIO_AUTH_TOKEN' },
TWILIO_PHONE_NUMBER: { connector: 'twilio', field: 'TWILIO_FROM_NUMBER' },
TWILIO_FROM_NUMBER: { connector: 'twilio', field: 'TWILIO_FROM_NUMBER' },
SLACK_BOT_TOKEN: { connector: 'slack', field: 'SLACK_BOT_TOKEN' },
SLACK_TOKEN: { connector: 'slack', field: 'SLACK_BOT_TOKEN' },
DISCORD_BOT_TOKEN: { connector: 'discord', field: 'DISCORD_BOT_TOKEN' },
MAILCHIMP_API_KEY: { connector: 'mailchimp', field: 'MAILCHIMP_API_KEY' },
KLAVIYO_API_KEY: { connector: 'klaviyo', field: 'KLAVIYO_API_KEY' },
HUBSPOT_ACCESS_TOKEN: { connector: 'hubspot', field: 'HUBSPOT_TOKEN' },
HUBSPOT_TOKEN: { connector: 'hubspot', field: 'HUBSPOT_TOKEN' },
SALESFORCE_ACCESS_TOKEN: { connector: 'salesforce', field: 'SALESFORCE_ACCESS_TOKEN' },
ZENDESK_API_TOKEN: { connector: 'zendesk', field: 'ZENDESK_API_TOKEN' },
ZENDESK_WIDGET_KEY: { connector: 'zendesk', field: 'ZENDESK_WIDGET_KEY' },
INTERCOM_ACCESS_TOKEN: { connector: 'intercom', field: 'INTERCOM_ACCESS_TOKEN' },
NOTION_API_KEY: { connector: 'notion', field: 'NOTION_TOKEN' },
NOTION_TOKEN: { connector: 'notion', field: 'NOTION_TOKEN' },
AIRTABLE_PAT: { connector: 'airtable', field: 'AIRTABLE_PAT' },
AIRTABLE_TOKEN: { connector: 'airtable', field: 'AIRTABLE_PAT' },
AIRTABLE_BASE_ID: { connector: 'airtable', field: 'AIRTABLE_BASE_ID' },
ASANA_PAT: { connector: 'asana', field: 'ASANA_PAT' },
CLICKUP_API_TOKEN: { connector: 'clickup', field: 'CLICKUP_API_TOKEN' },
TRELLO_API_KEY: { connector: 'trello', field: 'TRELLO_API_KEY' },
TRELLO_TOKEN: { connector: 'trello', field: 'TRELLO_TOKEN' },
FIGMA_TOKEN: { connector: 'figma', field: 'FIGMA_TOKEN' },
FIGMA_API_TOKEN: { connector: 'figma', field: 'FIGMA_TOKEN' },
CANVA_API_KEY: { connector: 'canva', field: 'CANVA_TOKEN' },
CANVA_TOKEN: { connector: 'canva', field: 'CANVA_TOKEN' },
ADOBE_CLIENT_ID: { connector: 'adobe', field: 'ADOBE_CLIENT_ID' },
AWS_ACCESS_KEY_ID: { connector: 'aws', field: 'AWS_ACCESS_KEY_ID' },
AWS_SECRET_ACCESS_KEY: { connector: 'aws', field: 'AWS_SECRET_ACCESS_KEY' },
VERCEL_TOKEN: { connector: 'vercel', field: 'VERCEL_TOKEN' },
SUPABASE_KEY: { connector: 'supabase', field: 'SUPABASE_KEY' },
ELEVENLABS_API_KEY: { connector: 'elevenlabs', field: 'ELEVENLABS_API_KEY' },
PURELYMAIL_API_TOKEN: { connector: 'purelymail', field: 'PURELYMAIL_API_TOKEN' },
LINKEDIN_CLIENT_ID: { connector: 'linkedin', field: 'LINKEDIN_CLIENT_ID' },
LINKEDIN_CLIENT_SECRET: { connector: 'linkedin', field: 'LINKEDIN_CLIENT_SECRET' },
};
// 5 canonical logins. Per Steve's "preload as admin, info@dw, steve@dw" + the
// "Both" choice on the @dw vs @vcl clarifier — seed both pairs so any natural
// login attempt lands.
function userSeeds(env) {
const dwPass = env.X_DEFAULT_PASSWORD || 'dw-demo-please-rotate';
return [
// admin keeps the existing stable id=1; password from env or 'admin' fallback
{ id: 1, email: 'admin@venturaclaw.com', role: 'admin', name: 'Admin', password: env.VENTURACLAW_ADMIN_PASSWORD || 'admin' },
{ id: 1778002313860, email: 'steve@venturaclaw.com', role: 'admin', name: 'Steve Abrams', password: env.VENTURACLAW_ADMIN_PASSWORD || 'admin' },
{ id: 1779000000001, email: 'info@venturaclaw.com', role: 'admin', name: 'Info — VenturaClaw', password: env.INFO_VENTURACLAW_COM_PASSWORD || env.INFO_VENTURACLAW_PASSWORD || dwPass },
{ id: 1779000000002, email: 'info@designerwallcoverings.com', role: 'admin', name: 'Info — Designer Wallcoverings', password: dwPass },
{ id: 1779000000003, email: 'steve@designerwallcoverings.com', role: 'admin', name: 'Steve Abrams (DW)', password: dwPass },
];
}
function parseEnv(file) {
if (!fs.existsSync(file)) { console.error('✗ secrets-manager .env missing at', file); process.exit(1); }
const env = {};
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const m = line.match(/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
if (!m) continue;
let val = m[2];
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
if (val) env[m[1]] = val;
}
return env;
}
function buildConnectorCreds(env) {
// env-keys → { connector: { field: value, ... } }
const out = {};
for (const [k, v] of Object.entries(env)) {
const map = ENV_KEY_TO_CONNECTOR[k];
if (!map) continue;
if (!out[map.connector]) out[map.connector] = {};
out[map.connector][map.field] = v;
}
const now = new Date().toISOString();
for (const cid of Object.keys(out)) {
out[cid]._imported_at = now;
out[cid]._import_source = 'preload-fleet';
}
return out;
}
function atomicWrite(file, data, mode = 0o600) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = file + '.tmp.' + process.pid;
const fd = fs.openSync(tmp, 'w', mode);
try {
fs.writeSync(fd, JSON.stringify(data, null, 2));
fs.fsyncSync(fd);
} finally { fs.closeSync(fd); }
fs.renameSync(tmp, file);
}
function main() {
const WRITE = process.argv.includes('--write');
const env = parseEnv(SECRETS_ENV);
const seeds = userSeeds(env);
const connectorCreds = buildConnectorCreds(env);
const connectorIds = Object.keys(connectorCreds).sort();
const fieldCount = connectorIds.reduce((n, cid) => n + Object.keys(connectorCreds[cid]).filter(k => !k.startsWith('_')).length, 0);
console.log('\n=== Ventura-Claw fleet preload ===');
console.log(` env file: ${SECRETS_ENV} (${Object.keys(env).length} keys)`);
console.log(` users to seed: ${seeds.length}`);
for (const u of seeds) console.log(` ${String(u.id).padEnd(15)} ${u.email.padEnd(42)} ${u.role}`);
console.log(` connectors live: ${connectorIds.length} (of 67 catalog) — ${fieldCount} fields`);
console.log(` ${connectorIds.join(', ')}`);
const unmapped = Object.keys(env).filter(k => !ENV_KEY_TO_CONNECTOR[k]);
console.log(` env keys ignored: ${unmapped.length} (no connector mapping — fleet/site passwords)`);
if (!WRITE) {
console.log('\n→ Dry-run only. Re-run with --write to commit.\n');
return;
}
// 1. users.json — bcrypt-hash, preserve any non-seed users already on disk
const existingUsers = fs.existsSync(USERS_JSON) ? JSON.parse(fs.readFileSync(USERS_JSON, 'utf8')) : [];
const seedEmails = new Set(seeds.map(u => u.email.toLowerCase()));
const seedIds = new Set(seeds.map(u => String(u.id)));
const kept = existingUsers.filter(u => !seedEmails.has(String(u.email || '').toLowerCase()) && !seedIds.has(String(u.id)));
const hashed = seeds.map(u => ({
id: u.id,
email: u.email,
role: u.role,
name: u.name,
password: bcrypt.hashSync(u.password, 10),
_seeded_at: new Date().toISOString(),
}));
const nextUsers = [...hashed, ...kept];
atomicWrite(USERS_JSON, nextUsers, 0o644);
console.log(`\n✓ users.json — wrote ${hashed.length} seed users (+ ${kept.length} preserved)`);
// 2. credentials.json — back up encrypted file, write fresh plaintext for all 5 users
if (fs.existsSync(CREDS_JSON)) {
const bak = CREDS_JSON + '.bak.' + Date.now();
fs.copyFileSync(CREDS_JSON, bak);
fs.chmodSync(bak, 0o600);
console.log(`✓ Backed up existing credentials.json → ${path.basename(bak)}`);
}
const creds = {};
for (const u of seeds) {
creds[String(u.id)] = JSON.parse(JSON.stringify(connectorCreds)); // deep clone per user
}
atomicWrite(CREDS_JSON, creds, 0o600);
console.log(`✓ credentials.json — ${Object.keys(creds).length} users × ${connectorIds.length} connectors written plaintext (server boot will re-encrypt under CC_SECRET)`);
// 3. Pretty preview — show the mask format Steve asked for, no real values
console.log('\n--- masked preview (this is what the UI will show) ---');
const sampleUser = String(seeds[0].id);
for (const cid of connectorIds.slice(0, 4)) {
const fields = creds[sampleUser][cid];
console.log(` ${cid}:`);
for (const [k, v] of Object.entries(fields)) {
if (k.startsWith('_')) continue;
const s = String(v);
const mask = s.length > 8 ? `${s.slice(0,4)}${'*'.repeat(8)}${s.slice(-4)}` : '••••••••';
console.log(` ${k.padEnd(28)} ${mask}`);
}
}
console.log('\n→ Restart ventura-claw (pm2 restart ventura-claw) to load the new seed.');
console.log('→ On next boot, server auto-encrypts credentials.json at-rest under CC_SECRET.\n');
}
main();