| 1 | #!/usr/bin/env node |
| 2 | // lchat-dev-up — spin up a local lchat server from the current checkout |
| 3 | // (whatever branch/worktree you run it from), pre-seeded with an invite link |
| 4 | // that signs you straight in. |
| 5 | // |
| 6 | // Usage: tools/lchat-dev-up.mjs [--port N] [--buddy-name NAME] [--group-name NAME] [--onboarding] [--clean] |
| 7 | // |
| 8 | // Persistent by default. The first run writes `.lchat-dev.json` (repo root) |
| 9 | // recording the admin token, db path, port, base URL, and the seeded |
| 10 | // account/thread; later runs reuse all of it, so: |
| 11 | // • the same admin URL keeps working, |
| 12 | // • the port is stable — your browser stays signed in across restarts |
| 13 | // (localStorage is keyed by origin), no re-provisioning needed, |
| 14 | // • the db (and every app/message in it) survives a restart. |
| 15 | // The written config is also how tools/lchat-dev-bot.mjs discovers the server: |
| 16 | // run it with no --base/--admin and it reads them from `.lchat-dev.json`. |
| 17 | // |
| 18 | // --clean deletes the config + its db files (db, -wal, -shm) and starts fresh. |
| 19 | // |
| 20 | // On a fresh start it builds the Go binary from this checkout, runs it against |
| 21 | // `.lchat-dev.db` + a fresh admin token, then uses web/static/crypto.js (the |
| 22 | // exact crypto the browser runs) to, without ever touching a browser: |
| 23 | // 1. mint an admin-issued "add a device" invite for a brand-new "you" |
| 24 | // account — join.js (see its `payload.priv` branch) recognises this as a |
| 25 | // device link and signs the browser straight in as that member, with no |
| 26 | // name entry and no second session, exactly like using a real add-device |
| 27 | // QR code |
| 28 | // With --onboarding: just create the invite with no buddies or chats |
| 29 | // Without --onboarding (group mode, default): |
| 30 | // 2. mint + claim a second member ("Dev Buddy") via the admin API, so |
| 31 | // there's someone else in the chat |
| 32 | // 3. create a group thread with you + Dev Buddy and post a welcome message |
| 33 | // 4. point the invite at the group thread so it opens automatically on sign-in |
| 34 | // The one thing no HTTP endpoint exposes — pointing that invite at the group |
| 35 | // thread so it's the one that opens automatically on sign-in — is patched |
| 36 | // directly into the sqlite file via the `sqlite3` CLI, best-effort: if that's |
| 37 | // unavailable the invite still works, you just land on the thread list one |
| 38 | // tap away from the group instead of inside it. |
| 39 | // |
| 40 | // On a reused start it skips all seeding (the accounts/thread already exist) and |
| 41 | // re-mints a fresh sign-in link for the stored "you" so you always have a |
| 42 | // working link to hand. |
| 43 | // |
| 44 | // The binary is always rebuilt from the current checkout so code changes take |
| 45 | // effect on restart. Ctrl-C stops the server but KEEPS the db + config (use |
| 46 | // --clean to wipe them). |
| 47 | // |
| 48 | // It also runs the webhook-ingest sidecar (`lchat hooks run`, docs/webhooks.md) |
| 49 | // on its own sticky local port — same binary, real provision/encrypt/post path, |
| 50 | // no router — so the Webhooks app works end-to-end locally: the server is told |
| 51 | // the port via LCHAT_HOOKS_BASE, making the app display URLs you can curl. The |
| 52 | // sidecar's service identity persists in .lchat-dev-hooks.json (cleared on any |
| 53 | // fresh instance, like the dev bot's). --no-hooks skips all of it. Dev-you is |
| 54 | // seeded as an ADMIN member (grants_admin on the invite + a best-effort role |
| 55 | // patch for pre-existing instances) so the in-app admin surfaces — Admin, |
| 56 | // Webhooks — work without the console. |
| 57 | |
| 58 | import { spawn, execFileSync } from "node:child_process"; |
| 59 | import { mkdtempSync, rmSync, existsSync } from "node:fs"; |
| 60 | import { tmpdir } from "node:os"; |
| 61 | import { randomBytes } from "node:crypto"; |
| 62 | import net from "node:net"; |
| 63 | import path from "node:path"; |
| 64 | import { fileURLToPath } from "node:url"; |
| 65 | import { |
| 66 | randomSecret, |
| 67 | deriveInvite, |
| 68 | generateUserKeypair, |
| 69 | generateKey, |
| 70 | encrypt, |
| 71 | wrapThreadKey, |
| 72 | } from "../web/static/crypto.js"; |
| 73 | import { buildInviteFragment } from "../web/static/invite.js"; |
| 74 | import { |
| 75 | CONFIG_PATH, |
| 76 | DEFAULT_DB_PATH, |
| 77 | BOT_SESSION_PATH, |
| 78 | HOOKS_SESSION_PATH, |
| 79 | dbFiles, |
| 80 | readConfig, |
| 81 | writeConfig, |
| 82 | clearConfig, |
| 83 | clearBotSession, |
| 84 | clearHooksSession, |
| 85 | } from "./dev-config.mjs"; |
| 86 | |
| 87 | const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 88 | const SAFE_ID = /^[A-Za-z0-9_-]+$/; // base64url alphabet used for every id we interpolate into SQL below |
| 89 | |
| 90 | function arg(name, def) { |
| 91 | const i = process.argv.indexOf(`--${name}`); |
| 92 | return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : def; |
| 93 | } |
| 94 | |
| 95 | if (process.argv.includes("-h") || process.argv.includes("--help")) { |
| 96 | process.stdout.write( |
| 97 | "lchat-dev-up — build + run this checkout, pre-seeded with a sign-in link\n" + |
| 98 | " tools/lchat-dev-up.mjs [--port N] [--buddy-name NAME] [--group-name NAME] [--onboarding] [--clean]\n" + |
| 99 | "\n" + |
| 100 | " Persistent by default: reuses the admin token / db / port stored in\n" + |
| 101 | " .lchat-dev.json (repo root) so restarts keep your data and browser session.\n" + |
| 102 | " tools/lchat-dev-bot.mjs reads that file to find --base/--admin automatically.\n" + |
| 103 | "\n" + |
| 104 | " --clean delete .lchat-dev.json + its db files and start fresh\n" + |
| 105 | " --onboarding fresh instance with no buddies or chats (default: Dev Buddy + group chat)\n" + |
| 106 | " --port N override the stored/auto port (changes origin — may drop your browser session)\n" + |
| 107 | " --buddy-name / --group-name labels used only on a fresh seed\n" + |
| 108 | " --no-hooks skip the webhook-ingest sidecar (default: runs it, so the\n" + |
| 109 | " Webhooks app works end-to-end — curl its printed URL locally)\n", |
| 110 | ); |
| 111 | process.exit(0); |
| 112 | } |
| 113 | |
| 114 | const BUDDY_NAME = arg("buddy-name", "Dev Buddy"); |
| 115 | const GROUP_NAME = arg("group-name", "Dev chat"); |
| 116 | const explicitPort = arg("port"); |
| 117 | const isOnboarding = process.argv.includes("--onboarding"); |
| 118 | const isClean = process.argv.includes("--clean"); |
| 119 | const wantHooks = !process.argv.includes("--no-hooks"); |
| 120 | |
| 121 | function log(msg) { |
| 122 | process.stderr.write(`lchat-dev-up: ${msg}\n`); |
| 123 | } |
| 124 | |
| 125 | async function postJSON(url, body, headers = { "Content-Type": "application/json" }) { |
| 126 | const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) }); |
| 127 | if (!res.ok) { |
| 128 | const detail = await res.text().catch(() => ""); |
| 129 | throw new Error(`POST ${url} -> HTTP ${res.status} ${detail}`.trim()); |
| 130 | } |
| 131 | return res.json(); |
| 132 | } |
| 133 | |
| 134 | function freePort() { |
| 135 | return new Promise((resolve, reject) => { |
| 136 | const srv = net.createServer(); |
| 137 | srv.on("error", reject); |
| 138 | srv.listen(0, "127.0.0.1", () => { |
| 139 | const { port } = srv.address(); |
| 140 | srv.close(() => resolve(port)); |
| 141 | }); |
| 142 | }); |
| 143 | } |
| 144 | |
| 145 | async function waitForServer(base, server) { |
| 146 | const deadline = Date.now() + 10_000; |
| 147 | while (Date.now() < deadline) { |
| 148 | if (server.exitCode !== null) throw new Error(`server exited early (code ${server.exitCode})`); |
| 149 | try { |
| 150 | if ((await fetch(`${base}/api/version`)).ok) return; |
| 151 | } catch {} |
| 152 | await new Promise((r) => setTimeout(r, 100)); |
| 153 | } |
| 154 | throw new Error("server didn't become ready in time"); |
| 155 | } |
| 156 | |
| 157 | // Provisions an admin-granting setup invite for onboarding mode (fresh instance, |
| 158 | // no buddies or chats). The first person to claim it becomes admin and lands in |
| 159 | // the setup conversation with Eleven. |
| 160 | async function seedOnboarding(base, adminToken) { |
| 161 | const admin = { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` }; |
| 162 | |
| 163 | const T = randomSecret(); |
| 164 | const derived = await deriveInvite(T); |
| 165 | await postJSON( |
| 166 | `${base}/api/admin/key-invites`, |
| 167 | { |
| 168 | id: derived.id, |
| 169 | wrapped: "", |
| 170 | claim_hash: derived.claimHash, |
| 171 | grants_admin: true, |
| 172 | }, |
| 173 | admin, |
| 174 | ); |
| 175 | |
| 176 | return { |
| 177 | inviteId: derived.id, |
| 178 | inviteURL: `${base}/join/join#${buildInviteFragment(T, "")}`, |
| 179 | }; |
| 180 | } |
| 181 | |
| 182 | // Provisions "Dev Buddy" (a real, fully-joined member, so it can post) and a |
| 183 | // group thread containing them + a brand-new "you" account, then posts a |
| 184 | // welcome message. Returns what the caller needs to build the invite link, patch |
| 185 | // its thread_id, and persist "you" for re-issuing the link on later runs. |
| 186 | async function seed(base, adminToken) { |
| 187 | const admin = { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` }; |
| 188 | |
| 189 | const buddyT = randomSecret(); |
| 190 | const buddyDerived = await deriveInvite(buddyT); |
| 191 | const buddyCreate = await postJSON( |
| 192 | `${base}/api/admin/key-invites`, |
| 193 | { label: BUDDY_NAME, id: buddyDerived.id, wrapped: "", claim_hash: buddyDerived.claimHash }, |
| 194 | admin, |
| 195 | ); |
| 196 | const buddyClaim = await postJSON(`${base}/api/key-invite/${encodeURIComponent(buddyDerived.id)}/claim`, { |
| 197 | claim_secret: buddyDerived.claimSecret, |
| 198 | }); |
| 199 | const buddyKp = await generateUserKeypair(); |
| 200 | await postJSON(`${base}/api/pubkey`, { token: buddyClaim.token, public_key: buddyKp.publicKey }); |
| 201 | |
| 202 | // "You": generate the keypair ourselves and ship it wrapped in the invite — |
| 203 | // the same shape shell.js's addDevice() builds — so opening the link signs |
| 204 | // the browser straight in as this already-provisioned member. We keep the |
| 205 | // keypair in the config so later runs can re-issue a fresh add-device link |
| 206 | // for the same account without re-seeding. grants_admin makes dev-you an |
| 207 | // ADMIN member (belt: patchAdminRole braces it) — the in-app admin surfaces |
| 208 | // (Admin, Webhooks) all work without the console token. |
| 209 | const youKp = await generateUserKeypair(); |
| 210 | const youT = randomSecret(); |
| 211 | const youDerived = await deriveInvite(youT); |
| 212 | const youWrapped = await encrypt(youDerived.wrapKey, { pub: youKp.publicKey, priv: youKp.privateKey }); |
| 213 | const youCreate = await postJSON( |
| 214 | `${base}/api/admin/key-invites`, |
| 215 | { id: youDerived.id, wrapped: youWrapped, claim_hash: youDerived.claimHash, grants_admin: true }, |
| 216 | admin, |
| 217 | ); |
| 218 | |
| 219 | const group = await generateKey(); |
| 220 | const members = [ |
| 221 | { user_id: buddyCreate.user_id, wrapped: await wrapThreadKey(group.b64, buddyKp.publicKey) }, |
| 222 | { user_id: youCreate.user_id, wrapped: await wrapThreadKey(group.b64, youKp.publicKey) }, |
| 223 | ]; |
| 224 | const nameBlob = await encrypt(group.key, { name: GROUP_NAME }); |
| 225 | const thread = await postJSON(`${base}/api/threads`, { |
| 226 | token: buddyClaim.token, |
| 227 | kind: "group", |
| 228 | name_blob: nameBlob, |
| 229 | members, |
| 230 | }); |
| 231 | |
| 232 | const welcome = await encrypt(group.key, { |
| 233 | name: BUDDY_NAME, |
| 234 | text: `👋 Welcome to ${GROUP_NAME} — this chat was seeded by lchat-dev-up.`, |
| 235 | ts: Date.now(), |
| 236 | uid: buddyCreate.user_id, |
| 237 | }); |
| 238 | await postJSON(`${base}/api/messages?token=${encodeURIComponent(buddyClaim.token)}`, { |
| 239 | thread_id: thread.thread_id, |
| 240 | blob: welcome, |
| 241 | }); |
| 242 | |
| 243 | return { |
| 244 | threadId: thread.thread_id, |
| 245 | inviteId: youDerived.id, |
| 246 | inviteURL: `${base}/join/join#${buildInviteFragment(youT, "You")}`, |
| 247 | you: { userId: youCreate.user_id, publicKey: youKp.publicKey, privateKey: youKp.privateKey }, |
| 248 | }; |
| 249 | } |
| 250 | |
| 251 | // Re-mint a fresh add-device invite for an already-seeded "you" account, using |
| 252 | // the keypair we stashed in the config on the fresh seed. Passing the existing |
| 253 | // user_id makes the server re-issue for that member rather than create a new one |
| 254 | // (see createKeyInvite). Returns the new invite id + URL. |
| 255 | // |
| 256 | // Deliberately NOT thread-patched: claimKeyInvite refuses a thread-bound |
| 257 | // invite once its user already has a device (the anti-account-takeover guard |
| 258 | // on user invites), and a reused instance's "you" always has one — a patched |
| 259 | // reissue would make the printed link die with "This invite was just used." |
| 260 | // The pre-open nicety only works on the very first, fresh-seed claim; a |
| 261 | // reissued link signs you in and leaves the group one tap away. |
| 262 | async function reissueYouInvite(base, adminToken, cfg) { |
| 263 | const admin = { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` }; |
| 264 | const youT = randomSecret(); |
| 265 | const derived = await deriveInvite(youT); |
| 266 | const wrapped = await encrypt(derived.wrapKey, { pub: cfg.you.publicKey, priv: cfg.you.privateKey }); |
| 267 | await postJSON( |
| 268 | `${base}/api/admin/key-invites`, |
| 269 | { id: derived.id, wrapped, claim_hash: derived.claimHash, user_id: cfg.you.userId }, |
| 270 | admin, |
| 271 | ); |
| 272 | return { |
| 273 | inviteId: derived.id, |
| 274 | inviteURL: `${base}/join/join#${buildInviteFragment(youT, "You")}`, |
| 275 | }; |
| 276 | } |
| 277 | |
| 278 | // Best-effort: point the "you" invite at the group thread so join.js opens it |
| 279 | // automatically on sign-in (see web/static/join.js's finish(), which stores |
| 280 | // claim.thread_id as lchat_open_thread). No admin endpoint sets thread_id on |
| 281 | // an existing invite, so this reaches into the sqlite file directly — safe |
| 282 | // here only because it's a throwaway dev DB we created moments ago. |
| 283 | function patchInviteThread(dbPath, inviteId, threadId) { |
| 284 | if (!SAFE_ID.test(inviteId) || !SAFE_ID.test(threadId)) { |
| 285 | log("unexpected id shape — skipping thread pre-open"); |
| 286 | return; |
| 287 | } |
| 288 | try { |
| 289 | execFileSync("sqlite3", [dbPath, `UPDATE key_invites SET thread_id = '${threadId}' WHERE id = '${inviteId}';`]); |
| 290 | } catch (e) { |
| 291 | log(`couldn't pre-open the group thread (${e.message}) — you'll land on the thread list instead`); |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | // Best-effort, idempotent: make dev-you an admin MEMBER (role, not the console |
| 296 | // token) — the in-app admin surfaces (Admin, Webhooks) gate on it. Fresh seeds |
| 297 | // get it via the invite's grants_admin; this patch also promotes a "you" from |
| 298 | // an instance seeded before that existed. Same throwaway-dev-DB caveat as |
| 299 | // patchInviteThread. |
| 300 | function patchAdminRole(dbPath, userId) { |
| 301 | if (!userId || !SAFE_ID.test(userId)) return; |
| 302 | try { |
| 303 | execFileSync("sqlite3", [dbPath, `UPDATE users SET role = 'admin' WHERE id = '${userId}';`]); |
| 304 | } catch (e) { |
| 305 | log(`couldn't grant the admin role (${e.message}) — in-app admin surfaces won't show`); |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | // Mint the hooks service-identity invite (only when no session is stored — the |
| 310 | // sidecar persists its identity like the dev bot does) and spawn |
| 311 | // `lchat hooks run` on its own local TCP port: the same binary, the real |
| 312 | // provision → poll → encrypt → post path, just with no router in front. The |
| 313 | // server is told the port via LCHAT_HOOKS_BASE so the Webhooks app displays |
| 314 | // URLs that actually work on this machine. |
| 315 | async function startHooksSidecar(binPath, base, adminToken, hooksPort) { |
| 316 | let invite = ""; |
| 317 | if (!existsSync(HOOKS_SESSION_PATH)) { |
| 318 | const T = randomSecret(); |
| 319 | const derived = await deriveInvite(T); |
| 320 | await postJSON( |
| 321 | `${base}/api/admin/key-invites`, |
| 322 | { label: "Webhooks", id: derived.id, wrapped: "", claim_hash: derived.claimHash, agent: true, hooks_service: true }, |
| 323 | { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` }, |
| 324 | ); |
| 325 | invite = T; |
| 326 | log("minted a hooks service invite (the sidecar claims it on first boot)"); |
| 327 | } |
| 328 | const child = spawn(binPath, ["hooks", "run"], { |
| 329 | env: { |
| 330 | ...process.env, |
| 331 | LCHAT_BASE: base, |
| 332 | LCHAT_SESSION: HOOKS_SESSION_PATH, |
| 333 | LCHAT_INVITE: invite, |
| 334 | LCHAT_SOCKET: "", // never inherit a socket path from the ambient env |
| 335 | ADDR: `127.0.0.1:${hooksPort}`, |
| 336 | }, |
| 337 | }); |
| 338 | child.stdout.pipe(process.stderr); |
| 339 | child.stderr.pipe(process.stderr); |
| 340 | const deadline = Date.now() + 10_000; |
| 341 | while (Date.now() < deadline) { |
| 342 | if (child.exitCode !== null) throw new Error(`hooks sidecar exited early (code ${child.exitCode})`); |
| 343 | try { |
| 344 | if ((await fetch(`http://127.0.0.1:${hooksPort}/healthz`)).ok) return child; |
| 345 | } catch {} |
| 346 | await new Promise((r) => setTimeout(r, 100)); |
| 347 | } |
| 348 | child.kill(); |
| 349 | throw new Error("hooks sidecar didn't become ready in time"); |
| 350 | } |
| 351 | |
| 352 | async function main() { |
| 353 | if (isClean) { |
| 354 | const stale = readConfig(); |
| 355 | const staleDb = stale?.dbPath || DEFAULT_DB_PATH; |
| 356 | for (const f of dbFiles(staleDb)) rmSync(f, { force: true }); |
| 357 | clearConfig(); |
| 358 | clearHooksSession(); |
| 359 | log(`cleaned ${path.basename(CONFIG_PATH)} + its db — starting fresh`); |
| 360 | } |
| 361 | |
| 362 | const existing = readConfig(); |
| 363 | const dbPath = existing?.dbPath || DEFAULT_DB_PATH; |
| 364 | // Reuse only when the config AND the db it points at both still exist; a |
| 365 | // config whose db was deleted out from under it can't be trusted to describe |
| 366 | // the (now-empty) instance, so we re-seed. |
| 367 | const reusing = !!existing && existsSync(dbPath); |
| 368 | if (existing && !reusing) log("config found but its db is gone — starting fresh"); |
| 369 | // A fresh instance invalidates the dev bot's stored identity (its user won't |
| 370 | // exist in the new db), so clear it — lchat-dev-bot will re-provision. |
| 371 | if (!reusing && existsSync(BOT_SESSION_PATH)) { |
| 372 | clearBotSession(); |
| 373 | log("fresh instance — cleared the stored dev-bot session (it'll re-provision)"); |
| 374 | } |
| 375 | // Same for the hooks sidecar's service identity. |
| 376 | if (!reusing && existsSync(HOOKS_SESSION_PATH)) { |
| 377 | clearHooksSession(); |
| 378 | log("fresh instance — cleared the stored hooks-sidecar session (it'll re-provision)"); |
| 379 | } |
| 380 | |
| 381 | const onboarding = reusing ? existing.mode === "onboarding" : isOnboarding; |
| 382 | const adminToken = reusing ? existing.adminToken : randomBytes(16).toString("hex"); |
| 383 | const port = explicitPort ? Number(explicitPort) : reusing ? existing.port : await freePort(); |
| 384 | const base = `http://127.0.0.1:${port}`; |
| 385 | // The hooks sidecar's port is as sticky as the server's, so webhook URLs |
| 386 | // already handed to outside senders keep working across restarts. |
| 387 | const hooksPort = wantHooks ? (reusing && existing.hooksPort) || (await freePort()) : null; |
| 388 | |
| 389 | const buildDir = mkdtempSync(path.join(tmpdir(), "lchat-dev-")); |
| 390 | const binPath = path.join(buildDir, "lchat"); |
| 391 | |
| 392 | let cleaned = false; |
| 393 | let server; |
| 394 | let hooksSidecar; |
| 395 | const cleanup = () => { |
| 396 | if (cleaned) return; |
| 397 | cleaned = true; |
| 398 | hooksSidecar?.kill(); |
| 399 | server?.kill(); |
| 400 | // Keep the db + config on purpose — that's what makes restarts reuse them. |
| 401 | rmSync(buildDir, { recursive: true, force: true }); |
| 402 | }; |
| 403 | process.on("SIGINT", () => { cleanup(); process.exit(0); }); |
| 404 | process.on("SIGTERM", () => { cleanup(); process.exit(0); }); |
| 405 | process.on("exit", cleanup); |
| 406 | |
| 407 | log("building lchat from this checkout..."); |
| 408 | execFileSync("go", ["build", "-o", binPath, "."], { cwd: REPO_ROOT, stdio: "inherit" }); |
| 409 | |
| 410 | log(`starting server on ${base} (db ${path.basename(dbPath)})...`); |
| 411 | server = spawn(binPath, [], { |
| 412 | cwd: buildDir, |
| 413 | env: { |
| 414 | ...process.env, |
| 415 | LCHAT_DB: dbPath, |
| 416 | LCHAT_ADMIN_TOKEN: adminToken, |
| 417 | ADDR: `127.0.0.1:${port}`, |
| 418 | // With the sidecar on a bare local port (no router, no in.<host> DNS), |
| 419 | // the server tells clients the real ingest base so the Webhooks app |
| 420 | // displays URLs that work on this machine. |
| 421 | ...(hooksPort ? { LCHAT_HOOKS_BASE: `http://127.0.0.1:${hooksPort}` } : {}), |
| 422 | }, |
| 423 | }); |
| 424 | server.stdout.pipe(process.stderr); |
| 425 | server.stderr.pipe(process.stderr); |
| 426 | |
| 427 | await waitForServer(base, server); |
| 428 | |
| 429 | // Build the config we'll persist. On reuse we carry the seeded fields forward |
| 430 | // and only refresh the connection details (port/base can change via --port). |
| 431 | const cfg = reusing |
| 432 | ? { ...existing, base, port, adminToken, dbPath, mode: onboarding ? "onboarding" : "group" } |
| 433 | : { version: 1, mode: onboarding ? "onboarding" : "group", base, port, adminToken, dbPath }; |
| 434 | cfg.adminURL = `${base}/admin#token=${adminToken}`; |
| 435 | |
| 436 | let inviteType; |
| 437 | if (reusing) { |
| 438 | if (onboarding) { |
| 439 | // Onboarding's setup invite carries no keypair (the browser generates the |
| 440 | // admin's keys on claim), so there's nothing to re-issue. The stable port |
| 441 | // keeps your browser signed in; a fresh browser needs --clean. |
| 442 | log("reusing existing onboarding instance..."); |
| 443 | cfg.inviteURL = null; |
| 444 | cfg.inviteId = null; |
| 445 | inviteType = "SETUP (already claimed — reload your browser)"; |
| 446 | } else { |
| 447 | log("reusing existing instance — re-minting a sign-in link for you..."); |
| 448 | const re = await reissueYouInvite(base, adminToken, existing); |
| 449 | cfg.inviteId = re.inviteId; |
| 450 | cfg.inviteURL = re.inviteURL; |
| 451 | inviteType = "Regular (user invite)"; |
| 452 | } |
| 453 | } else if (onboarding) { |
| 454 | log("seeding fresh onboarding instance (setup/admin invite, no buddies or chats)..."); |
| 455 | const seeded = await seedOnboarding(base, adminToken); |
| 456 | cfg.inviteId = seeded.inviteId; |
| 457 | cfg.inviteURL = seeded.inviteURL; |
| 458 | inviteType = "SETUP (admin-granting)"; |
| 459 | } else { |
| 460 | log("seeding a second member + group chat..."); |
| 461 | const seeded = await seed(base, adminToken); |
| 462 | patchInviteThread(dbPath, seeded.inviteId, seeded.threadId); |
| 463 | cfg.threadId = seeded.threadId; |
| 464 | cfg.you = seeded.you; |
| 465 | cfg.buddyName = BUDDY_NAME; |
| 466 | cfg.groupName = GROUP_NAME; |
| 467 | cfg.inviteId = seeded.inviteId; |
| 468 | cfg.inviteURL = seeded.inviteURL; |
| 469 | inviteType = "Regular (user invite)"; |
| 470 | } |
| 471 | |
| 472 | // Dev-you is an admin member (fresh seeds via the invite's grants_admin; |
| 473 | // this idempotent patch also promotes a "you" seeded before that existed), |
| 474 | // so the in-app admin surfaces — Admin, Webhooks — work out of the box. |
| 475 | if (cfg.you?.userId) patchAdminRole(dbPath, cfg.you.userId); |
| 476 | |
| 477 | if (hooksPort) cfg.hooksPort = hooksPort; |
| 478 | writeConfig(cfg); |
| 479 | |
| 480 | if (hooksPort) { |
| 481 | try { |
| 482 | hooksSidecar = await startHooksSidecar(binPath, base, adminToken, hooksPort); |
| 483 | } catch (e) { |
| 484 | // The chat server is still useful without ingest — degrade, loudly. |
| 485 | log(`hooks sidecar failed to start (${e.message}) — continuing without webhook ingest`); |
| 486 | } |
| 487 | } |
| 488 | |
| 489 | console.log(""); |
| 490 | console.log(`Server: ${base} ${reusing ? "(reused)" : "(fresh)"}`); |
| 491 | console.log(`Admin: ${cfg.adminURL}`); |
| 492 | console.log(`Config: ${CONFIG_PATH} (lchat-dev-bot reads this)`); |
| 493 | if (hooksSidecar) { |
| 494 | console.log(`Webhooks: http://127.0.0.1:${hooksPort} (ingest — create one in the app: Apps → Webhooks)`); |
| 495 | } |
| 496 | console.log(`Type: ${inviteType}`); |
| 497 | if (cfg.inviteURL) { |
| 498 | console.log(`Invite ID: ${cfg.inviteId}`); |
| 499 | console.log(`Link: ${cfg.inviteURL}`); |
| 500 | } |
| 501 | console.log(""); |
| 502 | if (onboarding && reusing) { |
| 503 | console.log("🔁 Reusing this instance — reload your browser; the stable port keeps you signed in."); |
| 504 | console.log(" Fresh browser or want a clean slate? re-run with --clean."); |
| 505 | } else if (onboarding) { |
| 506 | console.log("📋 Open the invite link to become admin and start onboarding."); |
| 507 | console.log(" The /admin command will be available in chat after you join."); |
| 508 | } else if (reusing) { |
| 509 | console.log("💬 Open the invite link to sign in — the group with Dev Buddy is one tap away."); |
| 510 | console.log(" (Already signed in from before? Just reload — the link is only if you need it.)"); |
| 511 | } else { |
| 512 | console.log("💬 Open the invite link to land straight in the group chat with Dev Buddy."); |
| 513 | } |
| 514 | console.log(""); |
| 515 | if (hooksSidecar) { |
| 516 | console.log("🪝 Webhook round-trip: open Apps → Webhooks, create one (it deploys within ~10s),"); |
| 517 | console.log(" then: curl -X POST -d '{\"hello\":\"world\"}' '<the URL the app shows>'"); |
| 518 | console.log(" The event lands encrypted in the chosen thread (typed events render only once"); |
| 519 | console.log(" an app claims them — check the sidecar log lines above to watch delivery)."); |
| 520 | } |
| 521 | console.log("Ctrl-C stops the server; the db + config are kept (use --clean to wipe them)."); |
| 522 | console.log(""); |
| 523 | |
| 524 | await new Promise((resolve) => server.on("exit", resolve)); |
| 525 | } |
| 526 | |
| 527 | main().catch((e) => { |
| 528 | log(e.message); |
| 529 | process.exit(1); |
| 530 | }); |
| 531 | |