mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Adds the worktree service, setup cache seeding, and Metro preview mount for /.sim. Also includes the related startup and mobile sidebar fixes needed by the updated worktree app flow.
357 lines
11 KiB
JavaScript
Executable File
357 lines
11 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
import { createHash } from "node:crypto";
|
|
import { closeSync, existsSync, mkdirSync, openSync, readdirSync } from "node:fs";
|
|
import { basename, dirname, join, resolve as resolvePath } from "node:path";
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
|
|
const rootDir = resolvePath(import.meta.dirname, "..");
|
|
const appDir = join(rootDir, "packages/app");
|
|
const appProductName = "PaseoDebug";
|
|
const appScheme = "paseo";
|
|
const preferredSimulatorType = process.env.PASEO_IOS_DEVICE_TYPE || "iPhone 16 Pro";
|
|
const paseoPort = requiredEnv("PASEO_PORT");
|
|
const worktreePath = process.env.PASEO_WORKTREE_PATH || rootDir;
|
|
const worktreeName = process.env.PASEO_BRANCH_NAME || basename(worktreePath);
|
|
const worktreeHash = createHash("sha1").update(worktreePath).digest("hex").slice(0, 8);
|
|
const simulatorName =
|
|
process.env.PASEO_IOS_SIMULATOR_NAME || `Paseo ${worktreeName} ${worktreeHash}`;
|
|
const daemonEndpoint =
|
|
process.env.PASEO_DEV_DAEMON_ENDPOINT ||
|
|
`localhost:${process.env.PASEO_SERVICE_DAEMON_PORT || "6768"}`;
|
|
|
|
const env = {
|
|
...process.env,
|
|
PATH: `${join(rootDir, "node_modules/.bin")}:${process.env.PATH || ""}`,
|
|
APP_VARIANT: "development",
|
|
CI: process.env.CI || "1",
|
|
EXPO_PUBLIC_LOCAL_DAEMON: daemonEndpoint,
|
|
};
|
|
const nativeBuildLog = join(rootDir, ".dev", "ios-build", `${simulatorSlug()}.log`);
|
|
|
|
let simulatorUdid = "";
|
|
let metro;
|
|
let shuttingDown = false;
|
|
let simulatorVisibilityGuard;
|
|
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.stack || error.message : error);
|
|
process.exitCode = 1;
|
|
void cleanup();
|
|
});
|
|
|
|
process.on("SIGINT", () => void shutdown());
|
|
process.on("SIGTERM", () => void shutdown());
|
|
|
|
async function main() {
|
|
startSimulatorVisibilityGuard();
|
|
simulatorUdid = findSimulator(simulatorName) || createSimulator(simulatorName);
|
|
hideNativeSimulatorApp();
|
|
bootSimulator(simulatorUdid);
|
|
hideNativeSimulatorApp();
|
|
run("npx", ["serve-sim", "--detach", "-q", simulatorUdid], { cwd: rootDir });
|
|
hideNativeSimulatorApp();
|
|
|
|
metro = startMetro();
|
|
await waitForUrl(`http://127.0.0.1:${paseoPort}/.sim`);
|
|
console.log(`iOS preview: ${process.env.PASEO_URL || `http://127.0.0.1:${paseoPort}`}/.sim`);
|
|
|
|
console.log("Building app dependencies...");
|
|
try {
|
|
run("npm", ["--prefix", rootDir, "run", "build:client"], { cwd: rootDir });
|
|
console.log("Generating iOS project...");
|
|
run("npx", ["expo", "prebuild", "--platform", "ios", "--non-interactive"], {
|
|
cwd: appDir,
|
|
});
|
|
|
|
const nativeProject = getNativeProject();
|
|
console.log(`Building iOS app (${nativeProject.scheme}); log: ${nativeBuildLog}`);
|
|
buildApp(nativeProject);
|
|
console.log("Installing iOS app...");
|
|
installApp(nativeProject);
|
|
console.log("Launching iOS app...");
|
|
launchApp();
|
|
hideNativeSimulatorApp();
|
|
} catch (error) {
|
|
process.exitCode = 1;
|
|
console.error("iOS app build/install failed; leaving preview running for manual QA.");
|
|
console.error(error instanceof Error ? error.message : error);
|
|
}
|
|
|
|
await waitForExit(metro);
|
|
await cleanup();
|
|
}
|
|
|
|
function buildApp(nativeProject) {
|
|
run(
|
|
"xcodebuild",
|
|
[
|
|
...nativeProject.args,
|
|
"-scheme",
|
|
nativeProject.scheme,
|
|
"-configuration",
|
|
"Debug",
|
|
"-destination",
|
|
`id=${simulatorUdid}`,
|
|
"-derivedDataPath",
|
|
nativeProject.derivedDataPath,
|
|
"build",
|
|
],
|
|
{ cwd: appDir, logFile: nativeBuildLog },
|
|
);
|
|
}
|
|
|
|
function installApp(nativeProject) {
|
|
const appPath = findBuiltApp(nativeProject.derivedDataPath);
|
|
run("xcrun", ["simctl", "install", simulatorUdid, appPath], { cwd: appDir });
|
|
}
|
|
|
|
function launchApp() {
|
|
const metroUrl = encodeURIComponent(`http://127.0.0.1:${paseoPort}`);
|
|
run(
|
|
"xcrun",
|
|
["simctl", "openurl", simulatorUdid, `${appScheme}://expo-development-client/?url=${metroUrl}`],
|
|
{ cwd: appDir },
|
|
);
|
|
}
|
|
|
|
function startMetro() {
|
|
const child = spawn("npx", ["expo", "start", "--port", paseoPort, "--localhost"], {
|
|
cwd: appDir,
|
|
env: {
|
|
...env,
|
|
PASEO_SERVE_SIM_PREVIEW: "1",
|
|
PASEO_SERVE_SIM_DEVICE_UDID: simulatorUdid,
|
|
BROWSER: "none",
|
|
},
|
|
stdio: "inherit",
|
|
});
|
|
child.on("exit", (code, signal) => {
|
|
if (!shuttingDown && code !== 0) {
|
|
console.error(`Metro exited with ${signal || code}`);
|
|
process.exitCode = code || 1;
|
|
}
|
|
});
|
|
return child;
|
|
}
|
|
|
|
function getNativeProject() {
|
|
const workspace = firstPath(join(appDir, "ios"), (name) => name.endsWith(".xcworkspace"));
|
|
const project = firstPath(join(appDir, "ios"), (name) => name.endsWith(".xcodeproj"));
|
|
const projectFile = workspace || project;
|
|
if (!projectFile) {
|
|
throw new Error("Expo prebuild did not create an iOS workspace or project.");
|
|
}
|
|
return {
|
|
args: workspace ? ["-workspace", workspace] : ["-project", projectFile],
|
|
scheme: getScheme(workspace ? ["-workspace", workspace] : ["-project", projectFile]),
|
|
derivedDataPath: join(rootDir, ".dev", "ios-build", simulatorSlug()),
|
|
};
|
|
}
|
|
|
|
function getScheme(projectArgs) {
|
|
const output = run("xcodebuild", [...projectArgs, "-list", "-json"], {
|
|
cwd: appDir,
|
|
capture: true,
|
|
});
|
|
const list = parseJson(output);
|
|
const schemes = list.workspace?.schemes || list.project?.schemes || [];
|
|
const scheme = schemes.find((value) => value === appProductName) || schemes[0];
|
|
if (!scheme) throw new Error("No iOS scheme found after Expo prebuild.");
|
|
return scheme;
|
|
}
|
|
|
|
function findBuiltApp(derivedDataPath) {
|
|
const productsDir = join(derivedDataPath, "Build", "Products", "Debug-iphonesimulator");
|
|
const appPath = join(productsDir, `${appProductName}.app`);
|
|
if (existsSync(appPath)) return appPath;
|
|
const fallback = firstPath(productsDir, (name) => name.endsWith(".app"));
|
|
if (fallback) return fallback;
|
|
throw new Error(`Built app was not found in ${productsDir}`);
|
|
}
|
|
|
|
function findSimulator(name) {
|
|
const output = parseJson(run("xcrun", ["simctl", "list", "devices", "-j"], { capture: true }));
|
|
for (const devices of Object.values(output.devices || {})) {
|
|
for (const device of devices) {
|
|
if (device.name === name && device.isAvailable !== false) {
|
|
return device.udid;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function createSimulator(name) {
|
|
return run("xcrun", ["simctl", "create", name, resolveSimulatorType()], {
|
|
capture: true,
|
|
}).trim();
|
|
}
|
|
|
|
function resolveSimulatorType() {
|
|
const output = parseJson(
|
|
run("xcrun", ["simctl", "list", "devicetypes", "-j"], {
|
|
capture: true,
|
|
}),
|
|
);
|
|
const types = output.devicetypes || [];
|
|
const preferred = types.find((type) => type.name === preferredSimulatorType);
|
|
if (preferred) return preferred.identifier;
|
|
const fallback =
|
|
types.find((type) => /^iPhone .* Pro$/.test(type.name)) ||
|
|
types.find((type) => /^iPhone\b/.test(type.name));
|
|
if (!fallback) throw new Error("No iPhone simulator device type is installed.");
|
|
return fallback.identifier;
|
|
}
|
|
|
|
function bootSimulator(udid) {
|
|
spawnSync("xcrun", ["simctl", "boot", udid], { stdio: "ignore" });
|
|
waitForBootedSimulator(udid);
|
|
}
|
|
|
|
function waitForBootedSimulator(udid) {
|
|
const deadline = Date.now() + 120_000;
|
|
while (Date.now() < deadline) {
|
|
hideNativeSimulatorApp();
|
|
const output = parseJson(run("xcrun", ["simctl", "list", "devices", "-j"], { capture: true }));
|
|
for (const devices of Object.values(output.devices || {})) {
|
|
for (const device of devices) {
|
|
if (device.udid === udid && device.state === "Booted") return;
|
|
}
|
|
}
|
|
spawnSync("sleep", ["1"]);
|
|
}
|
|
throw new Error(`Timed out waiting for simulator ${udid} to boot.`);
|
|
}
|
|
|
|
async function cleanup() {
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
stopSimulatorVisibilityGuard();
|
|
if (metro && !metro.killed) {
|
|
metro.kill("SIGTERM");
|
|
}
|
|
if (simulatorUdid) {
|
|
spawnSync("npx", ["serve-sim", "--kill", simulatorUdid], {
|
|
cwd: rootDir,
|
|
stdio: "ignore",
|
|
env,
|
|
});
|
|
spawnSync("xcrun", ["simctl", "shutdown", simulatorUdid], { stdio: "ignore" });
|
|
}
|
|
}
|
|
|
|
async function shutdown() {
|
|
await cleanup();
|
|
process.exit();
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
if (options.logFile) {
|
|
return runWithLogFile(command, args, options);
|
|
}
|
|
const stdio = options.capture || options.logFile ? ["ignore", "pipe", "pipe"] : "inherit";
|
|
const result = spawnSync(command, args, {
|
|
cwd: options.cwd || rootDir,
|
|
env,
|
|
encoding: "utf8",
|
|
stdio,
|
|
});
|
|
if (result.status !== 0) {
|
|
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
|
throw new Error(`${command} ${args.join(" ")} failed${output ? `\n${output}` : ""}`);
|
|
}
|
|
return result.stdout || "";
|
|
}
|
|
|
|
function runWithLogFile(command, args, options) {
|
|
mkdirSync(dirname(options.logFile), { recursive: true });
|
|
const logFile = openSync(options.logFile, "w");
|
|
try {
|
|
const result = spawnSync(command, args, {
|
|
cwd: options.cwd || rootDir,
|
|
env,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", logFile, logFile],
|
|
});
|
|
if (result.status !== 0) {
|
|
throw new Error(`${command} ${args.join(" ")} failed; see ${options.logFile}`);
|
|
}
|
|
return "";
|
|
} finally {
|
|
closeSync(logFile);
|
|
}
|
|
}
|
|
|
|
function hideNativeSimulatorApp() {
|
|
spawnSync(
|
|
"osascript",
|
|
[
|
|
"-e",
|
|
'tell application "System Events"',
|
|
"-e",
|
|
'if exists application process "Simulator" then set visible of application process "Simulator" to false',
|
|
"-e",
|
|
"end tell",
|
|
],
|
|
{ stdio: "ignore", timeout: 2_000 },
|
|
);
|
|
}
|
|
|
|
function startSimulatorVisibilityGuard() {
|
|
hideNativeSimulatorApp();
|
|
simulatorVisibilityGuard = setInterval(hideNativeSimulatorApp, 250);
|
|
simulatorVisibilityGuard.unref?.();
|
|
}
|
|
|
|
function stopSimulatorVisibilityGuard() {
|
|
if (simulatorVisibilityGuard) {
|
|
clearInterval(simulatorVisibilityGuard);
|
|
simulatorVisibilityGuard = undefined;
|
|
}
|
|
}
|
|
|
|
function parseJson(value) {
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch (error) {
|
|
throw new Error(`Could not parse JSON: ${error instanceof Error ? error.message : error}`, {
|
|
cause: error,
|
|
});
|
|
}
|
|
}
|
|
|
|
function firstPath(dir, predicate) {
|
|
try {
|
|
const name = readdirSync(dir).find(predicate);
|
|
return name ? join(dir, name) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function simulatorSlug() {
|
|
return `${simulatorName.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "")}-${worktreeHash}`;
|
|
}
|
|
|
|
function requiredEnv(name) {
|
|
const value = process.env[name];
|
|
if (!value) throw new Error(`${name} is required; run this as a Paseo service.`);
|
|
return value;
|
|
}
|
|
|
|
async function waitForUrl(url) {
|
|
const deadline = Date.now() + 120_000;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const response = await fetch(url);
|
|
if (response.ok) return;
|
|
} catch {}
|
|
await new Promise((done) => setTimeout(done, 1000));
|
|
}
|
|
throw new Error(`Timed out waiting for ${url}`);
|
|
}
|
|
|
|
async function waitForExit(child) {
|
|
return new Promise((done) => child.on("exit", done));
|
|
}
|