#!/usr/bin/env node // dev — simple CLI for common dev tasks on the messenger project. // // Usage: // ./tools/dev build mac Build the native Mac app (LChatMac scheme) // ./tools/dev build ios Build the native iOS app (LChat scheme) // ./tools/dev run mac Launch the Mac app // ./tools/dev alt [slug] Build + launch a side-by-side dev alt of the Mac app // ./tools/dev run ios Run the iOS app on simulator (see instructions) // ./tools/dev install ios [id] Build + install + launch on a real iPhone (network, // no cable); id defaults to the first paired device, // or LCHAT_IOS_DEVICE // ./tools/dev up [flags] Start the local dev server (persistent, reusable config) // + the webhook-ingest sidecar. Flags after the command // pass straight to tools/lchat-dev-up.mjs: --clean, // --onboarding, --no-hooks, --port N, --help. // ./tools/dev seed-mac Seed the Mac app with dev server credentials // ./tools/dev seed-ios Seed the iOS app with dev server credentials // ./tools/dev link Print the sign-in link for the dev server // ./tools/dev test Run the test suite (Go + Node) // ./tools/dev test web Run web tests only // ./tools/dev test go [args] Run Go tests only (args forwarded to go test, // e.g. -run TestFoo ./internal/bot) // ./tools/dev shots Marketing-screenshot kit (docs/marketing-screenshots.md): // up | tunnel | mac | web-check . Stands up an // isolated, fictional demo space ("The Commons") for // native Mac + iPhone captures. Interactive captures // stay manual — see the doc. // // All commands assume you're at the repo root. Use ./tools/dev to invoke from anywhere. import { spawn, spawnSync, execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import path from "node:path"; import { readFileSync, statSync, existsSync } from "node:fs"; import { readConfig } from "./dev-config.mjs"; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const IOS_DIR = path.join(REPO_ROOT, "ios"); // Check if project needs regeneration (project.yml changed since .xcodeproj was generated) function needsXcodegen() { const projectYml = path.join(IOS_DIR, "project.yml"); const xcodeproj = path.join(IOS_DIR, "LChat.xcodeproj"); if (!existsSync(xcodeproj)) return true; try { const ymlStat = statSync(projectYml); const projStat = statSync(xcodeproj); return ymlStat.mtime > projStat.mtime; } catch { return true; } } // The first physical iPhone devicectl reports as usable — what `install ios` // targets when no id is given. Simulators and the header row are skipped. function firstPairediPhone() { try { const out = execFileSync("xcrun", ["devicectl", "list", "devices"], { encoding: "utf8" }); for (const line of out.split("\n")) { if (!/(available|connected)/i.test(line)) continue; if (!/physical/i.test(line) || /simulated/i.test(line)) continue; 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); if (id) return id[1]; } } catch { // devicectl unavailable / no devices — the caller prints the hint. } return null; } // The app's bundle id, read from project.yml's one knob (LCHAT_APP_BUNDLE_ID) // so an overridden id still launches the thing we just installed. function bundleIdFromProject() { try { const yml = readFileSync(path.join(IOS_DIR, "project.yml"), "utf8"); const hit = yml.match(/LCHAT_APP_BUNDLE_ID:\s*(\S+)/); if (hit) return hit[1]; } catch { // fall through to the shipped default } return "com.elevenmessenger.messenger"; } // Check if build needs to be redone (source files changed since app was built) function needsBuild(appPath) { if (!existsSync(appPath)) return true; try { const appStat = statSync(appPath); // Check modification times of key source directories const sourceDir = IOS_DIR; const srcStat = statSync(sourceDir); return srcStat.mtime > appStat.mtime; } catch { return true; } } // The Mac target's Go build phase needs GOTOOLCHAIN=auto (go-toolchain // pinning otherwise fails the build under Xcode's environment). function goEnv() { return { ...process.env, GOTOOLCHAIN: process.env.GOTOOLCHAIN ?? "auto" }; } function run(cmd, args, opts = {}) { const result = spawnSync(cmd, args, { cwd: REPO_ROOT, stdio: "inherit", ...opts }); if (result.error) throw result.error; if (result.status !== 0) process.exit(result.status); } function runAsync(cmd, args, opts = {}) { return new Promise((resolve, reject) => { const proc = spawn(cmd, args, { cwd: REPO_ROOT, stdio: "inherit", ...opts }); proc.on("error", reject); proc.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`exited with code ${code}`)))); }); } const commands = { "build mac": () => { if (needsXcodegen()) { console.log("Generating Xcode project..."); run("xcodegen", ["generate"], { cwd: IOS_DIR }); } else { console.log("Xcode project up-to-date"); } console.log("Building LChatMac (native macOS app)..."); const result = spawnSync("xcodebuild", [ "-project", path.join(IOS_DIR, "LChat.xcodeproj"), "-scheme", "LChatMac", "-destination", "generic/platform=macOS", "-derivedDataPath", path.join(IOS_DIR, "build"), "build", ], { cwd: IOS_DIR, encoding: "utf8", env: goEnv(), maxBuffer: 64 * 1024 * 1024 }); if (result.status !== 0) { console.error("Build failed!"); // A spawn-level failure (e.g. ENOBUFS when output outgrows maxBuffer) // carries no build output — name it, or nothing at all gets printed. if (result.error) console.error(String(result.error)); // xcodebuild reports errors on STDOUT — stderr alone is usually empty. const errs = (result.stdout || "").split("\n").filter((l) => l.includes("error:")); console.error(errs.slice(-20).join("\n") || result.stderr || ""); process.exit(result.status ?? 1); } console.log("✓ Mac app built successfully"); }, "build ios": () => { if (needsXcodegen()) { console.log("Generating Xcode project..."); run("xcodegen", ["generate"], { cwd: IOS_DIR }); } else { console.log("Xcode project up-to-date"); } console.log("Building LChat (native iOS app)..."); run("xcodebuild", [ "-project", path.join(IOS_DIR, "LChat.xcodeproj"), "-scheme", "LChat", "-destination", "generic/platform=iOS", "-derivedDataPath", path.join(IOS_DIR, "build"), "build", ], { cwd: IOS_DIR }); console.log("✓ iOS app built successfully"); }, // 🤖 Build + install + launch on a REAL iPhone, over the network tunnel // devicectl opens (no cable: the phone just has to be unlocked, trusted and // on the same Wi-Fi). Encoded here rather than living in an operator's // private notes, so any machine can put a dev build on a device. // ./tools/dev install ios [device-id] (or LCHAT_IOS_DEVICE=) // Signing is Automatic + -allowProvisioningUpdates: a new device must // already be registered with the team before a profile can be minted. // If xcodebuild says "iOS is not installed", point DEVELOPER_DIR at an // Xcode that carries that platform (see docs/native-chat.md). "install ios": () => { if (needsXcodegen()) { console.log("Generating Xcode project..."); run("xcodegen", ["generate"], { cwd: IOS_DIR }); } const device = cmdArgs[0] || process.env.LCHAT_IOS_DEVICE || firstPairediPhone(); if (!device) { console.error("No paired iPhone found. Unlock it, trust this Mac, same Wi-Fi;"); console.error("`xcrun devicectl list devices` should show it as available."); process.exit(1); } console.log(`Building LChat for device ${device}...`); const derived = path.join(IOS_DIR, "build"); run("xcodebuild", [ "-project", path.join(IOS_DIR, "LChat.xcodeproj"), "-scheme", "LChat", "-destination", "generic/platform=iOS", "-derivedDataPath", derived, "-allowProvisioningUpdates", "CODE_SIGN_STYLE=Automatic", "build", ], { cwd: IOS_DIR }); const app = path.join(derived, "Build/Products/Debug-iphoneos/LChat.app"); // The first reach for a sleeping tunnel often comes back "Connection reset // by peer"; the immediate retry lands. Only a second failure is real. console.log("Installing..."); const install = () => spawnSync("xcrun", ["devicectl", "device", "install", "app", "--device", device, app], { cwd: REPO_ROOT, stdio: "inherit" }); if (install().status !== 0) { console.log("Device tunnel didn't answer — retrying once..."); if (install().status !== 0) { console.error("Install failed. Unlock the phone and make sure it's on this Wi-Fi;"); console.error("opening Xcode → Window → Devices and Simulators (⇧⌘2) kicks the tunnel."); process.exit(1); } } console.log("Launching..."); run("xcrun", ["devicectl", "device", "process", "launch", "--device", device, bundleIdFromProject()]); console.log("✓ Installed and launched on the device"); }, "up": async () => { console.log("Starting dev server (lchat-dev-up)..."); // Pass flags through (--clean, --onboarding, --no-hooks, --port N, …). await runAsync("node", ["tools/lchat-dev-up.mjs", ...cmdArgs]); }, "down": () => { console.log("Stop the dev server by pressing Ctrl-C in its terminal."); process.exit(0); }, "seed-mac": async () => { const cfg = readConfig(); if (!cfg) { console.error("No dev server config found. Run: ./tools/dev up"); process.exit(1); } if (cfg.mode === "onboarding") { console.error("Error: dev server is in onboarding mode (no 'you' account)."); console.error("Reset it in group mode (the default):"); console.error(" ./tools/dev up --clean"); process.exit(1); } console.log("Seeding Mac app with dev server credentials..."); await runAsync("node", ["tools/lchat-dev-mac.mjs"]); console.log("✓ Mac app seeded. Launch it from Xcode or Applications."); }, "seed-ios": async () => { const cfg = readConfig(); if (!cfg) { console.error("No dev server config found. Run: ./tools/dev up"); process.exit(1); } console.log("Seeding iOS app with dev server credentials..."); console.log("(iOS seeding not yet implemented — use the web app or open the invite link)"); console.log(`Invite link: ${cfg.inviteURL || "Not available"}`); process.exit(1); }, "link": async () => { const cfg = readConfig(); if (!cfg) { console.error("No dev server config found. Run: ./tools/dev up"); process.exit(1); } if (cfg.inviteURL) { console.log(cfg.inviteURL); } else { console.log("Server running at:", cfg.base); console.log("Admin at:", cfg.adminURL); } }, "run mac": async () => { // Find the build output directory. Must pass the same -derivedDataPath // "build mac" builds with — without it xcodebuild reports the default // DerivedData location, where the app was never built, and launch fails. const macSettingsArgs = [ "-project", path.join(IOS_DIR, "LChat.xcodeproj"), "-scheme", "LChatMac", "-destination", "generic/platform=macOS", "-derivedDataPath", path.join(IOS_DIR, "build"), "-showBuildSettings", ]; let buildSettingsOutput; try { buildSettingsOutput = execFileSync("xcodebuild", macSettingsArgs, { cwd: IOS_DIR, encoding: "utf8" }); } catch (e) { console.log("Build settings lookup failed. Building first..."); await commands["build mac"](); buildSettingsOutput = execFileSync("xcodebuild", macSettingsArgs, { cwd: IOS_DIR, encoding: "utf8" }); } const builtProductsDir = buildSettingsOutput .split("\n") .find(line => line.includes("BUILT_PRODUCTS_DIR")) ?.split("=")[1] ?.trim(); if (!builtProductsDir) { console.error("Could not determine build output directory"); process.exit(1); } const appPath = path.join(builtProductsDir, "Eleven.app"); // Check if app needs to be rebuilt if (needsXcodegen()) { console.log("Source files changed. Rebuilding..."); await commands["build mac"](); } else if (needsBuild(appPath)) { console.log("Source files changed. Rebuilding..."); await commands["build mac"](); } else if (!existsSync(appPath)) { console.log("App not found. Building..."); await commands["build mac"](); } else { console.log("App is up-to-date"); } console.log("Launching LChatMac..."); execFileSync("open", [appPath], { stdio: "inherit" }); }, "run ios": async () => { // Check for connected/available devices (USB, network, or paired) let connectedDevices = []; try { const devicesOutput = execFileSync("xcrun", ["devicectl", "list", "devices"], { encoding: "utf8" }); const lines = devicesOutput.split("\n"); connectedDevices = lines .filter(line => { // Match lines with device states like "available (paired)" or "Connected" return /(available|Connected)/i.test(line) && !line.includes("Simulator") && !line.includes("State"); }) .map(line => { // Extract UUID (format: 8-4-4-4-12 hex digits with hyphens) 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); return match ? match[1] : null; }) .filter(Boolean); } catch (e) { // devicectl not available or no devices } const useRealDevice = connectedDevices.length > 0; const destination = useRealDevice ? "generic/platform=iOS" : "generic/platform=iOS Simulator"; if (needsXcodegen()) { console.log("Generating Xcode project..."); run("xcodegen", ["generate"], { cwd: IOS_DIR }); } else { console.log("Xcode project up-to-date"); } console.log(`Building iOS app for ${useRealDevice ? "device" : "simulator"}...`); run("xcodebuild", [ "-project", path.join(IOS_DIR, "LChat.xcodeproj"), "-scheme", "LChat", "-destination", destination, "-derivedDataPath", path.join(IOS_DIR, "build"), "build", ], { cwd: IOS_DIR }); if (useRealDevice) { // Install on real device const deviceId = connectedDevices[0]; const appPath = path.join(IOS_DIR, "build/Build/Products/Debug-iphoneos/LChat.app"); if (!existsSync(appPath)) { console.error("Built app not found at:", appPath); process.exit(1); } try { console.log(`Installing on device ${deviceId}...`); execFileSync("xcrun", ["devicectl", "device", "install", "app", "--device", deviceId, appPath], { stdio: "inherit" }); console.log("Launching app..."); execFileSync("xcrun", ["devicectl", "device", "process", "launch", "--device", deviceId, "com.elevenmessenger.messenger"], { stdio: "inherit" }); console.log("✓ iOS app installed and launched on device"); } catch (e) { console.error("Failed to install on device:", e.message); process.exit(1); } } else { // Install on simulator const appPath = path.join(IOS_DIR, "build/Build/Products/Debug-iphonesimulator/LChat.app"); if (!existsSync(appPath)) { console.error("Built app not found at:", appPath); process.exit(1); } // Try to install and launch on booted simulator, or boot one if needed try { console.log("Installing on booted simulator..."); execFileSync("xcrun", ["simctl", "install", "booted", appPath], { stdio: "inherit" }); console.log("Launching app..."); execFileSync("xcrun", ["simctl", "launch", "booted", "com.elevenmessenger.messenger"], { stdio: "inherit" }); console.log("✓ iOS app launched on simulator"); } catch (e) { // No booted simulator; try to find and boot one console.log("No booted simulator. Looking for available simulators..."); try { const listOutput = execFileSync("xcrun", ["simctl", "list", "devices", "iPhone"], { encoding: "utf8" }); // Extract first available iPhone simulator UDID const match = listOutput.match(/\(([A-F0-9\-]+)\)/); if (match) { const udid = match[1]; console.log(`Booting simulator ${udid}...`); execFileSync("xcrun", ["simctl", "boot", udid], { stdio: "inherit" }); // Wait a moment for simulator to boot await new Promise(r => setTimeout(r, 2000)); console.log("Installing app..."); execFileSync("xcrun", ["simctl", "install", udid, appPath], { stdio: "inherit" }); console.log("Launching app..."); execFileSync("xcrun", ["simctl", "launch", udid, "com.elevenmessenger.messenger"], { stdio: "inherit" }); console.log("Opening Simulator app..."); execFileSync("open", ["-a", "Simulator"], { stdio: "inherit" }); console.log("✓ iOS app launched on simulator"); } else { console.log("No simulators found. Open Xcode to create one: open ios/LChat.xcodeproj"); } } catch (err) { console.log("Could not boot simulator. Opening Xcode instead: open ios/LChat.xcodeproj"); execFileSync("open", [path.join(IOS_DIR, "LChat.xcodeproj")], { stdio: "inherit" }); } } } }, test: () => { // Both suites run here, so there's no unambiguous target for extra args — // say so rather than silently handing Go's flags to npm (or vice versa). if (cmdArgs.length) { console.error("Extra arguments need a specific suite: ./tools/dev test go|web "); process.exit(1); } console.log("Running full test suite (Go + Node)..."); run("go", ["test", "./..."], { cwd: REPO_ROOT }); run("npm", ["test"], { cwd: REPO_ROOT }); console.log("✓ All tests passed"); }, "test web": () => { // The npm "test" script is a long `&&` chain, so `npm test -- args` would // hand them to the LAST file only — silently not what anyone means. Run a // single suite by invoking node directly instead. if (cmdArgs.length) { console.error("npm test takes no arguments (it chains every suite)."); console.error("Run one suite directly, e.g.: node test/format.test.mjs"); process.exit(1); } console.log("Running web (Node) tests..."); run("npm", ["test"], { cwd: REPO_ROOT }); console.log("✓ Web tests passed"); }, "test go": () => { console.log("Running Go tests..."); // e.g. ./tools/dev test go -run TestFoo — flags alone still mean the whole // tree, so keep ./... unless the caller named packages themselves (bare // `go test -run X` would quietly test only the repo-root package). const named = cmdArgs.some((a) => !a.startsWith("-") && (a.includes("/") || a === ".")); run("go", ["test", ...cmdArgs, ...(named ? [] : ["./..."])], { cwd: REPO_ROOT }); console.log("✓ Go tests passed"); }, // Marketing-screenshot kit — a family of verbs under one command; the // sub-verb (up/tunnel/mac/web-check) is cmdArgs[0]. shotsCmd is a hoisted // function declaration below. shots: () => shotsCmd(cmdArgs), }; // Side-by-side dev alt of the Mac app (CLAUDE.md § Dev CLI): the same // LChatMac target built under an alternate identity — bundle id suffix // (.alt-, so it gets its own sandbox container + Launch Services // identity and runs BESIDE the Sparkle-updated app), "Eleven Dev" naming, // the orange dev icon, and Sparkle auto-checks off. The keychain access // group is unchanged, so the alt sees the same signed-in spaces — it is the // REAL account, not a sandbox. One running app per slug; parallel sessions // pick different slugs (dev, dev2, …) like web canaries. First build of a // new slug mints its App ID/profile via -allowProvisioningUpdates. // // Point an alt at the DEV home server (Debug-only, LChatCore HomeBase; the // per-bundle-id defaults domain means each alt keeps its own aim — takes // effect on next launch, delete to go back to production): // defaults write com.elevenmessenger.messenger.mac.alt- \ // home_base_override https://home.dev.elevenmessenger.com async function altMac(args) { // --icon live picks the green set (the convention: green = a dev build // aimed at PRODUCTION home), so two alts with different aims never share // an icon. Default stays the orange AppIconDev. const ICONS = { dev: "AppIconDev", live: "AppIconDevLive" }; const iconAt = args.indexOf("--icon"); const iconKey = iconAt >= 0 ? args[iconAt + 1] : "dev"; if (!ICONS[iconKey]) { console.error(`Bad --icon — expected: ${Object.keys(ICONS).join(", ")}`); process.exit(1); } // Only skip the --icon VALUE when --icon is actually present: with iconAt // at -1, `iconAt + 1` is 0 — which silently ate the slug ("alt dek" built // the default dev alt). const iconValAt = iconAt >= 0 ? iconAt + 1 : -1; const rawSlug = args.filter((a, i) => !a.startsWith("--") && i !== iconValAt)[0]; const slug = (rawSlug || "dev").toLowerCase().replace(/[^a-z0-9-]/g, ""); if (!slug) { console.error("Bad slug — letters/digits/hyphens only."); process.exit(1); } const name = slug === "dev" ? "Eleven Dev" : `Eleven Dev ${slug[0].toUpperCase()}${slug.slice(1)}`; const suffix = `.alt-${slug}`; if (needsXcodegen()) { console.log("Generating Xcode project..."); run("xcodegen", ["generate"], { cwd: IOS_DIR }); } const derived = path.join(IOS_DIR, `build-alt-${slug}`); console.log(`Building ${name} (…messenger.mac${suffix})...`); const result = spawnSync("xcodebuild", [ "-project", path.join(IOS_DIR, "LChat.xcodeproj"), "-scheme", "LChatMac", "-destination", "generic/platform=macOS", "-derivedDataPath", derived, "-allowProvisioningUpdates", `LCHAT_MAC_ALT_SUFFIX=${suffix}`, `LCHAT_MAC_DISPLAY_NAME=${name}`, `LCHAT_MAC_APPICON=${ICONS[iconKey]}`, "LCHAT_MAC_SPARKLE_AUTOCHECK=NO", "build", ], { cwd: IOS_DIR, encoding: "utf8", env: goEnv(), maxBuffer: 64 * 1024 * 1024 }); if (result.status !== 0) { console.error("Build failed!"); if (result.error) console.error(String(result.error)); const errs = (result.stdout || "").split("\n").filter((l) => l.includes("error:")); console.error(errs.slice(-20).join("\n") || result.stderr || ""); process.exit(result.status ?? 1); } // Relaunch cleanly on rebuild: quit this slug's running copy (matched by // its per-slug build path, so other alts and the real app are untouched). spawnSync("pkill", ["-f", path.join(derived, "Build", "Products", "Debug")]); const appPath = path.join(derived, "Build", "Products", "Debug", `${name}.app`); console.log(`Launching ${appPath}`); run("open", [appPath]); } // ── Marketing-screenshot kit (docs/marketing-screenshots.md) ───────────────── // Stands up a fictional demo space ("The Commons") for capturing native Mac + // iPhone shots for the website hero collage. The implementation scripts live in // tools/marketing-shots/ (mirroring how `up` wraps tools/lchat-dev-up.mjs); this // dispatcher wraps them and owns the ISOLATED Mac build. const SHOTS_DIR = path.join(REPO_ROOT, "tools", "marketing-shots"); const DEMO_ENTITLEMENTS = path.join(IOS_DIR, "LChatMac", "LChatMac-Demo.entitlements"); const DEMO_BUNDLE_ID = "com.elevenmessenger.messenger.mac.capdemo"; const DEMO_DISPLAY_NAME = "Eleven Demo"; const DEMO_DERIVED = path.join(IOS_DIR, "build-demo"); const DEMO_APP = path.join(DEMO_DERIVED, "Build", "Products", "Debug", `${DEMO_DISPLAY_NAME}.app`); // Build the ISOLATED demo Mac app. The isolation + demo identity ride entirely // on COMMAND-LINE build settings so project.yml is NEVER touched — a normal // `./tools/dev build mac` keeps the real LChatMac.entitlements (shared keychain // group → reads the real signed-in spaces) and the real bundle id. Command-line // settings outrank the target/config values in the .pbxproj, so here they swap // in the minimal LChatMac-Demo.entitlements (own EMPTY keychain — can never read // Paul's real spaces) and a distinct .capdemo bundle id (own sandbox container + // Launch Services identity, runs beside the real app). The real purple AppIcon // is kept on purpose so captures show the real brand mark. Returns true on // success. Verify isolation any time: // codesign -d --entitlements :- "<…>/Eleven Demo.app" | grep -i keychain # → no output function buildDemoMac() { if (needsXcodegen()) { console.log("Generating Xcode project..."); run("xcodegen", ["generate"], { cwd: IOS_DIR }); } console.log(`Building ${DEMO_DISPLAY_NAME} (isolated demo identity, ${DEMO_BUNDLE_ID})...`); const result = spawnSync("xcodebuild", [ "-project", path.join(IOS_DIR, "LChat.xcodeproj"), "-scheme", "LChatMac", "-configuration", "Debug", "-destination", "generic/platform=macOS", "-derivedDataPath", DEMO_DERIVED, "-allowProvisioningUpdates", "CODE_SIGN_STYLE=Automatic", "DEVELOPMENT_TEAM=J5NDF5PCD8", // Command-line overrides (highest precedence): swap in the isolated identity // without editing project.yml. Absolute entitlements path so xcodebuild // resolves it regardless of cwd. `CODE_SIGN_ENTITLEMENTS=${DEMO_ENTITLEMENTS}`, `PRODUCT_BUNDLE_IDENTIFIER=${DEMO_BUNDLE_ID}`, `LCHAT_MAC_DISPLAY_NAME=${DEMO_DISPLAY_NAME}`, "LCHAT_MAC_SPARKLE_AUTOCHECK=NO", "build", ], { cwd: IOS_DIR, encoding: "utf8", env: goEnv(), maxBuffer: 64 * 1024 * 1024 }); if (result.status !== 0) { console.error("Demo Mac build failed!"); if (result.error) console.error(String(result.error)); const errs = (result.stdout || "").split("\n").filter((l) => l.includes("error:")); console.error(errs.slice(-20).join("\n") || result.stderr || ""); return false; } console.log(`✓ ${DEMO_DISPLAY_NAME}.app built (${DEMO_APP})`); return true; } // Build the demo Mac app when it's missing or stale. `soft`: on failure warn + // return false instead of exiting (so `shots up` still stands up the web demo). function ensureDemoMacApp({ soft = false } = {}) { const need = needsXcodegen() || !existsSync(DEMO_APP) || needsBuild(DEMO_APP); if (!need) { console.log(`${DEMO_DISPLAY_NAME}.app is up-to-date`); return true; } const ok = buildDemoMac(); if (!ok) { if (!soft) process.exit(1); console.warn(`⚠ ${DEMO_DISPLAY_NAME}.app didn't build — the web demo will still come up.`); console.warn(` Retry the native build later with: ./tools/dev shots mac`); } return ok; } function shotsUsage() { console.log("shots — marketing-screenshot kit (docs/marketing-screenshots.md)\n"); console.log("Usage:"); console.log(" ./tools/dev shots up Build the demo Mac app (if needed) + run a"); console.log(" throwaway local server seeded with \"The Commons\""); console.log(" (foreground; Ctrl-C stops it)"); console.log(" ./tools/dev shots tunnel Give it a public https try-*.11relay.com address"); console.log(" + single-use Mac & iPhone sign-in links"); console.log(" ./tools/dev shots mac Launch the isolated \"Eleven Demo\" Mac app"); console.log(" ./tools/dev shots web-check Headless 2x screenshot of the seeded space"); console.log("\nInteractive Mac/iPhone captures are MANUAL — see docs/marketing-screenshots.md."); } async function shotsCmd(args) { const verb = args[0] || "help"; const rest = args.slice(1); switch (verb) { case "up": // Ensure the native app is ready (soft: a provisioning hiccup shouldn't // block the web demo), then hand off to the foreground server+seed script. ensureDemoMacApp({ soft: true }); await runAsync("bash", [path.join(SHOTS_DIR, "serve.sh"), ...rest]); break; case "tunnel": await runAsync("bash", [path.join(SHOTS_DIR, "tunnel.sh"), ...rest]); break; case "mac": ensureDemoMacApp({ soft: false }); console.log(`Launching ${DEMO_APP}`); run("open", [DEMO_APP]); break; case "web-check": await runAsync("node", [path.join(SHOTS_DIR, "web-check.mjs"), ...rest]); break; case "help": shotsUsage(); break; default: console.error(`Unknown shots verb: ${verb}`); shotsUsage(); process.exit(1); } } // Command names are one or two bare words ("up", "build mac"); everything // after them belongs to the command. Match on the words alone — joining the // whole argv made any flag part of the lookup key, so `up --clean` fell // through to "Unknown command" instead of reaching up's pass-through. const argv = process.argv.slice(2); const words = []; for (const a of argv) { if (a.startsWith("-")) break; words.push(a); if (words.length === 2) break; } const cmd = commands[words.join(" ")] ? words.join(" ") : words[0] || ""; const cmdArgs = argv.slice(cmd ? cmd.split(" ").length : 0); // `-h`/`--help` is ours only when it stands alone — after a command word it // belongs to that command (`up --help` prints the dev server's own usage). const wantsHelp = cmd === "help" || (!cmd && argv.some((a) => a === "-h" || a === "--help")); if (!cmd || wantsHelp) { console.log("dev — messenger dev CLI\n"); console.log("Usage:"); console.log(" ./tools/dev build mac Build native Mac app"); console.log(" ./tools/dev build ios Build native iOS app"); console.log(" ./tools/dev run mac Launch the Mac app"); console.log(" ./tools/dev alt [slug] [--icon live] Build + launch a side-by-side dev alt"); console.log(" (orange icon; --icon live = green, the"); console.log(" convention for an alt aimed at prod home)"); console.log(" ./tools/dev run ios Run iOS app on simulator"); console.log(" ./tools/dev install ios [id] Build + install + launch on a real iPhone"); console.log(" (over the network; LCHAT_IOS_DEVICE also sets it)"); console.log(" ./tools/dev up [flags] Start local dev server (flags go to"); console.log(" lchat-dev-up: --clean, --onboarding,"); console.log(" --no-hooks, --port N, --help)"); console.log(" ./tools/dev seed-mac Seed Mac app with dev credentials"); console.log(" ./tools/dev seed-ios Seed iOS app with dev credentials"); console.log(" ./tools/dev link Print dev server sign-in link"); console.log(" ./tools/dev test Run all tests"); console.log(" ./tools/dev test web Run web tests only"); console.log(" ./tools/dev test go [args] Run Go tests only (args go to go test,"); console.log(" e.g. -run TestFoo ./internal/bot)"); console.log(" ./tools/dev shots Marketing-screenshot kit: up | tunnel | mac |"); console.log(" web-check (docs/marketing-screenshots.md)"); console.log(""); process.exit(wantsHelp ? 0 : 1); } if (cmd === "alt") { Promise.resolve(altMac(cmdArgs)).catch((e) => { console.error(`Error: ${e.message}`); process.exit(1); }); } else if (commands[cmd]) { // Some commands are sync (build/test) and return undefined — wrap, so the // failure handler works for both instead of crashing on `.catch`. Promise.resolve(commands[cmd]()).catch((e) => { console.error(`Error: ${e.message}`); process.exit(1); }); } else { console.error(`Unknown command: ${cmd}`); console.error(`Run './tools/dev help' for available commands.`); process.exit(1); }