#!/usr/bin/env node // lchat-dev-mac — seed the NATIVE Mac app with the dev server's space, for // click-testing a branch build against tools/lchat-dev-up.mjs. // // Why this exists: the Mac app's only product entry is "Sign in with Eleven" // (the home-server vault), which can't know about a local dev instance — and // the join page's "Open in the app" link is deliberately unhandled (joining // lives on the web; a native join flow comes later). So for dev builds this // script does what the vault would: it mints + claims a fresh add-device // token for the seeded "you" account and writes a SavedInstance the app can // load. // // It writes ~/Library/Application Support/lchat/instances.json — the store's // legacy READ fallback (InstancesStore in ios/LChatCore Keychain.swift). That // file holds the dev identity's PRIVATE KEY in the clear, which is exactly why // the app never WRITES it anymore — acceptable here only because this is a // throwaway localhost identity; never run this against a real space. The app // deletes the file on its first successful keychain save (migration), so // re-run the script if the space vanishes after the keychain adopts it. // // Usage: node tools/lchat-dev-mac.mjs (after tools/lchat-dev-up.mjs; quit // the Mac app first — it reads the stores at launch) import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { randomSecret, deriveInvite } from "../web/static/crypto.js"; const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); const cfgPath = path.join(root, ".lchat-dev.json"); if (!existsSync(cfgPath)) { console.error("No .lchat-dev.json — run tools/lchat-dev-up.mjs first."); process.exit(1); } const cfg = JSON.parse(readFileSync(cfgPath, "utf8")); // 1) Mint + claim a fresh add-device invite for the seeded "you" account. // wrapped is empty — we already hold the private key locally (dev config); // the claim only needs to yield a device token. const T = randomSecret(); const inv = await deriveInvite(T); let r = await fetch(cfg.base + "/api/admin/key-invites", { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer " + cfg.adminToken }, body: JSON.stringify({ label: "mac-dev", user_id: cfg.you.userId, id: inv.id, wrapped: "", claim_hash: inv.claimHash }), }); if (!r.ok) { console.error("minting the device invite failed:", r.status, await r.text()); process.exit(1); } r = await fetch(`${cfg.base}/api/key-invite/${encodeURIComponent(inv.id)}/claim`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ claim_secret: inv.claimSecret }), }); if (!r.ok) { console.error("claiming the device invite failed:", r.status, await r.text()); process.exit(1); } const { token } = await r.json(); // 2) Write the SavedInstance (Codable field names — ios/LChatCore Models.swift) // and a matching rail entry (SavedChat — ios/LChatMac MacInstance.swift), // merging with whatever is already there. const dir = path.join(homedir(), "Library/Application Support/lchat"); mkdirSync(dir, { recursive: true }); const instPath = path.join(dir, "instances.json"); const serversPath = path.join(dir, "servers.json"); const instance = { origin: cfg.base, deviceToken: token, screenName: "You", userPub: cfg.you.publicKey, userPriv: cfg.you.privateKey, myUserId: cfg.you.userId, groupName: cfg.groupName || "Dev chat", }; const readJSON = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return []; } }; const instances = readJSON(instPath).filter((i) => i && i.origin !== instance.origin); instances.push(instance); writeFileSync(instPath, JSON.stringify(instances, null, 2) + "\n"); const servers = readJSON(serversPath); if (!servers.some((s) => s && typeof s.url === "string" && s.url.startsWith(cfg.base))) { servers.push({ url: cfg.base, name: "You", lastSeenId: 0 }); writeFileSync(serversPath, JSON.stringify(servers, null, 2) + "\n"); } console.log("Seeded the Mac app with the dev space:"); console.log(" origin: " + cfg.base); console.log(" token: " + token.slice(0, 8) + "… (fresh device token)"); console.log(" stores: " + instPath); console.log(" " + serversPath); console.log("Launch the app — the space should be in the rail, chats fully native.");