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