#!/usr/bin/env bun /**
- ios.ts — Build, run, and manage the Clanky iOS app on a physical device.
- Usage:
- bun scripts/ios.ts build Build for device
- bun scripts/ios.ts run Build + install + launch on device
- bun scripts/ios.ts install Install built app on device
- bun scripts/ios.ts launch Launch already-installed app
- bun scripts/ios.ts log Stream app logs from device
- bun scripts/ios.ts kill Kill the running app
- bun scripts/ios.ts generate Regenerate Xcode project from project.yml
- bun scripts/ios.ts clean Clean build artifacts
- bun scripts/ios.ts device Show connected device info
- Options:
- --release Build in Release mode
- --verbose Show full build output */
const IOS_DIR = new URL("../ios", import.meta.url).pathname;
const PROJECT = ${IOS_DIR}/Clanky.xcodeproj;
const SCHEME = "Clanky";
const BUNDLE_ID = "com.clanky.app";
// Parse args const args = process.argv.slice(2); const command = args.find((a) => !a.startsWith("--")) ?? "run"; const isRelease = args.includes("--release"); const isVerbose = args.includes("--verbose"); const config = isRelease ? "Release" : "Debug";
// ── Helpers ──────────────────────────────────────────────────────────
async function exec(cmd: string[], opts?: { cwd?: string; quiet?: boolean }): Promise {
const proc = Bun.spawn(cmd, {
cwd: opts?.cwd ?? IOS_DIR,
stdout: opts?.quiet ? "pipe" : "inherit",
stderr: opts?.quiet ? "pipe" : "inherit",
});
const exitCode = await proc.exited;
if (opts?.quiet) {
const out = await new Response(proc.stdout).text();
if (exitCode !== 0) {
const err = await new Response(proc.stderr).text();
throw new Error(Command failed (${exitCode}): ${cmd.join(" ")} ${err});
}
return out.trim();
}
if (exitCode !== 0) {
throw new Error(Command failed with exit code ${exitCode});
}
return "";
}
interface PhysicalDevice { udid: string; name: string; osVersion: string; }
async function getConnectedDevice(): Promise { const out = await exec( ["xcrun", "xctrace", "list", "devices"], { quiet: true } );
// Parse lines like: James's iPhone (2) (26.3.1) (00008150-0016258C21F2401C) // Physical devices appear before the "Simulators" section const lines = out.split(" "); const simulatorIdx = lines.findIndex((l) => l.includes("Simulator")); const deviceLines = simulatorIdx > 0 ? lines.slice(0, simulatorIdx) : lines;
for (const line of deviceLines) { // Match: Name (OS Version) (UDID) const match = line.match(/^(.+?)\s+((\d+.\d+(?:.\d+)?))\s+(([A-F0-9-]+))\s*$/); if (match) { return { name: match[1].trim(), osVersion: match[2], udid: match[3], }; } }
throw new Error( "No physical device connected. Plug in your iPhone via USB or ensure Wi-Fi debugging is enabled." ); }
function getBuildDir(): string {
return ${process.env.HOME ?? "~"}/Library/Developer/Xcode/DerivedData;
}
async function getAppPath(): Promise { const out = await exec( [ "find", getBuildDir(), "-path", "/Clanky-/Build/Products/*-iphoneos/Clanky.app", "-maxdepth", 6, "-type", "d", ], { quiet: true } ); const paths = out.split(" ").filter(Boolean); if (!paths.length) throw new Error("Built app not found. Run: bun scripts/ios.ts build"); return paths[paths.length - 1]; }
// ── Commands ─────────────────────────────────────────────────────────
async function cmdGenerate(): Promise { console.log("Regenerating Xcode project..."); await exec(["xcodegen", "generate"], { cwd: IOS_DIR }); console.log("Project regenerated"); }
async function cmdBuild(): Promise {
const device = await getConnectedDevice();
console.log(Building Clanky (${config}) for ${device.name}...);
const start = performance.now();
const buildArgs = [
"xcodebuild",
"-project", PROJECT,
"-scheme", SCHEME,
"-destination", id=${device.udid},
"-configuration", config,
"-allowProvisioningUpdates",
"build",
];
if (!isVerbose) { buildArgs.push("-quiet"); }
await exec(buildArgs);
const elapsed = ((performance.now() - start) / 1000).toFixed(1);
console.log(Build succeeded (${elapsed}s));
}
async function cmdInstall(): Promise {
const device = await getConnectedDevice();
const appPath = await getAppPath();
console.log(Installing ${appPath.split("/").pop()} on ${device.name}...);
await exec([ "xcrun", "devicectl", "device", "install", "app", "--device", device.udid, appPath, ]);
console.log("Installed"); }
async function cmdLaunch(): Promise {
const device = await getConnectedDevice();
console.log(Launching Clanky on ${device.name}...);
await exec([ "xcrun", "devicectl", "device", "process", "launch", "--device", device.udid, BUNDLE_ID, ]);
console.log("Launched"); }
async function cmdRun(): Promise { await cmdBuild(); await cmdInstall(); await cmdLaunch(); }
async function cmdLog(): Promise {
const device = await getConnectedDevice();
console.log(Launching Clanky with console attached on ${device.name} (Ctrl+C to stop)... );
const proc = Bun.spawn( [ "xcrun", "devicectl", "device", "process", "launch", "--device", device.udid, "--console", "--terminate-existing", BUNDLE_ID, ], { stdout: "inherit", stderr: "inherit" } );
process.on("SIGINT", () => { proc.kill(); process.exit(0); });
await proc.exited; }
async function cmdKill(): Promise { const device = await getConnectedDevice(); console.log("Terminating Clanky..."); try { // List running processes to find PID const out = await exec([ "xcrun", "devicectl", "device", "info", "processes", "--device", device.udid, "--json-output", "/dev/stdout", ], { quiet: true }); const json = JSON.parse(out); const processes = json?.result?.runningProcesses ?? []; const clanky = processes.find((p: { executable?: string; bundleIdentifier?: string }) => p.bundleIdentifier === BUNDLE_ID || p.executable?.includes("Clanky") );
if (clanky?.processIdentifier) {
await exec([
"xcrun", "devicectl", "device", "process", "terminate",
"--device", device.udid,
"--pid", String(clanky.processIdentifier),
], { quiet: true });
console.log("Terminated");
} else {
console.log("App was not running");
}
} catch { console.log("App was not running"); } }
async function cmdClean(): Promise { console.log("Cleaning build artifacts..."); await exec([ "xcodebuild", "-project", PROJECT, "-scheme", SCHEME, "-configuration", config, "clean", "-quiet", ]); console.log("Clean"); }
async function cmdDevice(): Promise {
console.log("
Looking for connected devices...
");
try {
const device = await getConnectedDevice();
console.log( NAME: ${device.name});
console.log( UDID: ${device.udid});
console.log( OS: iOS ${device.osVersion});
console.log();
} catch (e) {
console.log( ${e instanceof Error ? e.message : e});
}
}
// ── Main ─────────────────────────────────────────────────────────────
const commands: Record<string, () => Promise> = { build: cmdBuild, run: cmdRun, install: cmdInstall, launch: cmdLaunch, log: cmdLog, kill: cmdKill, generate: cmdGenerate, clean: cmdClean, device: cmdDevice, };
const handler = commands[command];
if (!handler) {
console.error(Unknown command: ${command});
console.error(Available: ${Object.keys(commands).join(", ")});
process.exit(1);
}
try {
await handler();
} catch (e) {
console.error( ${e instanceof Error ? e.message : e});
process.exit(1);
}
