eleven / messenger Public

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

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