| index d18ad7f..64c1b1a 100755 |
| --- a/tools/lchat-dev-up.mjs |
| +++ b/tools/lchat-dev-up.mjs |
| @@ -1,20 +1,32 @@ |
| #!/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 an |
| -// invite link that signs you straight in. |
| +// 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] |
| +// Usage: tools/lchat-dev-up.mjs [--port N] [--buddy-name NAME] [--group-name NAME] [--onboarding] [--clean] |
| // |
| -// 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: |
| +// 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 (default mode): just create the invite with no buddies or chats |
| -// Without --onboarding (group mode): |
| +// 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 |
| @@ -25,10 +37,16 @@ |
| // 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. |
| +// 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). |
| |
| import { spawn, execFileSync } from "node:child_process"; |
| -import { mkdtempSync, rmSync } from "node:fs"; |
| +import { mkdtempSync, rmSync, existsSync } from "node:fs"; |
| import { tmpdir } from "node:os"; |
| import { randomBytes } from "node:crypto"; |
| import net from "node:net"; |
| @@ -43,6 +61,16 @@ import { |
| wrapThreadKey, |
| } from "../web/static/crypto.js"; |
| import { buildInviteFragment } from "../web/static/invite.js"; |
| +import { |
| + CONFIG_PATH, |
| + DEFAULT_DB_PATH, |
| + BOT_SESSION_PATH, |
| + dbFiles, |
| + readConfig, |
| + writeConfig, |
| + clearConfig, |
| + clearBotSession, |
| +} 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 |
| @@ -55,8 +83,16 @@ function arg(name, 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]\n" + |
| - " --onboarding: fresh instance with no buddies or chats (default: includes Dev Buddy + group chat)\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", |
| ); |
| process.exit(0); |
| } |
| @@ -65,6 +101,7 @@ 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"); |
| |
| function log(msg) { |
| process.stderr.write(`lchat-dev-up: ${msg}\n`); |
| @@ -129,8 +166,8 @@ async function seedOnboarding(base, adminToken) { |
| |
| // 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. |
| +// 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}` }; |
| |
| @@ -149,7 +186,9 @@ async function seed(base, adminToken) { |
| |
| // "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. |
| + // 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. |
| const youKp = await generateUserKeypair(); |
| const youT = randomSecret(); |
| const youDerived = await deriveInvite(youT); |
| @@ -186,7 +225,29 @@ async function seed(base, adminToken) { |
| |
| return { |
| threadId: thread.thread_id, |
| - youInviteId: youDerived.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. |
| +async function reissueYouInvite(base, adminToken, cfg, dbPath) { |
| + 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, |
| + ); |
| + if (cfg.threadId) patchInviteThread(dbPath, derived.id, cfg.threadId); |
| + return { |
| + inviteId: derived.id, |
| inviteURL: `${base}/join/join#${buildInviteFragment(youT, "You")}`, |
| }; |
| } |
| @@ -209,20 +270,44 @@ function patchInviteThread(dbPath, inviteId, threadId) { |
| } |
| |
| 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(); |
| + if (isClean) { |
| + const stale = readConfig(); |
| + const staleDb = stale?.dbPath || DEFAULT_DB_PATH; |
| + for (const f of dbFiles(staleDb)) rmSync(f, { force: true }); |
| + clearConfig(); |
| + 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)"); |
| + } |
| + |
| + 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}`; |
| |
| + const buildDir = mkdtempSync(path.join(tmpdir(), "lchat-dev-")); |
| + const binPath = path.join(buildDir, "lchat"); |
| + |
| let cleaned = false; |
| let server; |
| const cleanup = () => { |
| if (cleaned) return; |
| cleaned = true; |
| server?.kill(); |
| - rmSync(workDir, { recursive: true, force: true }); |
| + // 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); }); |
| @@ -231,9 +316,9 @@ async function main() { |
| log("building lchat from this checkout..."); |
| execFileSync("go", ["build", "-o", binPath, "."], { cwd: REPO_ROOT, stdio: "inherit" }); |
| |
| - log(`starting server on ${base}...`); |
| + log(`starting server on ${base} (db ${path.basename(dbPath)})...`); |
| server = spawn(binPath, [], { |
| - cwd: workDir, |
| + cwd: buildDir, |
| env: { ...process.env, LCHAT_DB: dbPath, LCHAT_ADMIN_TOKEN: adminToken, ADDR: `127.0.0.1:${port}` }, |
| }); |
| server.stdout.pipe(process.stderr); |
| @@ -241,34 +326,73 @@ async function main() { |
| |
| await waitForServer(base, server); |
| |
| - let seeded; |
| + // 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 (isOnboarding) { |
| + 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, dbPath); |
| + 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)..."); |
| - seeded = await seedOnboarding(base, adminToken); |
| + 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..."); |
| - seeded = await seed(base, adminToken); |
| - patchInviteThread(dbPath, seeded.youInviteId, seeded.threadId); |
| + 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)"; |
| } |
| |
| + writeConfig(cfg); |
| + |
| console.log(""); |
| - console.log(`Server: ${base}`); |
| - console.log(`Admin: ${base}/admin#token=${adminToken}`); |
| - console.log(`Invite ID: ${seeded.inviteId || seeded.youInviteId}`); |
| + console.log(`Server: ${base} ${reusing ? "(reused)" : "(fresh)"}`); |
| + console.log(`Admin: ${cfg.adminURL}`); |
| + console.log(`Config: ${CONFIG_PATH} (lchat-dev-bot reads this)`); |
| console.log(`Type: ${inviteType}`); |
| - console.log(`Link: ${seeded.inviteURL}`); |
| + if (cfg.inviteURL) { |
| + console.log(`Invite ID: ${cfg.inviteId}`); |
| + console.log(`Link: ${cfg.inviteURL}`); |
| + } |
| console.log(""); |
| - if (isOnboarding) { |
| + 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 { |
| console.log("💬 Open the invite link to land straight in the group chat with Dev Buddy."); |
| + if (reusing) console.log(" (Already signed in from before? Just reload — the link is only if you need it.)"); |
| } |
| console.log(""); |
| - console.log("Ctrl-C to stop and clean up."); |
| + 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)); |