#!/usr/bin/env node // Podium ops CLI. // // podium deploy [--local] [--no-push] env file → .env, sm deploy, then push // podium push [--local] [--watch] sync lexicons + Lua scripts to HappyView // podium status [--local] list what the instance currently has // // Like sm, commands target production by default; pass --local for the // local stack. Backend code lives in backend/ and podium.config.json is // the manifest that maps it onto the HappyView admin API. import { readFile, copyFile } from 'node:fs/promises'; import { watch } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import process from 'node:process'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const args = process.argv.slice(2); const command = args.find((a) => !a.startsWith('-')); const flags = new Set(args.filter((a) => a.startsWith('-'))); const env = flags.has('--local') ? 'local' : 'prod'; const log = (msg) => console.log(msg); const fail = (msg) => { console.error(`podium: ${msg}`); process.exit(1); }; async function loadEnvFile() { const file = resolve(root, `.env.${env}`); let text; try { text = await readFile(file, 'utf8'); } catch { fail(`missing .env.${env} — copy .env.example and fill it in`); } const vars = {}; for (const line of text.split('\n')) { const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/); if (m) vars[m[1]] = m[2].replace(/^(["'])(.*)\1$/, '$2'); } return { file, vars }; } async function loadConfig() { const text = await readFile(resolve(root, 'podium.config.json'), 'utf8'); return JSON.parse(text); } function makeApi({ vars }) { const base = vars.PODIUM_HV_URL?.replace(/\/$/, ''); if (!base) fail(`PODIUM_HV_URL is not set in .env.${env}`); if (!vars.PODIUM_HV_KEY) { fail( `PODIUM_HV_KEY is not set in .env.${env}\n` + ` Log into the HappyView dashboard (${base}/), create an API key under\n` + ` Settings > API Keys with lexicons:create/read + scripts:manage/read,\n` + ` and put it in .env.${env}.` ); } return async (method, path, body) => { const res = await fetch(`${base}${path}`, { method, headers: { authorization: `Bearer ${vars.PODIUM_HV_KEY}`, ...(body ? { 'content-type': 'application/json' } : {}), }, body: body ? JSON.stringify(body) : undefined, }); const text = await res.text(); let data; try { data = text ? JSON.parse(text) : null; } catch { data = text; } return { status: res.status, ok: res.ok, data }; }; } async function waitForInstance(api) { for (let i = 0; i < 15; i++) { try { const res = await api('GET', '/admin/lexicons'); if (res.status === 401 || res.status === 403) { fail('the instance rejected PODIUM_HV_KEY (401/403) — check the key and its permissions'); } if (res.ok) return; } catch { // not up yet } if (i === 0) log('… waiting for HappyView to answer'); await new Promise((r) => setTimeout(r, 2000)); } fail('HappyView did not answer on /admin/lexicons — is the stack up?'); } async function push(envFile, config) { const api = makeApi(envFile); await waitForInstance(api); // HappyView 421s any Host it doesn't know (its primary domain comes from // PUBLIC_URL); make sure the public-facing domains are registered. const wanted = (envFile.vars.PODIUM_DOMAINS ?? '') .split(',') .map((d) => d.trim()) .filter(Boolean); if (wanted.length) { const existing = await api('GET', '/admin/domains'); const have = new Set( (Array.isArray(existing.data) ? existing.data : []).map((d) => d.url?.replace(/\/$/, '')) ); for (const url of wanted) { if (have.has(url.replace(/\/$/, ''))) continue; const res = await api('POST', '/admin/domains', { url }); if (res.ok) log(`✓ domain ${url}`); else log(`✗ domain ${url}: ${res.status} ${JSON.stringify(res.data)}`); } } // Re-POSTing an existing network lexicon re-fetches it and re-kicks // jetstream/backfill machinery, so only create the missing ones. const netExisting = await api('GET', '/admin/network-lexicons'); const netHave = new Set( (Array.isArray(netExisting.data) ? netExisting.data : []).map((l) => l.nsid) ); for (const { nsid, targetCollection } of config.networkLexicons ?? []) { if (netHave.has(nsid)) { log(`= network lexicon ${nsid}`); continue; } const res = await api('POST', '/admin/network-lexicons', { nsid, target_collection: targetCollection ?? null, }); if (res.ok) log(`✓ network lexicon ${nsid}`); else log(`✗ network lexicon ${nsid}: ${res.status} ${JSON.stringify(res.data)}`); // A fresh record collection has no history yet — kick off a backfill. if (res.ok) await backfill(api, nsid); } for (const { file, targetCollection, backfill } of config.lexicons ?? []) { const lexicon = JSON.parse(await readFile(resolve(root, file), 'utf8')); const res = await api('POST', '/admin/lexicons', { lexicon_json: lexicon, backfill: backfill ?? false, target_collection: targetCollection ?? null, }); if (res.ok) log(`✓ lexicon ${lexicon.id}`); else log(`✗ lexicon ${lexicon.id}: ${res.status} ${JSON.stringify(res.data)}`); } for (const { trigger, file, description } of config.scripts ?? []) { const body = await readFile(resolve(root, file), 'utf8'); const res = await api('POST', '/admin/scripts', { id: trigger, script_type: 'lua', body, description, }); if (res.ok) log(`✓ script ${trigger}`); else log(`✗ script ${trigger}: ${res.status} ${JSON.stringify(res.data)}`); } } async function backfill(api, collection) { const res = await api('POST', '/admin/backfill', collection ? { collection } : {}); if (res.ok) log(`⟳ backfill started${collection ? ` for ${collection}` : ''} (job ${res.data?.id ?? '?'})`); else log(`✗ backfill${collection ? ` for ${collection}` : ''}: ${res.status} ${JSON.stringify(res.data)}`); } async function cmdBackfill() { const envFile = await loadEnvFile(); const api = makeApi(envFile); await waitForInstance(api); const collection = args.filter((a) => !a.startsWith('-'))[1]; if (collection) return backfill(api, collection); const config = await loadConfig(); for (const { nsid } of config.networkLexicons ?? []) await backfill(api, nsid); } async function cmdPush() { const envFile = await loadEnvFile(); const config = await loadConfig(); await push(envFile, config); if (!flags.has('--watch')) return; log(`\nwatching backend/ and podium.config.json (${env}) — ^C to stop`); let timer = null; const kick = (what) => { clearTimeout(timer); timer = setTimeout(async () => { log(`\n↻ ${what} changed`); try { await push(await loadEnvFile(), await loadConfig()); } catch (err) { log(`✗ push failed: ${err.message}`); } }, 250); }; watch(resolve(root, 'backend'), { recursive: true }, (_e, f) => kick(f ?? 'backend')); watch(resolve(root, 'podium.config.json'), () => kick('podium.config.json')); await new Promise(() => {}); } async function cmdDeploy() { const envFile = await loadEnvFile(); await copyFile(envFile.file, resolve(root, '.env')); log(`.env ← .env.${env}`); const smArgs = ['deploy']; if (env === 'local') smArgs.push('--local'); const res = spawnSync('sm', smArgs, { cwd: root, stdio: 'inherit' }); if (res.error?.code === 'ENOENT') fail('sm not found on PATH (npm link it from caddy-front)'); if (res.status !== 0) fail(`sm deploy exited with ${res.status}`); // The prod .env has been rsynced to the server; restore the local flavor // so docker compose keeps working on this machine. if (env === 'prod') { try { await copyFile(resolve(root, '.env.local'), resolve(root, '.env')); log('.env ← .env.local (restored for local compose)'); } catch { // no .env.local — leave the prod copy in place } } if (flags.has('--no-push')) return; if (!envFile.vars.PODIUM_HV_KEY) { log('\nNo PODIUM_HV_KEY yet, skipping backend push. First-boot bootstrap:'); log(` 1. open ${envFile.vars.PODIUM_HV_URL ?? 'the HappyView dashboard'}/`); log(' 2. log in with your atproto handle (first login becomes super user)'); log(' 3. Settings > API Keys → create a key (lexicons:create/read, scripts:manage/read)'); log(` 4. put it in .env.${env} as PODIUM_HV_KEY and run: podium push${env === 'local' ? ' --local' : ''}`); return; } await push(envFile, await loadConfig()); } // First-boot helper: seeds a super user + API key straight into SQLite, // bypassing dashboard OAuth (which can't run against .bast domains, and on a // fresh prod instance 421s until `podium push` has registered the public // domain — a chicken-and-egg since push needs the key). Only safe with // HappyView stopped (writing to the live WAL db over a Docker bind mount // corrupts it), so this stops and restarts the container — locally via // compose, on prod over SSH to $SUPRAMUNDANE. async function cmdBootstrap() { const { execSync } = await import('node:child_process'); const crypto = await import('node:crypto'); const envFile = await loadEnvFile(); if (envFile.vars.PODIUM_HV_KEY) fail(`.env.${env} already has PODIUM_HV_KEY — nothing to do`); const handleArg = args.filter((a) => !a.startsWith('-'))[1]; if (!handleArg) fail('usage: podium bootstrap [--local]'); let did = handleArg; if (!did.startsWith('did:')) { const res = await fetch( `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handleArg}` ); if (!res.ok) fail(`could not resolve handle ${handleArg}`); did = (await res.json()).did; log(`${handleArg} → ${did}`); } const rawKey = `hv_${crypto.randomBytes(16).toString('hex')}`; const hash = crypto.createHash('sha256').update(rawKey).digest('hex'); const sql = ` INSERT OR IGNORE INTO happyview_users (id, did, is_super, created_at) VALUES ('${crypto.randomUUID()}', '${did}', 1, datetime('now')); INSERT INTO happyview_api_keys (id, user_id, name, key_hash, key_prefix, permissions, created_at) SELECT '${crypto.randomUUID()}', id, 'podium bootstrap', '${hash}', '${rawKey.slice(0, 11)}', '[]', datetime('now') FROM happyview_users WHERE did = '${did}';`; const run = (cmd, input) => execSync(cmd, { cwd: root, input, stdio: [input ? 'pipe' : 'ignore', 'pipe', 'inherit'] }); if (env === 'local') { const dataRoot = envFile.vars.SM_DATA_ROOT; if (!dataRoot) fail('SM_DATA_ROOT is not set in .env.local'); run('docker compose stop podium-hv'); try { run(`sqlite3 ${JSON.stringify(`${dataRoot}/podium/happyview.db`)}`, sql); } finally { run('docker compose start podium-hv'); } } else { const host = process.env.SUPRAMUNDANE; if (!host) fail('SUPRAMUNDANE is not set (remote host for prod bootstrap)'); const target = process.env.SM_REMOTE_USER ? `${process.env.SM_REMOTE_USER}@${host}` : host; const ssh = (cmd, input) => execSync(`ssh ${target} ${JSON.stringify(cmd)}`, { input, stdio: [input ? 'pipe' : 'ignore', 'pipe', 'inherit'], }); // Ask docker where /data actually lives rather than guessing paths. const dataDir = ssh( `docker inspect podium-hv --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}'` ) .toString() .trim(); if (!dataDir) fail('could not find the /data mount of podium-hv on the server'); log(`server data dir: ${dataDir}`); ssh('docker stop podium-hv'); try { ssh( `docker run --rm -i -v ${dataDir}:/d alpine:3 sh -c 'apk add -q sqlite >/dev/null && sqlite3 /d/happyview.db'`, sql ); } finally { ssh('docker start podium-hv'); } } const envText = await readFile(envFile.file, 'utf8'); const updated = envText.replace(/^PODIUM_HV_KEY=.*$/m, `PODIUM_HV_KEY=${rawKey}`); const { writeFile } = await import('node:fs/promises'); await writeFile(envFile.file, updated.includes(rawKey) ? updated : `${envText}\nPODIUM_HV_KEY=${rawKey}\n`); log(`✓ super user ${did} seeded, API key written to .env.${env}`); log(` run: podium push${env === 'local' ? ' --local' : ''}`); } async function cmdStatus() { const envFile = await loadEnvFile(); const api = makeApi(envFile); const [lex, scripts] = await Promise.all([ api('GET', '/admin/lexicons'), api('GET', '/admin/scripts'), ]); if (!lex.ok || !scripts.ok) { fail(`instance answered ${lex.status}/${scripts.status} — check PODIUM_HV_URL and PODIUM_HV_KEY`); } const lexicons = Array.isArray(lex.data) ? lex.data : lex.data?.lexicons ?? []; const scr = Array.isArray(scripts.data) ? scripts.data : scripts.data?.scripts ?? []; log(`lexicons (${lexicons.length}):`); for (const l of lexicons) log(` ${l.id ?? l.nsid ?? JSON.stringify(l)}`); log(`scripts (${scr.length}):`); for (const s of scr) log(` ${s.id ?? JSON.stringify(s)}`); } async function main() { switch (command) { case 'deploy': return cmdDeploy(); case 'push': return cmdPush(); case 'backfill': return cmdBackfill(); case 'bootstrap': return cmdBootstrap(); case 'status': return cmdStatus(); default: log('usage: podium [--local] [--watch] [--no-push]'); process.exit(command ? 1 : 0); } } await main();