| new file mode 100755 |
| index 0000000..04900ee |
| --- /dev/null |
| +++ b/tools/dev |
| @@ -0,0 +1,385 @@ |
| +#!/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 run ios Run the iOS app on simulator (see instructions) |
| +// ./tools/dev up Start the local dev server (persistent, reusable config) |
| +// ./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 Run Go tests only |
| +// |
| +// 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; |
| + } |
| +} |
| + |
| +// 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; |
| + } |
| +} |
| + |
| +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" }); |
| + |
| + if (result.status !== 0) { |
| + console.error("Build failed!"); |
| + console.error(result.stderr); |
| + process.exit(result.status); |
| + } |
| + |
| + 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"); |
| + }, |
| + |
| + "up": async () => { |
| + console.log("Starting dev server (lchat-dev-up)..."); |
| + await runAsync("node", ["tools/lchat-dev-up.mjs"]); |
| + }, |
| + |
| + "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 |
| + let buildSettingsOutput; |
| + try { |
| + buildSettingsOutput = execFileSync("xcodebuild", [ |
| + "-project", path.join(IOS_DIR, "LChat.xcodeproj"), |
| + "-scheme", "LChatMac", |
| + "-destination", "generic/platform=macOS", |
| + "-showBuildSettings", |
| + ], { cwd: IOS_DIR, encoding: "utf8" }); |
| + } catch (e) { |
| + console.log("Build settings lookup failed. Building first..."); |
| + await commands["build mac"](); |
| + buildSettingsOutput = execFileSync("xcodebuild", [ |
| + "-project", path.join(IOS_DIR, "LChat.xcodeproj"), |
| + "-scheme", "LChatMac", |
| + "-destination", "generic/platform=macOS", |
| + "-showBuildSettings", |
| + ], { 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: () => { |
| + 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": () => { |
| + console.log("Running web (Node) tests..."); |
| + run("npm", ["test"], { cwd: REPO_ROOT }); |
| + console.log("✓ Web tests passed"); |
| + }, |
| + |
| + "test go": () => { |
| + console.log("Running Go tests..."); |
| + run("go", ["test", "./..."], { cwd: REPO_ROOT }); |
| + console.log("✓ Go tests passed"); |
| + }, |
| +}; |
| + |
| +const cmd = process.argv.slice(2).join(" "); |
| + |
| +if (!cmd || cmd === "-h" || cmd === "--help" || cmd === "help") { |
| + 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 run ios Run iOS app on simulator"); |
| + console.log(" ./tools/dev up Start local dev server"); |
| + 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 Run Go tests only"); |
| + console.log(""); |
| + process.exit(cmd ? 0 : 1); |
| +} |
| + |
| +if (commands[cmd]) { |
| + 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); |
| +} |