#!/usr/bin/env node // lchat-dev-up — spin up a local lchat server from the current checkout // (whatever branch/worktree you run it from), pre-seeded with an invite link // that signs you straight in. // // Usage: tools/lchat-dev-up.mjs [--port N] [--buddy-name NAME] [--group-name NAME] [--onboarding] [--clean] // // Persistent by default. The first run writes `.lchat-dev.json` (repo root) // recording the admin token, db path, port, base URL, and the seeded // account/thread; later runs reuse all of it, so: // • the same admin URL keeps working, // • the port is stable — your browser stays signed in across restarts // (localStorage is keyed by origin), no re-provisioning needed, // • the db (and every app/message in it) survives a restart. // The written config is also how tools/lchat-dev-bot.mjs discovers the server: // run it with no --base/--admin and it reads them from `.lchat-dev.json`. // // --clean deletes the config + its db files (db, -wal, -shm) and starts fresh. // // On a fresh start it builds the Go binary from this checkout, runs it against // `.lchat-dev.db` + a fresh admin token, then uses web/static/crypto.js (the // exact crypto the browser runs) to, without ever touching a browser: // 1. mint an admin-issued "add a device" invite for a brand-new "you" // account — join.js (see its `payload.priv` branch) recognises this as a // device link and signs the browser straight in as that member, with no // name entry and no second session, exactly like using a real add-device // QR code // With --onboarding: just create the invite with no buddies or chats // Without --onboarding (group mode, default): // 2. mint + claim a second member ("Dev Buddy") via the admin API, so // there's someone else in the chat // 3. create a group thread with you + Dev Buddy and post a welcome message // 4. point the invite at the group thread so it opens automatically on sign-in // The one thing no HTTP endpoint exposes — pointing that invite at the group // thread so it's the one that opens automatically on sign-in — is patched // directly into the sqlite file via the `sqlite3` CLI, best-effort: if that's // unavailable the invite still works, you just land on the thread list one // tap away from the group instead of inside it. // // On a reused start it skips all seeding (the accounts/thread already exist) and // re-mints a fresh sign-in link for the stored "you" so you always have a // working link to hand. // // The binary is always rebuilt from the current checkout so code changes take // effect on restart. Ctrl-C stops the server but KEEPS the db + config (use // --clean to wipe them). // // It also runs the webhook-ingest sidecar (`lchat hooks run`, docs/webhooks.md) // on its own sticky local port — same binary, real provision/encrypt/post path, // no router — so the Webhooks app works end-to-end locally: the server is told // the port via LCHAT_HOOKS_BASE, making the app display URLs you can curl. The // sidecar's service identity persists in .lchat-dev-hooks.json (cleared on any // fresh instance, like the dev bot's). --no-hooks skips all of it. Dev-you is // seeded as an ADMIN member (grants_admin on the invite + a best-effort role // patch for pre-existing instances) so the in-app admin surfaces — Admin, // Webhooks — work without the console. import { spawn, execFileSync } from "node:child_process"; import { mkdtempSync, rmSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { randomBytes } from "node:crypto"; import net from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { randomSecret, deriveInvite, generateUserKeypair, generateKey, encrypt, wrapThreadKey, } from "../web/static/crypto.js"; import { buildInviteFragment } from "../web/static/invite.js"; import { CONFIG_PATH, DEFAULT_DB_PATH, BOT_SESSION_PATH, HOOKS_SESSION_PATH, dbFiles, readConfig, writeConfig, clearConfig, clearBotSession, clearHooksSession, } from "./dev-config.mjs"; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const SAFE_ID = /^[A-Za-z0-9_-]+$/; // base64url alphabet used for every id we interpolate into SQL below function arg(name, def) { const i = process.argv.indexOf(`--${name}`); return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : def; } if (process.argv.includes("-h") || process.argv.includes("--help")) { process.stdout.write( "lchat-dev-up — build + run this checkout, pre-seeded with a sign-in link\n" + " tools/lchat-dev-up.mjs [--port N] [--buddy-name NAME] [--group-name NAME] [--onboarding] [--clean]\n" + "\n" + " Persistent by default: reuses the admin token / db / port stored in\n" + " .lchat-dev.json (repo root) so restarts keep your data and browser session.\n" + " tools/lchat-dev-bot.mjs reads that file to find --base/--admin automatically.\n" + "\n" + " --clean delete .lchat-dev.json + its db files and start fresh\n" + " --onboarding fresh instance with no buddies or chats (default: Dev Buddy + group chat)\n" + " --port N override the stored/auto port (changes origin — may drop your browser session)\n" + " --buddy-name / --group-name labels used only on a fresh seed\n" + " --no-hooks skip the webhook-ingest sidecar (default: runs it, so the\n" + " Webhooks app works end-to-end — curl its printed URL locally)\n", ); process.exit(0); } const BUDDY_NAME = arg("buddy-name", "Dev Buddy"); const GROUP_NAME = arg("group-name", "Dev chat"); const explicitPort = arg("port"); const isOnboarding = process.argv.includes("--onboarding"); const isClean = process.argv.includes("--clean"); const wantHooks = !process.argv.includes("--no-hooks"); function log(msg) { process.stderr.write(`lchat-dev-up: ${msg}\n`); } async function postJSON(url, body, headers = { "Content-Type": "application/json" }) { const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) }); if (!res.ok) { const detail = await res.text().catch(() => ""); throw new Error(`POST ${url} -> HTTP ${res.status} ${detail}`.trim()); } return res.json(); } function freePort() { return new Promise((resolve, reject) => { const srv = net.createServer(); srv.on("error", reject); srv.listen(0, "127.0.0.1", () => { const { port } = srv.address(); srv.close(() => resolve(port)); }); }); } async function waitForServer(base, server) { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { if (server.exitCode !== null) throw new Error(`server exited early (code ${server.exitCode})`); try { if ((await fetch(`${base}/api/version`)).ok) return; } catch {} await new Promise((r) => setTimeout(r, 100)); } throw new Error("server didn't become ready in time"); } // Provisions an admin-granting setup invite for onboarding mode (fresh instance, // no buddies or chats). The first person to claim it becomes admin and lands in // the setup conversation with Eleven. async function seedOnboarding(base, adminToken) { const admin = { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` }; const T = randomSecret(); const derived = await deriveInvite(T); await postJSON( `${base}/api/admin/key-invites`, { id: derived.id, wrapped: "", claim_hash: derived.claimHash, grants_admin: true, }, admin, ); return { inviteId: derived.id, inviteURL: `${base}/join/join#${buildInviteFragment(T, "")}`, }; } // Provisions "Dev Buddy" (a real, fully-joined member, so it can post) and a // group thread containing them + a brand-new "you" account, then posts a // welcome message. Returns what the caller needs to build the invite link, patch // its thread_id, and persist "you" for re-issuing the link on later runs. async function seed(base, adminToken) { const admin = { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` }; const buddyT = randomSecret(); const buddyDerived = await deriveInvite(buddyT); const buddyCreate = await postJSON( `${base}/api/admin/key-invites`, { label: BUDDY_NAME, id: buddyDerived.id, wrapped: "", claim_hash: buddyDerived.claimHash }, admin, ); const buddyClaim = await postJSON(`${base}/api/key-invite/${encodeURIComponent(buddyDerived.id)}/claim`, { claim_secret: buddyDerived.claimSecret, }); const buddyKp = await generateUserKeypair(); await postJSON(`${base}/api/pubkey`, { token: buddyClaim.token, public_key: buddyKp.publicKey }); // "You": generate the keypair ourselves and ship it wrapped in the invite — // the same shape shell.js's addDevice() builds — so opening the link signs // the browser straight in as this already-provisioned member. We keep the // keypair in the config so later runs can re-issue a fresh add-device link // for the same account without re-seeding. grants_admin makes dev-you an // ADMIN member (belt: patchAdminRole braces it) — the in-app admin surfaces // (Admin, Webhooks) all work without the console token. const youKp = await generateUserKeypair(); const youT = randomSecret(); const youDerived = await deriveInvite(youT); const youWrapped = await encrypt(youDerived.wrapKey, { pub: youKp.publicKey, priv: youKp.privateKey }); const youCreate = await postJSON( `${base}/api/admin/key-invites`, { id: youDerived.id, wrapped: youWrapped, claim_hash: youDerived.claimHash, grants_admin: true }, admin, ); const group = await generateKey(); const members = [ { user_id: buddyCreate.user_id, wrapped: await wrapThreadKey(group.b64, buddyKp.publicKey) }, { user_id: youCreate.user_id, wrapped: await wrapThreadKey(group.b64, youKp.publicKey) }, ]; const nameBlob = await encrypt(group.key, { name: GROUP_NAME }); const thread = await postJSON(`${base}/api/threads`, { token: buddyClaim.token, kind: "group", name_blob: nameBlob, members, }); const welcome = await encrypt(group.key, { name: BUDDY_NAME, text: `👋 Welcome to ${GROUP_NAME} — this chat was seeded by lchat-dev-up.`, ts: Date.now(), uid: buddyCreate.user_id, }); await postJSON(`${base}/api/messages?token=${encodeURIComponent(buddyClaim.token)}`, { thread_id: thread.thread_id, blob: welcome, }); return { threadId: thread.thread_id, inviteId: youDerived.id, inviteURL: `${base}/join/join#${buildInviteFragment(youT, "You")}`, you: { userId: youCreate.user_id, publicKey: youKp.publicKey, privateKey: youKp.privateKey }, }; } // Re-mint a fresh add-device invite for an already-seeded "you" account, using // the keypair we stashed in the config on the fresh seed. Passing the existing // user_id makes the server re-issue for that member rather than create a new one // (see createKeyInvite). Returns the new invite id + URL. // // Deliberately NOT thread-patched: claimKeyInvite refuses a thread-bound // invite once its user already has a device (the anti-account-takeover guard // on user invites), and a reused instance's "you" always has one — a patched // reissue would make the printed link die with "This invite was just used." // The pre-open nicety only works on the very first, fresh-seed claim; a // reissued link signs you in and leaves the group one tap away. async function reissueYouInvite(base, adminToken, cfg) { const admin = { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` }; const youT = randomSecret(); const derived = await deriveInvite(youT); const wrapped = await encrypt(derived.wrapKey, { pub: cfg.you.publicKey, priv: cfg.you.privateKey }); await postJSON( `${base}/api/admin/key-invites`, { id: derived.id, wrapped, claim_hash: derived.claimHash, user_id: cfg.you.userId }, admin, ); return { inviteId: derived.id, inviteURL: `${base}/join/join#${buildInviteFragment(youT, "You")}`, }; } // Best-effort: point the "you" invite at the group thread so join.js opens it // automatically on sign-in (see web/static/join.js's finish(), which stores // claim.thread_id as lchat_open_thread). No admin endpoint sets thread_id on // an existing invite, so this reaches into the sqlite file directly — safe // here only because it's a throwaway dev DB we created moments ago. function patchInviteThread(dbPath, inviteId, threadId) { if (!SAFE_ID.test(inviteId) || !SAFE_ID.test(threadId)) { log("unexpected id shape — skipping thread pre-open"); return; } try { execFileSync("sqlite3", [dbPath, `UPDATE key_invites SET thread_id = '${threadId}' WHERE id = '${inviteId}';`]); } catch (e) { log(`couldn't pre-open the group thread (${e.message}) — you'll land on the thread list instead`); } } // Best-effort, idempotent: make dev-you an admin MEMBER (role, not the console // token) — the in-app admin surfaces (Admin, Webhooks) gate on it. Fresh seeds // get it via the invite's grants_admin; this patch also promotes a "you" from // an instance seeded before that existed. Same throwaway-dev-DB caveat as // patchInviteThread. function patchAdminRole(dbPath, userId) { if (!userId || !SAFE_ID.test(userId)) return; try { execFileSync("sqlite3", [dbPath, `UPDATE users SET role = 'admin' WHERE id = '${userId}';`]); } catch (e) { log(`couldn't grant the admin role (${e.message}) — in-app admin surfaces won't show`); } } // Mint the hooks service-identity invite (only when no session is stored — the // sidecar persists its identity like the dev bot does) and spawn // `lchat hooks run` on its own local TCP port: the same binary, the real // provision → poll → encrypt → post path, just with no router in front. The // server is told the port via LCHAT_HOOKS_BASE so the Webhooks app displays // URLs that actually work on this machine. async function startHooksSidecar(binPath, base, adminToken, hooksPort) { let invite = ""; if (!existsSync(HOOKS_SESSION_PATH)) { const T = randomSecret(); const derived = await deriveInvite(T); await postJSON( `${base}/api/admin/key-invites`, { label: "Webhooks", id: derived.id, wrapped: "", claim_hash: derived.claimHash, agent: true, hooks_service: true }, { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` }, ); invite = T; log("minted a hooks service invite (the sidecar claims it on first boot)"); } const child = spawn(binPath, ["hooks", "run"], { env: { ...process.env, LCHAT_BASE: base, LCHAT_SESSION: HOOKS_SESSION_PATH, LCHAT_INVITE: invite, LCHAT_SOCKET: "", // never inherit a socket path from the ambient env ADDR: `127.0.0.1:${hooksPort}`, }, }); child.stdout.pipe(process.stderr); child.stderr.pipe(process.stderr); const deadline = Date.now() + 10_000; while (Date.now() < deadline) { if (child.exitCode !== null) throw new Error(`hooks sidecar exited early (code ${child.exitCode})`); try { if ((await fetch(`http://127.0.0.1:${hooksPort}/healthz`)).ok) return child; } catch {} await new Promise((r) => setTimeout(r, 100)); } child.kill(); throw new Error("hooks sidecar didn't become ready in time"); } async function main() { if (isClean) { const stale = readConfig(); const staleDb = stale?.dbPath || DEFAULT_DB_PATH; for (const f of dbFiles(staleDb)) rmSync(f, { force: true }); clearConfig(); clearHooksSession(); log(`cleaned ${path.basename(CONFIG_PATH)} + its db — starting fresh`); } const existing = readConfig(); const dbPath = existing?.dbPath || DEFAULT_DB_PATH; // Reuse only when the config AND the db it points at both still exist; a // config whose db was deleted out from under it can't be trusted to describe // the (now-empty) instance, so we re-seed. const reusing = !!existing && existsSync(dbPath); if (existing && !reusing) log("config found but its db is gone — starting fresh"); // A fresh instance invalidates the dev bot's stored identity (its user won't // exist in the new db), so clear it — lchat-dev-bot will re-provision. if (!reusing && existsSync(BOT_SESSION_PATH)) { clearBotSession(); log("fresh instance — cleared the stored dev-bot session (it'll re-provision)"); } // Same for the hooks sidecar's service identity. if (!reusing && existsSync(HOOKS_SESSION_PATH)) { clearHooksSession(); log("fresh instance — cleared the stored hooks-sidecar session (it'll re-provision)"); } const onboarding = reusing ? existing.mode === "onboarding" : isOnboarding; const adminToken = reusing ? existing.adminToken : randomBytes(16).toString("hex"); const port = explicitPort ? Number(explicitPort) : reusing ? existing.port : await freePort(); const base = `http://127.0.0.1:${port}`; // The hooks sidecar's port is as sticky as the server's, so webhook URLs // already handed to outside senders keep working across restarts. const hooksPort = wantHooks ? (reusing && existing.hooksPort) || (await freePort()) : null; const buildDir = mkdtempSync(path.join(tmpdir(), "lchat-dev-")); const binPath = path.join(buildDir, "lchat"); let cleaned = false; let server; let hooksSidecar; const cleanup = () => { if (cleaned) return; cleaned = true; hooksSidecar?.kill(); server?.kill(); // Keep the db + config on purpose — that's what makes restarts reuse them. rmSync(buildDir, { recursive: true, force: true }); }; process.on("SIGINT", () => { cleanup(); process.exit(0); }); process.on("SIGTERM", () => { cleanup(); process.exit(0); }); process.on("exit", cleanup); log("building lchat from this checkout..."); execFileSync("go", ["build", "-o", binPath, "."], { cwd: REPO_ROOT, stdio: "inherit" }); log(`starting server on ${base} (db ${path.basename(dbPath)})...`); server = spawn(binPath, [], { cwd: buildDir, env: { ...process.env, LCHAT_DB: dbPath, LCHAT_ADMIN_TOKEN: adminToken, ADDR: `127.0.0.1:${port}`, // With the sidecar on a bare local port (no router, no in. DNS), // the server tells clients the real ingest base so the Webhooks app // displays URLs that work on this machine. ...(hooksPort ? { LCHAT_HOOKS_BASE: `http://127.0.0.1:${hooksPort}` } : {}), }, }); server.stdout.pipe(process.stderr); server.stderr.pipe(process.stderr); await waitForServer(base, server); // Build the config we'll persist. On reuse we carry the seeded fields forward // and only refresh the connection details (port/base can change via --port). const cfg = reusing ? { ...existing, base, port, adminToken, dbPath, mode: onboarding ? "onboarding" : "group" } : { version: 1, mode: onboarding ? "onboarding" : "group", base, port, adminToken, dbPath }; cfg.adminURL = `${base}/admin#token=${adminToken}`; let inviteType; if (reusing) { if (onboarding) { // Onboarding's setup invite carries no keypair (the browser generates the // admin's keys on claim), so there's nothing to re-issue. The stable port // keeps your browser signed in; a fresh browser needs --clean. log("reusing existing onboarding instance..."); cfg.inviteURL = null; cfg.inviteId = null; inviteType = "SETUP (already claimed — reload your browser)"; } else { log("reusing existing instance — re-minting a sign-in link for you..."); const re = await reissueYouInvite(base, adminToken, existing); cfg.inviteId = re.inviteId; cfg.inviteURL = re.inviteURL; inviteType = "Regular (user invite)"; } } else if (onboarding) { log("seeding fresh onboarding instance (setup/admin invite, no buddies or chats)..."); const seeded = await seedOnboarding(base, adminToken); cfg.inviteId = seeded.inviteId; cfg.inviteURL = seeded.inviteURL; inviteType = "SETUP (admin-granting)"; } else { log("seeding a second member + group chat..."); const seeded = await seed(base, adminToken); patchInviteThread(dbPath, seeded.inviteId, seeded.threadId); cfg.threadId = seeded.threadId; cfg.you = seeded.you; cfg.buddyName = BUDDY_NAME; cfg.groupName = GROUP_NAME; cfg.inviteId = seeded.inviteId; cfg.inviteURL = seeded.inviteURL; inviteType = "Regular (user invite)"; } // Dev-you is an admin member (fresh seeds via the invite's grants_admin; // this idempotent patch also promotes a "you" seeded before that existed), // so the in-app admin surfaces — Admin, Webhooks — work out of the box. if (cfg.you?.userId) patchAdminRole(dbPath, cfg.you.userId); if (hooksPort) cfg.hooksPort = hooksPort; writeConfig(cfg); if (hooksPort) { try { hooksSidecar = await startHooksSidecar(binPath, base, adminToken, hooksPort); } catch (e) { // The chat server is still useful without ingest — degrade, loudly. log(`hooks sidecar failed to start (${e.message}) — continuing without webhook ingest`); } } console.log(""); console.log(`Server: ${base} ${reusing ? "(reused)" : "(fresh)"}`); console.log(`Admin: ${cfg.adminURL}`); console.log(`Config: ${CONFIG_PATH} (lchat-dev-bot reads this)`); if (hooksSidecar) { console.log(`Webhooks: http://127.0.0.1:${hooksPort} (ingest — create one in the app: Apps → Webhooks)`); } console.log(`Type: ${inviteType}`); if (cfg.inviteURL) { console.log(`Invite ID: ${cfg.inviteId}`); console.log(`Link: ${cfg.inviteURL}`); } console.log(""); if (onboarding && reusing) { console.log("🔁 Reusing this instance — reload your browser; the stable port keeps you signed in."); console.log(" Fresh browser or want a clean slate? re-run with --clean."); } else if (onboarding) { console.log("📋 Open the invite link to become admin and start onboarding."); console.log(" The /admin command will be available in chat after you join."); } else if (reusing) { console.log("💬 Open the invite link to sign in — the group with Dev Buddy is one tap away."); console.log(" (Already signed in from before? Just reload — the link is only if you need it.)"); } else { console.log("💬 Open the invite link to land straight in the group chat with Dev Buddy."); } console.log(""); if (hooksSidecar) { console.log("🪝 Webhook round-trip: open Apps → Webhooks, create one (it deploys within ~10s),"); console.log(" then: curl -X POST -d '{\"hello\":\"world\"}' ''"); console.log(" The event lands encrypted in the chosen thread (typed events render only once"); console.log(" an app claims them — check the sidecar log lines above to watch delivery)."); } console.log("Ctrl-C stops the server; the db + config are kept (use --clean to wipe them)."); console.log(""); await new Promise((resolve) => server.on("exit", resolve)); } main().catch((e) => { log(e.message); process.exit(1); });