tools: add lchat-dev-up, a one-shot local dev server seeded with a chat (#268)
Builds the current checkout, runs it against a throwaway sqlite DB, and uses web/static/crypto.js to mint a second member + a group thread + a device-style invite that signs you straight into it — so any branch can be smoke-tested in a browser in one command instead of hand-wiring invites through the UI.
Co-authored-by: Claude <noreply@anthropic.com>
1 file changed,
+231
−0
tools/lchat-dev-up.mjs+231 −0
diff --git a/tools/lchat-dev-up.mjs b/tools/lchat-dev-up.mjs| new file mode 100755 |
| index 0000000..6aec2d8 |
| --- /dev/null |
| +++ b/tools/lchat-dev-up.mjs |
| @@ -0,0 +1,231 @@ |
| +#!/usr/bin/env node |
| +// lchat-dev-up — spin up a throwaway local lchat server from the current |
| +// checkout (whatever branch/worktree you run it from), pre-seeded with a |
| +// second member and a group chat, and print an invite link that signs you |
| +// straight into it. |
| +// |
| +// Usage: tools/lchat-dev-up.mjs [--port N] [--buddy-name NAME] [--group-name NAME] |
| +// |
| +// Builds the Go binary from this checkout, runs it against a fresh temp SQLite |
| +// DB + a random admin token, then uses web/static/crypto.js (the exact crypto |
| +// the browser runs) to, without ever touching a browser: |
| +// 1. mint + claim a second member ("Dev Buddy") via the admin API, so |
| +// there's someone else in the chat |
| +// 2. create a group thread with you + Dev Buddy and post a welcome message |
| +// 3. 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 |
| +// 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. |
| +// |
| +// Ctrl-C stops the server and deletes the temp DB. |
| + |
| +import { spawn, execFileSync } from "node:child_process"; |
| +import { mkdtempSync, rmSync } 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"; |
| + |
| +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 group chat, and print a sign-in link\n" + |
| + " tools/lchat-dev-up.mjs [--port N] [--buddy-name NAME] [--group-name NAME]\n", |
| + ); |
| + process.exit(0); |
| +} |
| + |
| +const BUDDY_NAME = arg("buddy-name", "Dev Buddy"); |
| +const GROUP_NAME = arg("group-name", "Dev chat"); |
| +const explicitPort = arg("port"); |
| + |
| +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 "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 and |
| +// patch its thread_id. |
| +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. |
| + 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 }, |
| + 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, |
| + youInviteId: youDerived.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`); |
| + } |
| +} |
| + |
| +async function main() { |
| + const workDir = mkdtempSync(path.join(tmpdir(), "lchat-dev-")); |
| + const dbPath = path.join(workDir, "lchat-dev.db"); |
| + const binPath = path.join(workDir, "lchat"); |
| + const adminToken = randomBytes(16).toString("hex"); |
| + const port = explicitPort ? Number(explicitPort) : await freePort(); |
| + const base = `http://127.0.0.1:${port}`; |
| + |
| + let cleaned = false; |
| + let server; |
| + const cleanup = () => { |
| + if (cleaned) return; |
| + cleaned = true; |
| + server?.kill(); |
| + rmSync(workDir, { 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}...`); |
| + server = spawn(binPath, [], { |
| + cwd: workDir, |
| + env: { ...process.env, LCHAT_DB: dbPath, LCHAT_ADMIN_TOKEN: adminToken, ADDR: `127.0.0.1:${port}` }, |
| + }); |
| + server.stdout.pipe(process.stderr); |
| + server.stderr.pipe(process.stderr); |
| + |
| + await waitForServer(base, server); |
| + log("seeding a second member + group chat..."); |
| + const seeded = await seed(base, adminToken); |
| + patchInviteThread(dbPath, seeded.youInviteId, seeded.threadId); |
| + |
| + console.log(""); |
| + console.log(`Server: ${base}`); |
| + console.log(`Admin: ${base}/admin#token=${adminToken}`); |
| + console.log(`Invite: ${seeded.inviteURL}`); |
| + console.log(""); |
| + console.log("Open the invite link to land straight in the group chat. Ctrl-C to stop and clean up."); |
| + |
| + await new Promise((resolve) => server.on("exit", resolve)); |
| +} |
| + |
| +main().catch((e) => { |
| + log(e.message); |
| + process.exit(1); |
| +}); |