eleven / messenger Public

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>
James Adam 19b40e6816ded0ff7cd54655349cd224f394d73b parent 8359691
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);
+});