eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1#!/usr/bin/env node
2// dev — simple CLI for common dev tasks on the messenger project.
3//
4// Usage:
5// ./tools/dev build mac Build the native Mac app (LChatMac scheme)
6// ./tools/dev build ios Build the native iOS app (LChat scheme)
7// ./tools/dev run mac Launch the Mac app
8// ./tools/dev alt [slug] Build + launch a side-by-side dev alt of the Mac app
9// ./tools/dev run ios Run the iOS app on simulator (see instructions)
10// ./tools/dev install ios [id] Build + install + launch on a real iPhone (network,
11// no cable); id defaults to the first paired device,
12// or LCHAT_IOS_DEVICE
13// ./tools/dev up [flags] Start the local dev server (persistent, reusable config)
14// + the webhook-ingest sidecar. Flags after the command
15// pass straight to tools/lchat-dev-up.mjs: --clean,
16// --onboarding, --no-hooks, --port N, --help.
17// ./tools/dev seed-mac Seed the Mac app with dev server credentials
18// ./tools/dev seed-ios Seed the iOS app with dev server credentials
19// ./tools/dev link Print the sign-in link for the dev server
20// ./tools/dev test Run the test suite (Go + Node)
21// ./tools/dev test web Run web tests only
22// ./tools/dev test go [args] Run Go tests only (args forwarded to go test,
23// e.g. -run TestFoo ./internal/bot)
24// ./tools/dev shots <verb> Marketing-screenshot kit (docs/marketing-screenshots.md):
25// up | tunnel | mac | web-check <png>. Stands up an
26// isolated, fictional demo space ("The Commons") for
27// native Mac + iPhone captures. Interactive captures
28// stay manual — see the doc.
29//
30// All commands assume you're at the repo root. Use ./tools/dev to invoke from anywhere.
31
32import { spawn, spawnSync, execFileSync } from "node:child_process";
33import { fileURLToPath } from "node:url";
34import path from "node:path";
35import { readFileSync, statSync, existsSync } from "node:fs";
36import { readConfig } from "./dev-config.mjs";
37
38const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
39const IOS_DIR = path.join(REPO_ROOT, "ios");
40
41// Check if project needs regeneration (project.yml changed since .xcodeproj was generated)
42function needsXcodegen() {
43 const projectYml = path.join(IOS_DIR, "project.yml");
44 const xcodeproj = path.join(IOS_DIR, "LChat.xcodeproj");
45
46 if (!existsSync(xcodeproj)) return true;
47
48 try {
49 const ymlStat = statSync(projectYml);
50 const projStat = statSync(xcodeproj);
51 return ymlStat.mtime > projStat.mtime;
52 } catch {
53 return true;
54 }
55}
56
57// The first physical iPhone devicectl reports as usable — what `install ios`
58// targets when no id is given. Simulators and the header row are skipped.
59function firstPairediPhone() {
60 try {
61 const out = execFileSync("xcrun", ["devicectl", "list", "devices"], { encoding: "utf8" });
62 for (const line of out.split("\n")) {
63 if (!/(available|connected)/i.test(line)) continue;
64 if (!/physical/i.test(line) || /simulated/i.test(line)) continue;
65 const id = line.match(/([A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12})/i);
66 if (id) return id[1];
67 }
68 } catch {
69 // devicectl unavailable / no devices — the caller prints the hint.
70 }
71 return null;
72}
73
74// The app's bundle id, read from project.yml's one knob (LCHAT_APP_BUNDLE_ID)
75// so an overridden id still launches the thing we just installed.
76function bundleIdFromProject() {
77 try {
78 const yml = readFileSync(path.join(IOS_DIR, "project.yml"), "utf8");
79 const hit = yml.match(/LCHAT_APP_BUNDLE_ID:\s*(\S+)/);
80 if (hit) return hit[1];
81 } catch {
82 // fall through to the shipped default
83 }
84 return "com.elevenmessenger.messenger";
85}
86
87// Check if build needs to be redone (source files changed since app was built)
88function needsBuild(appPath) {
89 if (!existsSync(appPath)) return true;
90
91 try {
92 const appStat = statSync(appPath);
93 // Check modification times of key source directories
94 const sourceDir = IOS_DIR;
95 const srcStat = statSync(sourceDir);
96 return srcStat.mtime > appStat.mtime;
97 } catch {
98 return true;
99 }
100}
101
102// The Mac target's Go build phase needs GOTOOLCHAIN=auto (go-toolchain
103// pinning otherwise fails the build under Xcode's environment).
104function goEnv() {
105 return { ...process.env, GOTOOLCHAIN: process.env.GOTOOLCHAIN ?? "auto" };
106}
107
108function run(cmd, args, opts = {}) {
109 const result = spawnSync(cmd, args, { cwd: REPO_ROOT, stdio: "inherit", ...opts });
110 if (result.error) throw result.error;
111 if (result.status !== 0) process.exit(result.status);
112}
113
114function runAsync(cmd, args, opts = {}) {
115 return new Promise((resolve, reject) => {
116 const proc = spawn(cmd, args, { cwd: REPO_ROOT, stdio: "inherit", ...opts });
117 proc.on("error", reject);
118 proc.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`exited with code ${code}`))));
119 });
120}
121
122const commands = {
123 "build mac": () => {
124 if (needsXcodegen()) {
125 console.log("Generating Xcode project...");
126 run("xcodegen", ["generate"], { cwd: IOS_DIR });
127 } else {
128 console.log("Xcode project up-to-date");
129 }
130
131 console.log("Building LChatMac (native macOS app)...");
132 const result = spawnSync("xcodebuild", [
133 "-project", path.join(IOS_DIR, "LChat.xcodeproj"),
134 "-scheme", "LChatMac",
135 "-destination", "generic/platform=macOS",
136 "-derivedDataPath", path.join(IOS_DIR, "build"),
137 "build",
138 ], { cwd: IOS_DIR, encoding: "utf8", env: goEnv(), maxBuffer: 64 * 1024 * 1024 });
139
140 if (result.status !== 0) {
141 console.error("Build failed!");
142 // A spawn-level failure (e.g. ENOBUFS when output outgrows maxBuffer)
143 // carries no build output — name it, or nothing at all gets printed.
144 if (result.error) console.error(String(result.error));
145 // xcodebuild reports errors on STDOUT — stderr alone is usually empty.
146 const errs = (result.stdout || "").split("\n").filter((l) => l.includes("error:"));
147 console.error(errs.slice(-20).join("\n") || result.stderr || "");
148 process.exit(result.status ?? 1);
149 }
150
151 console.log("✓ Mac app built successfully");
152 },
153
154 "build ios": () => {
155 if (needsXcodegen()) {
156 console.log("Generating Xcode project...");
157 run("xcodegen", ["generate"], { cwd: IOS_DIR });
158 } else {
159 console.log("Xcode project up-to-date");
160 }
161
162 console.log("Building LChat (native iOS app)...");
163 run("xcodebuild", [
164 "-project", path.join(IOS_DIR, "LChat.xcodeproj"),
165 "-scheme", "LChat",
166 "-destination", "generic/platform=iOS",
167 "-derivedDataPath", path.join(IOS_DIR, "build"),
168 "build",
169 ], { cwd: IOS_DIR });
170 console.log("✓ iOS app built successfully");
171 },
172
173 // 🤖 Build + install + launch on a REAL iPhone, over the network tunnel
174 // devicectl opens (no cable: the phone just has to be unlocked, trusted and
175 // on the same Wi-Fi). Encoded here rather than living in an operator's
176 // private notes, so any machine can put a dev build on a device.
177 // ./tools/dev install ios [device-id] (or LCHAT_IOS_DEVICE=<id>)
178 // Signing is Automatic + -allowProvisioningUpdates: a new device must
179 // already be registered with the team before a profile can be minted.
180 // If xcodebuild says "iOS <v> is not installed", point DEVELOPER_DIR at an
181 // Xcode that carries that platform (see docs/native-chat.md).
182 "install ios": () => {
183 if (needsXcodegen()) {
184 console.log("Generating Xcode project...");
185 run("xcodegen", ["generate"], { cwd: IOS_DIR });
186 }
187 const device = cmdArgs[0] || process.env.LCHAT_IOS_DEVICE || firstPairediPhone();
188 if (!device) {
189 console.error("No paired iPhone found. Unlock it, trust this Mac, same Wi-Fi;");
190 console.error("`xcrun devicectl list devices` should show it as available.");
191 process.exit(1);
192 }
193 console.log(`Building LChat for device ${device}...`);
194 const derived = path.join(IOS_DIR, "build");
195 run("xcodebuild", [
196 "-project", path.join(IOS_DIR, "LChat.xcodeproj"),
197 "-scheme", "LChat",
198 "-destination", "generic/platform=iOS",
199 "-derivedDataPath", derived,
200 "-allowProvisioningUpdates",
201 "CODE_SIGN_STYLE=Automatic",
202 "build",
203 ], { cwd: IOS_DIR });
204 const app = path.join(derived, "Build/Products/Debug-iphoneos/LChat.app");
205 // The first reach for a sleeping tunnel often comes back "Connection reset
206 // by peer"; the immediate retry lands. Only a second failure is real.
207 console.log("Installing...");
208 const install = () => spawnSync("xcrun",
209 ["devicectl", "device", "install", "app", "--device", device, app],
210 { cwd: REPO_ROOT, stdio: "inherit" });
211 if (install().status !== 0) {
212 console.log("Device tunnel didn't answer — retrying once...");
213 if (install().status !== 0) {
214 console.error("Install failed. Unlock the phone and make sure it's on this Wi-Fi;");
215 console.error("opening Xcode → Window → Devices and Simulators (⇧⌘2) kicks the tunnel.");
216 process.exit(1);
217 }
218 }
219 console.log("Launching...");
220 run("xcrun", ["devicectl", "device", "process", "launch", "--device", device,
221 bundleIdFromProject()]);
222 console.log("✓ Installed and launched on the device");
223 },
224
225 "up": async () => {
226 console.log("Starting dev server (lchat-dev-up)...");
227 // Pass flags through (--clean, --onboarding, --no-hooks, --port N, …).
228 await runAsync("node", ["tools/lchat-dev-up.mjs", ...cmdArgs]);
229 },
230
231 "down": () => {
232 console.log("Stop the dev server by pressing Ctrl-C in its terminal.");
233 process.exit(0);
234 },
235
236 "seed-mac": async () => {
237 const cfg = readConfig();
238 if (!cfg) {
239 console.error("No dev server config found. Run: ./tools/dev up");
240 process.exit(1);
241 }
242 if (cfg.mode === "onboarding") {
243 console.error("Error: dev server is in onboarding mode (no 'you' account).");
244 console.error("Reset it in group mode (the default):");
245 console.error(" ./tools/dev up --clean");
246 process.exit(1);
247 }
248 console.log("Seeding Mac app with dev server credentials...");
249 await runAsync("node", ["tools/lchat-dev-mac.mjs"]);
250 console.log("✓ Mac app seeded. Launch it from Xcode or Applications.");
251 },
252
253 "seed-ios": async () => {
254 const cfg = readConfig();
255 if (!cfg) {
256 console.error("No dev server config found. Run: ./tools/dev up");
257 process.exit(1);
258 }
259 console.log("Seeding iOS app with dev server credentials...");
260 console.log("(iOS seeding not yet implemented — use the web app or open the invite link)");
261 console.log(`Invite link: ${cfg.inviteURL || "Not available"}`);
262 process.exit(1);
263 },
264
265 "link": async () => {
266 const cfg = readConfig();
267 if (!cfg) {
268 console.error("No dev server config found. Run: ./tools/dev up");
269 process.exit(1);
270 }
271 if (cfg.inviteURL) {
272 console.log(cfg.inviteURL);
273 } else {
274 console.log("Server running at:", cfg.base);
275 console.log("Admin at:", cfg.adminURL);
276 }
277 },
278
279 "run mac": async () => {
280 // Find the build output directory. Must pass the same -derivedDataPath
281 // "build mac" builds with — without it xcodebuild reports the default
282 // DerivedData location, where the app was never built, and launch fails.
283 const macSettingsArgs = [
284 "-project", path.join(IOS_DIR, "LChat.xcodeproj"),
285 "-scheme", "LChatMac",
286 "-destination", "generic/platform=macOS",
287 "-derivedDataPath", path.join(IOS_DIR, "build"),
288 "-showBuildSettings",
289 ];
290 let buildSettingsOutput;
291 try {
292 buildSettingsOutput = execFileSync("xcodebuild", macSettingsArgs,
293 { cwd: IOS_DIR, encoding: "utf8" });
294 } catch (e) {
295 console.log("Build settings lookup failed. Building first...");
296 await commands["build mac"]();
297 buildSettingsOutput = execFileSync("xcodebuild", macSettingsArgs,
298 { cwd: IOS_DIR, encoding: "utf8" });
299 }
300
301 const builtProductsDir = buildSettingsOutput
302 .split("\n")
303 .find(line => line.includes("BUILT_PRODUCTS_DIR"))
304 ?.split("=")[1]
305 ?.trim();
306
307 if (!builtProductsDir) {
308 console.error("Could not determine build output directory");
309 process.exit(1);
310 }
311
312 const appPath = path.join(builtProductsDir, "Eleven.app");
313
314 // Check if app needs to be rebuilt
315 if (needsXcodegen()) {
316 console.log("Source files changed. Rebuilding...");
317 await commands["build mac"]();
318 } else if (needsBuild(appPath)) {
319 console.log("Source files changed. Rebuilding...");
320 await commands["build mac"]();
321 } else if (!existsSync(appPath)) {
322 console.log("App not found. Building...");
323 await commands["build mac"]();
324 } else {
325 console.log("App is up-to-date");
326 }
327
328 console.log("Launching LChatMac...");
329 execFileSync("open", [appPath], { stdio: "inherit" });
330 },
331
332 "run ios": async () => {
333 // Check for connected/available devices (USB, network, or paired)
334 let connectedDevices = [];
335 try {
336 const devicesOutput = execFileSync("xcrun", ["devicectl", "list", "devices"], { encoding: "utf8" });
337 const lines = devicesOutput.split("\n");
338
339 connectedDevices = lines
340 .filter(line => {
341 // Match lines with device states like "available (paired)" or "Connected"
342 return /(available|Connected)/i.test(line) && !line.includes("Simulator") && !line.includes("State");
343 })
344 .map(line => {
345 // Extract UUID (format: 8-4-4-4-12 hex digits with hyphens)
346 const match = line.match(/([A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12})/i);
347 return match ? match[1] : null;
348 })
349 .filter(Boolean);
350 } catch (e) {
351 // devicectl not available or no devices
352 }
353
354 const useRealDevice = connectedDevices.length > 0;
355 const destination = useRealDevice
356 ? "generic/platform=iOS"
357 : "generic/platform=iOS Simulator";
358
359 if (needsXcodegen()) {
360 console.log("Generating Xcode project...");
361 run("xcodegen", ["generate"], { cwd: IOS_DIR });
362 } else {
363 console.log("Xcode project up-to-date");
364 }
365
366 console.log(`Building iOS app for ${useRealDevice ? "device" : "simulator"}...`);
367 run("xcodebuild", [
368 "-project", path.join(IOS_DIR, "LChat.xcodeproj"),
369 "-scheme", "LChat",
370 "-destination", destination,
371 "-derivedDataPath", path.join(IOS_DIR, "build"),
372 "build",
373 ], { cwd: IOS_DIR });
374
375 if (useRealDevice) {
376 // Install on real device
377 const deviceId = connectedDevices[0];
378 const appPath = path.join(IOS_DIR, "build/Build/Products/Debug-iphoneos/LChat.app");
379
380 if (!existsSync(appPath)) {
381 console.error("Built app not found at:", appPath);
382 process.exit(1);
383 }
384
385 try {
386 console.log(`Installing on device ${deviceId}...`);
387 execFileSync("xcrun", ["devicectl", "device", "install", "app", "--device", deviceId, appPath], { stdio: "inherit" });
388 console.log("Launching app...");
389 execFileSync("xcrun", ["devicectl", "device", "process", "launch", "--device", deviceId, "com.elevenmessenger.messenger"], { stdio: "inherit" });
390 console.log("✓ iOS app installed and launched on device");
391 } catch (e) {
392 console.error("Failed to install on device:", e.message);
393 process.exit(1);
394 }
395 } else {
396 // Install on simulator
397 const appPath = path.join(IOS_DIR, "build/Build/Products/Debug-iphonesimulator/LChat.app");
398
399 if (!existsSync(appPath)) {
400 console.error("Built app not found at:", appPath);
401 process.exit(1);
402 }
403
404 // Try to install and launch on booted simulator, or boot one if needed
405 try {
406 console.log("Installing on booted simulator...");
407 execFileSync("xcrun", ["simctl", "install", "booted", appPath], { stdio: "inherit" });
408 console.log("Launching app...");
409 execFileSync("xcrun", ["simctl", "launch", "booted", "com.elevenmessenger.messenger"], { stdio: "inherit" });
410 console.log("✓ iOS app launched on simulator");
411 } catch (e) {
412 // No booted simulator; try to find and boot one
413 console.log("No booted simulator. Looking for available simulators...");
414 try {
415 const listOutput = execFileSync("xcrun", ["simctl", "list", "devices", "iPhone"], { encoding: "utf8" });
416 // Extract first available iPhone simulator UDID
417 const match = listOutput.match(/\(([A-F0-9\-]+)\)/);
418 if (match) {
419 const udid = match[1];
420 console.log(`Booting simulator ${udid}...`);
421 execFileSync("xcrun", ["simctl", "boot", udid], { stdio: "inherit" });
422
423 // Wait a moment for simulator to boot
424 await new Promise(r => setTimeout(r, 2000));
425
426 console.log("Installing app...");
427 execFileSync("xcrun", ["simctl", "install", udid, appPath], { stdio: "inherit" });
428 console.log("Launching app...");
429 execFileSync("xcrun", ["simctl", "launch", udid, "com.elevenmessenger.messenger"], { stdio: "inherit" });
430 console.log("Opening Simulator app...");
431 execFileSync("open", ["-a", "Simulator"], { stdio: "inherit" });
432 console.log("✓ iOS app launched on simulator");
433 } else {
434 console.log("No simulators found. Open Xcode to create one: open ios/LChat.xcodeproj");
435 }
436 } catch (err) {
437 console.log("Could not boot simulator. Opening Xcode instead: open ios/LChat.xcodeproj");
438 execFileSync("open", [path.join(IOS_DIR, "LChat.xcodeproj")], { stdio: "inherit" });
439 }
440 }
441 }
442 },
443
444 test: () => {
445 // Both suites run here, so there's no unambiguous target for extra args —
446 // say so rather than silently handing Go's flags to npm (or vice versa).
447 if (cmdArgs.length) {
448 console.error("Extra arguments need a specific suite: ./tools/dev test go|web <args>");
449 process.exit(1);
450 }
451 console.log("Running full test suite (Go + Node)...");
452 run("go", ["test", "./..."], { cwd: REPO_ROOT });
453 run("npm", ["test"], { cwd: REPO_ROOT });
454 console.log("✓ All tests passed");
455 },
456
457 "test web": () => {
458 // The npm "test" script is a long `&&` chain, so `npm test -- args` would
459 // hand them to the LAST file only — silently not what anyone means. Run a
460 // single suite by invoking node directly instead.
461 if (cmdArgs.length) {
462 console.error("npm test takes no arguments (it chains every suite).");
463 console.error("Run one suite directly, e.g.: node test/format.test.mjs");
464 process.exit(1);
465 }
466 console.log("Running web (Node) tests...");
467 run("npm", ["test"], { cwd: REPO_ROOT });
468 console.log("✓ Web tests passed");
469 },
470
471 "test go": () => {
472 console.log("Running Go tests...");
473 // e.g. ./tools/dev test go -run TestFoo — flags alone still mean the whole
474 // tree, so keep ./... unless the caller named packages themselves (bare
475 // `go test -run X` would quietly test only the repo-root package).
476 const named = cmdArgs.some((a) => !a.startsWith("-") && (a.includes("/") || a === "."));
477 run("go", ["test", ...cmdArgs, ...(named ? [] : ["./..."])], { cwd: REPO_ROOT });
478 console.log("✓ Go tests passed");
479 },
480
481 // Marketing-screenshot kit — a family of verbs under one command; the
482 // sub-verb (up/tunnel/mac/web-check) is cmdArgs[0]. shotsCmd is a hoisted
483 // function declaration below.
484 shots: () => shotsCmd(cmdArgs),
485};
486
487// Side-by-side dev alt of the Mac app (CLAUDE.md § Dev CLI): the same
488// LChatMac target built under an alternate identity — bundle id suffix
489// (.alt-<slug>, so it gets its own sandbox container + Launch Services
490// identity and runs BESIDE the Sparkle-updated app), "Eleven Dev" naming,
491// the orange dev icon, and Sparkle auto-checks off. The keychain access
492// group is unchanged, so the alt sees the same signed-in spaces — it is the
493// REAL account, not a sandbox. One running app per slug; parallel sessions
494// pick different slugs (dev, dev2, …) like web canaries. First build of a
495// new slug mints its App ID/profile via -allowProvisioningUpdates.
496//
497// Point an alt at the DEV home server (Debug-only, LChatCore HomeBase; the
498// per-bundle-id defaults domain means each alt keeps its own aim — takes
499// effect on next launch, delete to go back to production):
500// defaults write com.elevenmessenger.messenger.mac.alt-<slug> \
501// home_base_override https://home.dev.elevenmessenger.com
502async function altMac(args) {
503 // --icon live picks the green set (the convention: green = a dev build
504 // aimed at PRODUCTION home), so two alts with different aims never share
505 // an icon. Default stays the orange AppIconDev.
506 const ICONS = { dev: "AppIconDev", live: "AppIconDevLive" };
507 const iconAt = args.indexOf("--icon");
508 const iconKey = iconAt >= 0 ? args[iconAt + 1] : "dev";
509 if (!ICONS[iconKey]) {
510 console.error(`Bad --icon — expected: ${Object.keys(ICONS).join(", ")}`);
511 process.exit(1);
512 }
513 // Only skip the --icon VALUE when --icon is actually present: with iconAt
514 // at -1, `iconAt + 1` is 0 — which silently ate the slug ("alt dek" built
515 // the default dev alt).
516 const iconValAt = iconAt >= 0 ? iconAt + 1 : -1;
517 const rawSlug = args.filter((a, i) => !a.startsWith("--") && i !== iconValAt)[0];
518 const slug = (rawSlug || "dev").toLowerCase().replace(/[^a-z0-9-]/g, "");
519 if (!slug) {
520 console.error("Bad slug — letters/digits/hyphens only.");
521 process.exit(1);
522 }
523 const name = slug === "dev" ? "Eleven Dev" : `Eleven Dev ${slug[0].toUpperCase()}${slug.slice(1)}`;
524 const suffix = `.alt-${slug}`;
525 if (needsXcodegen()) {
526 console.log("Generating Xcode project...");
527 run("xcodegen", ["generate"], { cwd: IOS_DIR });
528 }
529 const derived = path.join(IOS_DIR, `build-alt-${slug}`);
530 console.log(`Building ${name} (…messenger.mac${suffix})...`);
531 const result = spawnSync("xcodebuild", [
532 "-project", path.join(IOS_DIR, "LChat.xcodeproj"),
533 "-scheme", "LChatMac",
534 "-destination", "generic/platform=macOS",
535 "-derivedDataPath", derived,
536 "-allowProvisioningUpdates",
537 `LCHAT_MAC_ALT_SUFFIX=${suffix}`,
538 `LCHAT_MAC_DISPLAY_NAME=${name}`,
539 `LCHAT_MAC_APPICON=${ICONS[iconKey]}`,
540 "LCHAT_MAC_SPARKLE_AUTOCHECK=NO",
541 "build",
542 ], { cwd: IOS_DIR, encoding: "utf8", env: goEnv(), maxBuffer: 64 * 1024 * 1024 });
543 if (result.status !== 0) {
544 console.error("Build failed!");
545 if (result.error) console.error(String(result.error));
546 const errs = (result.stdout || "").split("\n").filter((l) => l.includes("error:"));
547 console.error(errs.slice(-20).join("\n") || result.stderr || "");
548 process.exit(result.status ?? 1);
549 }
550 // Relaunch cleanly on rebuild: quit this slug's running copy (matched by
551 // its per-slug build path, so other alts and the real app are untouched).
552 spawnSync("pkill", ["-f", path.join(derived, "Build", "Products", "Debug")]);
553 const appPath = path.join(derived, "Build", "Products", "Debug", `${name}.app`);
554 console.log(`Launching ${appPath}`);
555 run("open", [appPath]);
556}
557
558// ── Marketing-screenshot kit (docs/marketing-screenshots.md) ─────────────────
559// Stands up a fictional demo space ("The Commons") for capturing native Mac +
560// iPhone shots for the website hero collage. The implementation scripts live in
561// tools/marketing-shots/ (mirroring how `up` wraps tools/lchat-dev-up.mjs); this
562// dispatcher wraps them and owns the ISOLATED Mac build.
563const SHOTS_DIR = path.join(REPO_ROOT, "tools", "marketing-shots");
564const DEMO_ENTITLEMENTS = path.join(IOS_DIR, "LChatMac", "LChatMac-Demo.entitlements");
565const DEMO_BUNDLE_ID = "com.elevenmessenger.messenger.mac.capdemo";
566const DEMO_DISPLAY_NAME = "Eleven Demo";
567const DEMO_DERIVED = path.join(IOS_DIR, "build-demo");
568const DEMO_APP = path.join(DEMO_DERIVED, "Build", "Products", "Debug", `${DEMO_DISPLAY_NAME}.app`);
569
570// Build the ISOLATED demo Mac app. The isolation + demo identity ride entirely
571// on COMMAND-LINE build settings so project.yml is NEVER touched — a normal
572// `./tools/dev build mac` keeps the real LChatMac.entitlements (shared keychain
573// group → reads the real signed-in spaces) and the real bundle id. Command-line
574// settings outrank the target/config values in the .pbxproj, so here they swap
575// in the minimal LChatMac-Demo.entitlements (own EMPTY keychain — can never read
576// Paul's real spaces) and a distinct .capdemo bundle id (own sandbox container +
577// Launch Services identity, runs beside the real app). The real purple AppIcon
578// is kept on purpose so captures show the real brand mark. Returns true on
579// success. Verify isolation any time:
580// codesign -d --entitlements :- "<…>/Eleven Demo.app" | grep -i keychain # → no output
581function buildDemoMac() {
582 if (needsXcodegen()) {
583 console.log("Generating Xcode project...");
584 run("xcodegen", ["generate"], { cwd: IOS_DIR });
585 }
586 console.log(`Building ${DEMO_DISPLAY_NAME} (isolated demo identity, ${DEMO_BUNDLE_ID})...`);
587 const result = spawnSync("xcodebuild", [
588 "-project", path.join(IOS_DIR, "LChat.xcodeproj"),
589 "-scheme", "LChatMac",
590 "-configuration", "Debug",
591 "-destination", "generic/platform=macOS",
592 "-derivedDataPath", DEMO_DERIVED,
593 "-allowProvisioningUpdates",
594 "CODE_SIGN_STYLE=Automatic",
595 "DEVELOPMENT_TEAM=J5NDF5PCD8",
596 // Command-line overrides (highest precedence): swap in the isolated identity
597 // without editing project.yml. Absolute entitlements path so xcodebuild
598 // resolves it regardless of cwd.
599 `CODE_SIGN_ENTITLEMENTS=${DEMO_ENTITLEMENTS}`,
600 `PRODUCT_BUNDLE_IDENTIFIER=${DEMO_BUNDLE_ID}`,
601 `LCHAT_MAC_DISPLAY_NAME=${DEMO_DISPLAY_NAME}`,
602 "LCHAT_MAC_SPARKLE_AUTOCHECK=NO",
603 "build",
604 ], { cwd: IOS_DIR, encoding: "utf8", env: goEnv(), maxBuffer: 64 * 1024 * 1024 });
605 if (result.status !== 0) {
606 console.error("Demo Mac build failed!");
607 if (result.error) console.error(String(result.error));
608 const errs = (result.stdout || "").split("\n").filter((l) => l.includes("error:"));
609 console.error(errs.slice(-20).join("\n") || result.stderr || "");
610 return false;
611 }
612 console.log(`✓ ${DEMO_DISPLAY_NAME}.app built (${DEMO_APP})`);
613 return true;
614}
615
616// Build the demo Mac app when it's missing or stale. `soft`: on failure warn +
617// return false instead of exiting (so `shots up` still stands up the web demo).
618function ensureDemoMacApp({ soft = false } = {}) {
619 const need = needsXcodegen() || !existsSync(DEMO_APP) || needsBuild(DEMO_APP);
620 if (!need) {
621 console.log(`${DEMO_DISPLAY_NAME}.app is up-to-date`);
622 return true;
623 }
624 const ok = buildDemoMac();
625 if (!ok) {
626 if (!soft) process.exit(1);
627 console.warn(`⚠ ${DEMO_DISPLAY_NAME}.app didn't build — the web demo will still come up.`);
628 console.warn(` Retry the native build later with: ./tools/dev shots mac`);
629 }
630 return ok;
631}
632
633function shotsUsage() {
634 console.log("shots — marketing-screenshot kit (docs/marketing-screenshots.md)\n");
635 console.log("Usage:");
636 console.log(" ./tools/dev shots up Build the demo Mac app (if needed) + run a");
637 console.log(" throwaway local server seeded with \"The Commons\"");
638 console.log(" (foreground; Ctrl-C stops it)");
639 console.log(" ./tools/dev shots tunnel Give it a public https try-*.11relay.com address");
640 console.log(" + single-use Mac & iPhone sign-in links");
641 console.log(" ./tools/dev shots mac Launch the isolated \"Eleven Demo\" Mac app");
642 console.log(" ./tools/dev shots web-check <png> Headless 2x screenshot of the seeded space");
643 console.log("\nInteractive Mac/iPhone captures are MANUAL — see docs/marketing-screenshots.md.");
644}
645
646async function shotsCmd(args) {
647 const verb = args[0] || "help";
648 const rest = args.slice(1);
649 switch (verb) {
650 case "up":
651 // Ensure the native app is ready (soft: a provisioning hiccup shouldn't
652 // block the web demo), then hand off to the foreground server+seed script.
653 ensureDemoMacApp({ soft: true });
654 await runAsync("bash", [path.join(SHOTS_DIR, "serve.sh"), ...rest]);
655 break;
656 case "tunnel":
657 await runAsync("bash", [path.join(SHOTS_DIR, "tunnel.sh"), ...rest]);
658 break;
659 case "mac":
660 ensureDemoMacApp({ soft: false });
661 console.log(`Launching ${DEMO_APP}`);
662 run("open", [DEMO_APP]);
663 break;
664 case "web-check":
665 await runAsync("node", [path.join(SHOTS_DIR, "web-check.mjs"), ...rest]);
666 break;
667 case "help":
668 shotsUsage();
669 break;
670 default:
671 console.error(`Unknown shots verb: ${verb}`);
672 shotsUsage();
673 process.exit(1);
674 }
675}
676
677// Command names are one or two bare words ("up", "build mac"); everything
678// after them belongs to the command. Match on the words alone — joining the
679// whole argv made any flag part of the lookup key, so `up --clean` fell
680// through to "Unknown command" instead of reaching up's pass-through.
681const argv = process.argv.slice(2);
682const words = [];
683for (const a of argv) {
684 if (a.startsWith("-")) break;
685 words.push(a);
686 if (words.length === 2) break;
687}
688const cmd = commands[words.join(" ")] ? words.join(" ") : words[0] || "";
689const cmdArgs = argv.slice(cmd ? cmd.split(" ").length : 0);
690// `-h`/`--help` is ours only when it stands alone — after a command word it
691// belongs to that command (`up --help` prints the dev server's own usage).
692const wantsHelp = cmd === "help" || (!cmd && argv.some((a) => a === "-h" || a === "--help"));
693
694if (!cmd || wantsHelp) {
695 console.log("dev — messenger dev CLI\n");
696 console.log("Usage:");
697 console.log(" ./tools/dev build mac Build native Mac app");
698 console.log(" ./tools/dev build ios Build native iOS app");
699 console.log(" ./tools/dev run mac Launch the Mac app");
700 console.log(" ./tools/dev alt [slug] [--icon live] Build + launch a side-by-side dev alt");
701 console.log(" (orange icon; --icon live = green, the");
702 console.log(" convention for an alt aimed at prod home)");
703 console.log(" ./tools/dev run ios Run iOS app on simulator");
704 console.log(" ./tools/dev install ios [id] Build + install + launch on a real iPhone");
705 console.log(" (over the network; LCHAT_IOS_DEVICE also sets it)");
706 console.log(" ./tools/dev up [flags] Start local dev server (flags go to");
707 console.log(" lchat-dev-up: --clean, --onboarding,");
708 console.log(" --no-hooks, --port N, --help)");
709 console.log(" ./tools/dev seed-mac Seed Mac app with dev credentials");
710 console.log(" ./tools/dev seed-ios Seed iOS app with dev credentials");
711 console.log(" ./tools/dev link Print dev server sign-in link");
712 console.log(" ./tools/dev test Run all tests");
713 console.log(" ./tools/dev test web Run web tests only");
714 console.log(" ./tools/dev test go [args] Run Go tests only (args go to go test,");
715 console.log(" e.g. -run TestFoo ./internal/bot)");
716 console.log(" ./tools/dev shots <verb> Marketing-screenshot kit: up | tunnel | mac |");
717 console.log(" web-check <png> (docs/marketing-screenshots.md)");
718 console.log("");
719 process.exit(wantsHelp ? 0 : 1);
720}
721
722if (cmd === "alt") {
723 Promise.resolve(altMac(cmdArgs)).catch((e) => {
724 console.error(`Error: ${e.message}`);
725 process.exit(1);
726 });
727} else if (commands[cmd]) {
728 // Some commands are sync (build/test) and return undefined — wrap, so the
729 // failure handler works for both instead of crashing on `.catch`.
730 Promise.resolve(commands[cmd]()).catch((e) => {
731 console.error(`Error: ${e.message}`);
732 process.exit(1);
733 });
734} else {
735 console.error(`Unknown command: ${cmd}`);
736 console.error(`Run './tools/dev help' for available commands.`);
737 process.exit(1);
738}
739