| 1 | #!/usr/bin/env node |
| 2 | // lchat-dev-mac — seed the NATIVE Mac app with the dev server's space, for |
| 3 | // click-testing a branch build against tools/lchat-dev-up.mjs. |
| 4 | // |
| 5 | // Why this exists: the Mac app's only product entry is "Sign in with Eleven" |
| 6 | // (the home-server vault), which can't know about a local dev instance — and |
| 7 | // the join page's "Open in the app" link is deliberately unhandled (joining |
| 8 | // lives on the web; a native join flow comes later). So for dev builds this |
| 9 | // script does what the vault would: it mints + claims a fresh add-device |
| 10 | // token for the seeded "you" account and writes a SavedInstance the app can |
| 11 | // load. |
| 12 | // |
| 13 | // It writes ~/Library/Application Support/lchat/instances.json — the store's |
| 14 | // legacy READ fallback (InstancesStore in ios/LChatCore Keychain.swift). That |
| 15 | // file holds the dev identity's PRIVATE KEY in the clear, which is exactly why |
| 16 | // the app never WRITES it anymore — acceptable here only because this is a |
| 17 | // throwaway localhost identity; never run this against a real space. The app |
| 18 | // deletes the file on its first successful keychain save (migration), so |
| 19 | // re-run the script if the space vanishes after the keychain adopts it. |
| 20 | // |
| 21 | // Usage: node tools/lchat-dev-mac.mjs (after tools/lchat-dev-up.mjs; quit |
| 22 | // the Mac app first — it reads the stores at launch) |
| 23 | import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; |
| 24 | import { homedir } from "node:os"; |
| 25 | import path from "node:path"; |
| 26 | import { fileURLToPath } from "node:url"; |
| 27 | import { randomSecret, deriveInvite } from "../web/static/crypto.js"; |
| 28 | |
| 29 | const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 30 | const cfgPath = path.join(root, ".lchat-dev.json"); |
| 31 | if (!existsSync(cfgPath)) { |
| 32 | console.error("No .lchat-dev.json — run tools/lchat-dev-up.mjs first."); |
| 33 | process.exit(1); |
| 34 | } |
| 35 | const cfg = JSON.parse(readFileSync(cfgPath, "utf8")); |
| 36 | |
| 37 | // 1) Mint + claim a fresh add-device invite for the seeded "you" account. |
| 38 | // wrapped is empty — we already hold the private key locally (dev config); |
| 39 | // the claim only needs to yield a device token. |
| 40 | const T = randomSecret(); |
| 41 | const inv = await deriveInvite(T); |
| 42 | let r = await fetch(cfg.base + "/api/admin/key-invites", { |
| 43 | method: "POST", |
| 44 | headers: { "Content-Type": "application/json", Authorization: "Bearer " + cfg.adminToken }, |
| 45 | body: JSON.stringify({ label: "mac-dev", user_id: cfg.you.userId, id: inv.id, wrapped: "", claim_hash: inv.claimHash }), |
| 46 | }); |
| 47 | if (!r.ok) { |
| 48 | console.error("minting the device invite failed:", r.status, await r.text()); |
| 49 | process.exit(1); |
| 50 | } |
| 51 | r = await fetch(`${cfg.base}/api/key-invite/${encodeURIComponent(inv.id)}/claim`, { |
| 52 | method: "POST", |
| 53 | headers: { "Content-Type": "application/json" }, |
| 54 | body: JSON.stringify({ claim_secret: inv.claimSecret }), |
| 55 | }); |
| 56 | if (!r.ok) { |
| 57 | console.error("claiming the device invite failed:", r.status, await r.text()); |
| 58 | process.exit(1); |
| 59 | } |
| 60 | const { token } = await r.json(); |
| 61 | |
| 62 | // 2) Write the SavedInstance (Codable field names — ios/LChatCore Models.swift) |
| 63 | // and a matching rail entry (SavedChat — ios/LChatMac MacInstance.swift), |
| 64 | // merging with whatever is already there. |
| 65 | const dir = path.join(homedir(), "Library/Application Support/lchat"); |
| 66 | mkdirSync(dir, { recursive: true }); |
| 67 | const instPath = path.join(dir, "instances.json"); |
| 68 | const serversPath = path.join(dir, "servers.json"); |
| 69 | |
| 70 | const instance = { |
| 71 | origin: cfg.base, |
| 72 | deviceToken: token, |
| 73 | screenName: "You", |
| 74 | userPub: cfg.you.publicKey, |
| 75 | userPriv: cfg.you.privateKey, |
| 76 | myUserId: cfg.you.userId, |
| 77 | groupName: cfg.groupName || "Dev chat", |
| 78 | }; |
| 79 | const readJSON = (p) => { |
| 80 | try { |
| 81 | return JSON.parse(readFileSync(p, "utf8")); |
| 82 | } catch { |
| 83 | return []; |
| 84 | } |
| 85 | }; |
| 86 | const instances = readJSON(instPath).filter((i) => i && i.origin !== instance.origin); |
| 87 | instances.push(instance); |
| 88 | writeFileSync(instPath, JSON.stringify(instances, null, 2) + "\n"); |
| 89 | |
| 90 | const servers = readJSON(serversPath); |
| 91 | if (!servers.some((s) => s && typeof s.url === "string" && s.url.startsWith(cfg.base))) { |
| 92 | servers.push({ url: cfg.base, name: "You", lastSeenId: 0 }); |
| 93 | writeFileSync(serversPath, JSON.stringify(servers, null, 2) + "\n"); |
| 94 | } |
| 95 | |
| 96 | console.log("Seeded the Mac app with the dev space:"); |
| 97 | console.log(" origin: " + cfg.base); |
| 98 | console.log(" token: " + token.slice(0, 8) + "… (fresh device token)"); |
| 99 | console.log(" stores: " + instPath); |
| 100 | console.log(" " + serversPath); |
| 101 | console.log("Launch the app — the space should be in the rail, chats fully native."); |
| 102 | |