eleven / messenger Public

tools: persistent local-dev config + a runnable bot helper (#395)

Make the local-dev startup scripts reuse everything across restarts by default,
with an easy clean slate — so iterating on the app no longer means re-minting
tokens, re-seeding data, and re-provisioning the bot every run.

lchat-dev-up:
- Writes .lchat-dev.json (repo root, gitignored, 0600 — it holds the admin
  token) recording the admin token, db path, port, base URL, and the seeded
  "you" account + group thread.
- Reuses all of it on the next run: same admin token (the admin URL keeps
  working), stable port (the browser stays signed in — localStorage is keyed by
  origin, no re-provisioning), and a persistent .lchat-dev.db that survives
  restarts. On reuse it skips seeding and re-mints a fresh sign-in link.
- --clean deletes the dotfile + its db files (db, -wal, -shm) and starts fresh.
- The binary is still rebuilt from the checkout each run, so code changes take
  effect on restart; Ctrl-C keeps the db + config (only --clean wipes them).

lchat-dev-bot (new): mint an agent invite, provision, and run the Claude bot in
one command instead of the mint + `bot provision` + `bot run` dance.
- Discovers the server from .lchat-dev.json automatically: a bare invocation
  reads --base/--admin from what lchat-dev-up wrote (flag > env > config file).
- Persists its identity in .lchat-dev-bot.json by default (gitignored, 0600 — it
  holds the bot's private key + device token), so the bot keeps its roster slot
  across restarts. --clean re-provisions a fresh bot. That identity is tied to
  the db, so lchat-dev-up clears it on any fresh start (--clean, or a missing
  db) and the bot re-provisions against the new instance.

tools/dev-config.mjs (new): the shared paths + read/write/clear helpers, so both
scripts agree on where the state lives and what it holds.


Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes
Co-authored-by: Claude <noreply@anthropic.com>
James Adam 2ac8d1704305a2391248d18d96844b1657b1098c parent 0bd66d9
4 files changed, +384 −36
  • .gitignore +5 −0
  • tools/dev-config.mjs +51 −0
  • tools/lchat-dev-bot.mjs +168 −0
  • tools/lchat-dev-up.mjs +160 −36
diff --git a/.gitignore b/.gitignore
index b523eb3..bebcc1e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,11 @@
*.db-wal
*.db-shm
+# Local dev state written by tools/lchat-dev-{up,bot}.mjs — the admin token and
+# the dev bot's keys/device token. (The db they point at is covered by *.db.)
+.lchat-dev.json
+.lchat-dev-bot.json
+
# macOS
.DS_Store
diff --git a/tools/dev-config.mjs b/tools/dev-config.mjs
new file mode 100644
index 0000000..b8bcc59
--- /dev/null
+++ b/tools/dev-config.mjs
@@ -0,0 +1,51 @@
+// Shared local-dev state written by tools/lchat-dev-up.mjs and read back by it
+// (to reuse the same admin token / db / port across restarts) and by
+// tools/lchat-dev-bot.mjs (to auto-discover the running server's base URL +
+// admin token, so you don't have to copy them by hand).
+//
+// The file lives at the repo root as `.lchat-dev.json` and is gitignored — it
+// holds the instance's admin token, so treat it like the secret it is (it's
+// written 0600 and never committed). The SQLite db it points at lives next to
+// it as `.lchat-dev.db` (+ `-wal`/`-shm`) and is covered by the repo's `*.db`
+// ignore rules.
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+
+export const CONFIG_PATH = path.join(REPO_ROOT, ".lchat-dev.json");
+export const DEFAULT_DB_PATH = path.join(REPO_ROOT, ".lchat-dev.db");
+
+// Where tools/lchat-dev-bot.mjs keeps the dev bot's identity (its keypair +
+// device token) so it survives restarts like the server does. It's tied to the
+// db above — a fresh instance invalidates it — so lchat-dev-up clears it on any
+// fresh start, and lchat-dev-bot --clean drops it to re-provision.
+export const BOT_SESSION_PATH = path.join(REPO_ROOT, ".lchat-dev-bot.json");
+
+// The three files SQLite writes for one WAL-mode database, so callers can clean
+// them all in one sweep.
+export function dbFiles(dbPath) {
+ return [dbPath, `${dbPath}-wal`, `${dbPath}-shm`];
+}
+
+// Returns the parsed config, or null if it's missing/unreadable.
+export function readConfig() {
+ try {
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
+ } catch {
+ return null;
+ }
+}
+
+export function writeConfig(cfg) {
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
+}
+
+export function clearConfig() {
+ fs.rmSync(CONFIG_PATH, { force: true });
+}
+
+export function clearBotSession() {
+ fs.rmSync(BOT_SESSION_PATH, { force: true });
+}
diff --git a/tools/lchat-dev-bot.mjs b/tools/lchat-dev-bot.mjs
new file mode 100644
index 0000000..cfa0deb
--- /dev/null
+++ b/tools/lchat-dev-bot.mjs
@@ -0,0 +1,168 @@
+#!/usr/bin/env node
+// lchat-dev-bot — mint an agent invite, provision the Claude bot, and run it,
+// against a local lchat server (e.g. one started by tools/lchat-dev-up.mjs).
+// One command instead of the mint + `bot provision` + `bot run` dance.
+//
+// Usage:
+// ANTHROPIC_API_KEY=sk-ant-... \
+// tools/lchat-dev-bot.mjs --base http://127.0.0.1:PORT --admin ADMINTOKEN
+//
+// --base URL the running server (defaults to .lchat-dev.json, then $LCHAT_BASE)
+// --admin TOKEN the server's admin token (defaults to .lchat-dev.json, then env)
+// --name NAME bot name shown in the roster (default: Claude)
+// --session PATH session file to keep the bot's identity in (default:
+// .lchat-dev-bot.json). If it exists, the bot is REUSED (no new
+// invite/provision); otherwise it's minted + provisioned.
+// --clean drop the stored session and provision a fresh bot
+// --available/--no-available show the bot in members' "Helpers" list (default: on)
+//
+// Uses web/static/crypto.js (the exact browser crypto) to mint the agent
+// invite, builds the binary from this checkout, provisions, then runs.
+// ANTHROPIC_API_KEY is required for the bot to actually build member apps.
+// The session persists by default so the bot keeps its identity (and roster
+// slot) across restarts, like lchat-dev-up keeps the server. A fresh lchat-dev-up
+// instance clears it automatically; --clean resets it by hand. Ctrl-C stops the
+// bot but keeps the session.
+
+import { spawn, execFileSync } from "node:child_process";
+import { mkdtempSync, rmSync, existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { randomSecret, deriveInvite } from "../web/static/crypto.js";
+import { readConfig, BOT_SESSION_PATH } from "./dev-config.mjs";
+
+const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+
+function arg(name, def) {
+ const i = process.argv.indexOf(`--${name}`);
+ return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : def;
+}
+function log(msg) {
+ process.stderr.write(`lchat-dev-bot: ${msg}\n`);
+}
+function die(msg) {
+ log(msg);
+ process.exit(1);
+}
+
+if (process.argv.includes("-h") || process.argv.includes("--help")) {
+ process.stdout.write(
+ "lchat-dev-bot — mint an agent invite, provision + run the Claude bot\n" +
+ " ANTHROPIC_API_KEY=… tools/lchat-dev-bot.mjs [--base http://127.0.0.1:PORT] [--admin TOKEN]\n" +
+ " --base/--admin default to .lchat-dev.json (written by lchat-dev-up), then $LCHAT_BASE/$LCHAT_ADMIN_TOKEN\n" +
+ " --name NAME --session PATH --clean --no-available\n" +
+ " The session persists by default (.lchat-dev-bot.json); --clean re-provisions a fresh bot.\n",
+ );
+ process.exit(0);
+}
+
+// Fall back to whatever lchat-dev-up wrote so a bare `tools/lchat-dev-bot.mjs`
+// just works against the running local server. Order: explicit flag > env >
+// dev config file.
+const cfg = readConfig() || {};
+const base = (arg("base", process.env.LCHAT_BASE || cfg.base) || "").replace(/\/$/, "");
+const admin = arg("admin", process.env.LCHAT_ADMIN_TOKEN || cfg.adminToken);
+const name = arg("name", "Claude");
+const available = !process.argv.includes("--no-available");
+const apiKey = process.env.ANTHROPIC_API_KEY || "";
+
+// Session persists by default so the bot keeps its identity + roster slot across
+// restarts (mirroring lchat-dev-up's server). --clean drops it to re-provision.
+const session = arg("session", BOT_SESSION_PATH);
+if (process.argv.includes("--clean")) {
+ rmSync(session, { force: true });
+ log(`--clean: dropped ${path.basename(session)} — provisioning a fresh bot`);
+}
+
+if (!base) die("set --base (or LCHAT_BASE, or run lchat-dev-up first) to the server URL, e.g. http://127.0.0.1:8080");
+if (!admin) die("set --admin (or LCHAT_ADMIN_TOKEN, or run lchat-dev-up first) to the server's admin token");
+
+async function reachable() {
+ try {
+ return (await fetch(`${base}/api/version`)).ok;
+ } catch {
+ return false;
+ }
+}
+
+// Mint an agent invite via the admin API and return its secret T.
+async function mintAgentInvite() {
+ const T = randomSecret();
+ const d = await deriveInvite(T);
+ const res = await fetch(`${base}/api/admin/key-invites`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin}` },
+ body: JSON.stringify({
+ label: name,
+ id: d.id,
+ claim_hash: d.claimHash,
+ agent: true,
+ available_to_members: available,
+ description: "General assistant",
+ }),
+ });
+ if (!res.ok) throw new Error(`mint agent invite -> HTTP ${res.status} ${await res.text().catch(() => "")}`.trim());
+ return T;
+}
+
+async function main() {
+ if (!(await reachable())) die(`can't reach ${base}/api/version — is the server running?`);
+ if (!apiKey) log("⚠️ ANTHROPIC_API_KEY is not set — the bot will connect but can't build apps.");
+
+ // Build the binary from this checkout (throwaway build dir; the session is
+ // persistent and lives outside it).
+ const workDir = mkdtempSync(path.join(tmpdir(), "lchat-dev-bot-"));
+ const binPath = path.join(workDir, "lchat");
+ log("building lchat from this checkout...");
+ execFileSync("go", ["build", "-o", binPath, "."], { cwd: REPO_ROOT, stdio: "inherit" });
+
+ let cleaned = false;
+ let bot;
+ const cleanup = () => {
+ if (cleaned) return;
+ cleaned = true;
+ bot?.kill();
+ rmSync(workDir, { recursive: true, force: true }); // build dir only — keep the session
+ };
+ process.on("SIGINT", () => { cleanup(); process.exit(0); });
+ process.on("SIGTERM", () => { cleanup(); process.exit(0); });
+ process.on("exit", cleanup);
+
+ // Reuse an existing session, else mint an invite + provision into it.
+ const reused = existsSync(session);
+ if (reused) {
+ log(`reusing existing session ${session} (skipping invite + provision)`);
+ } else {
+ log("minting an agent invite...");
+ const T = await mintAgentInvite();
+ log(`provisioning "${name}"...`);
+ execFileSync(binPath, ["bot", "provision", "-base", base, "-session", session, "-name", name], {
+ stdio: "inherit",
+ env: { ...process.env, LCHAT_INVITE: T },
+ });
+ }
+
+ console.log("");
+ console.log(`Bot: ${name}`);
+ console.log(`Server: ${base}`);
+ console.log(`Session: ${session} ${reused ? "(reused)" : "(new — provisioned)"}`);
+ console.log(`AI build: ${apiKey ? "enabled" : "DISABLED (set ANTHROPIC_API_KEY)"}`);
+ console.log("");
+ console.log("👉 Open (or reload) your browser now so your client grants the instance");
+ console.log(" key to this bot — required before it can build/deploy member apps.");
+ console.log(" Then: + New chat → Helpers → " + name + " → “build me a habit tracker”.");
+ console.log("");
+ console.log("Ctrl-C to stop.");
+ console.log("");
+
+ log("running the bot...");
+ bot = spawn(binPath, ["bot", "run", "-base", base, session], {
+ env: { ...process.env, ANTHROPIC_API_KEY: apiKey },
+ });
+ bot.stdout.pipe(process.stderr);
+ bot.stderr.pipe(process.stderr);
+ await new Promise((resolve) => bot.on("exit", resolve));
+}
+
+main().catch((e) => die(e.message));
diff --git a/tools/lchat-dev-up.mjs b/tools/lchat-dev-up.mjs
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));