eleven / messenger Public

Add the ./tools/dev command to run common things (#604)

like building the apps, seeding and starting and clearing local servers,
that sort of thing.
James Adam a875d83ff8f0a1d245693a0e0a33541cb41fc042 parent 04e329f
3 files changed, +412 −0
  • CLAUDE.md +27 −0
  • tools/dev +385 −0
  • tools/lchat-dev-mac.mjs +0 −0
diff --git a/CLAUDE.md b/CLAUDE.md
index 46b6231..8004ca4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -453,6 +453,33 @@ sessions (they trample each other):
`deploy --all`): check `lchat update status` first — every `AUTO=pinned`
row is someone's live canary; leave it alone.
+## Dev CLI (`tools/dev`)
+
+Simple command-line interface for common dev tasks. Run from anywhere in the repo.
+
+```sh
+./tools/dev build mac # Build native Mac app (xcodebuild)
+./tools/dev build ios # Build native iOS app (xcodebuild)
+./tools/dev run mac # Launch the Mac app
+./tools/dev run ios # Run iOS app on simulator
+./tools/dev up # Start local dev server (persistent config)
+./tools/dev seed-mac # Seed Mac app with dev server credentials
+./tools/dev link # Print dev server sign-in link
+./tools/dev test # Run full test suite (Go + Node)
+./tools/dev test web # Run web tests only
+./tools/dev test go # Run Go tests only
+```
+
+All build and test commands are thin wrappers around the underlying toolchain (xcodebuild, go, npm). The `dev` subcommands orchestrate the dev server (`tools/lchat-dev-up.mjs`) and app seeding scripts. The `run` commands automatically build if needed, so you can skip the explicit build step. **Reuse and extend this as the single entry point for all dev workflows** — don't hand-roll xcodebuild invocations or embed them in Claude prompts. When adding new dev commands, document them here and in the script's usage header.
+
+Typical workflow:
+
+```sh
+./tools/dev up # 1. Start dev server in one terminal
+./tools/dev seed-mac # 2. Seed Mac app with dev server credentials
+./tools/dev run mac # 3. Build (if needed) and launch it
+```
+
## Branches, commits & PRs
- **Prefer small, focused PRs that ship incrementally.** When iterating, slice
diff --git a/tools/dev b/tools/dev
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);
+}
diff --git a/tools/lchat-dev-mac.mjs b/tools/lchat-dev-mac.mjs
old mode 100644
new mode 100755