Stabilize local dev daemon homes
Use a checkout-local .dev/paseo-home for root and worktree dev flows so development daemons do not collide with the packaged app home. Split server, app, and desktop dev entrypoints, seed worktree homes from the source checkout metadata, and keep desktop dev on its own user-data directory and Expo port.
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env node
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const desktopDir = path.resolve(scriptDir, "..");
|
||||
const rootDir = path.resolve(desktopDir, "../..");
|
||||
const appDir = path.resolve(desktopDir, "../app");
|
||||
const require = createRequire(import.meta.url);
|
||||
const electron = require("electron");
|
||||
|
||||
const expoPort = Number(process.env.EXPO_PORT);
|
||||
if (!Number.isInteger(expoPort) || expoPort <= 0) {
|
||||
console.error("[dev] EXPO_PORT must be set before running desktop dev");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const expoDevUrl = process.env.EXPO_DEV_URL || `http://localhost:${expoPort}`;
|
||||
const daemonEndpoint = process.env.PASEO_DAEMON_ENDPOINT || process.env.PASEO_LISTEN || "";
|
||||
const colorEnv = {
|
||||
FORCE_COLOR: process.env.FORCE_COLOR || "1",
|
||||
npm_config_color: process.env.npm_config_color || "always",
|
||||
};
|
||||
|
||||
const children = new Map();
|
||||
let stopping = false;
|
||||
let exitCode = 0;
|
||||
|
||||
function prefixStream(name, stream, target) {
|
||||
let buffered = "";
|
||||
stream.setEncoding("utf8");
|
||||
stream.on("data", (chunk) => {
|
||||
buffered += chunk;
|
||||
const lines = buffered.split(/\r?\n/);
|
||||
buffered = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
target.write(line ? `[${name}] ${line}\n` : `[${name}]\n`);
|
||||
}
|
||||
});
|
||||
stream.on("end", () => {
|
||||
if (buffered) {
|
||||
target.write(`[${name}] ${buffered}\n`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function spawnChild(name, command, args, options = {}) {
|
||||
const child = spawn(command, args, {
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
...process.env,
|
||||
...colorEnv,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...options,
|
||||
});
|
||||
|
||||
children.set(name, child);
|
||||
prefixStream(name, child.stdout, process.stdout);
|
||||
prefixStream(name, child.stderr, process.stderr);
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(`[${name}] failed to start: ${error.message}`);
|
||||
exitCode = 1;
|
||||
stopAll("SIGTERM");
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
children.delete(name);
|
||||
if (!stopping) {
|
||||
if (code !== 0) {
|
||||
exitCode = code ?? 1;
|
||||
console.error(`[${name}] exited with ${signal ?? code}`);
|
||||
}
|
||||
stopAll("SIGTERM");
|
||||
}
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
function killChild(child, signal) {
|
||||
if (!child.pid || child.killed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (child.detached) {
|
||||
process.kill(-child.pid, signal);
|
||||
} else {
|
||||
child.kill(signal);
|
||||
}
|
||||
} catch {
|
||||
// The child may have exited between the liveness check and the signal.
|
||||
}
|
||||
}
|
||||
|
||||
function stopAll(signal) {
|
||||
if (stopping) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopping = true;
|
||||
for (const child of children.values()) {
|
||||
killChild(child, signal);
|
||||
}
|
||||
|
||||
const forceKill = setTimeout(() => {
|
||||
for (const child of children.values()) {
|
||||
killChild(child, "SIGKILL");
|
||||
}
|
||||
}, 2500);
|
||||
forceKill.unref();
|
||||
|
||||
const finish = setInterval(() => {
|
||||
if (children.size === 0) {
|
||||
clearInterval(finish);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
async function waitForPort(port, host = "127.0.0.1", timeoutMs = 60_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (await canConnect(port, host)) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${host}:${port}`);
|
||||
}
|
||||
|
||||
function canConnect(port, host) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection({ port, host });
|
||||
socket.setTimeout(1000);
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once("timeout", () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
socket.once("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => stopAll("SIGTERM"));
|
||||
process.on("SIGTERM", () => stopAll("SIGTERM"));
|
||||
|
||||
spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], {
|
||||
cwd: appDir,
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
...colorEnv,
|
||||
BROWSER: "none",
|
||||
APP_VARIANT: "development",
|
||||
PASEO_WEB_PLATFORM: "electron",
|
||||
EXPO_PUBLIC_LOCAL_DAEMON: daemonEndpoint,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForPort(expoPort);
|
||||
} catch (error) {
|
||||
console.error(`[dev] ${error.message}`);
|
||||
exitCode = 1;
|
||||
stopAll("SIGTERM");
|
||||
}
|
||||
|
||||
if (!stopping) {
|
||||
spawnChild("electron", electron, [desktopDir], {
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
...colorEnv,
|
||||
EXPO_DEV_URL: expoDevUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3,88 +3,41 @@ set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DESKTOP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
APP_DIR="$(cd "$DESKTOP_DIR/../app" && pwd)"
|
||||
ROOT_DIR="$(cd "$DESKTOP_DIR/../.." && pwd)"
|
||||
|
||||
# Build the Electron main process
|
||||
npm run build:main
|
||||
source "$ROOT_DIR/scripts/dev-home.sh"
|
||||
|
||||
# Prefer Metro's stable default port so dev browser storage keeps the same
|
||||
# localhost origin across restarts. Fall back only when earlier ports are busy.
|
||||
EXPO_PORT=$(NO_COLOR=1 FORCE_COLOR=0 "$ROOT_DIR/node_modules/.bin/get-port" 8081 8082 8083 8084 8085)
|
||||
export PATH="$ROOT_DIR/node_modules/.bin:$PATH"
|
||||
export PASEO_LISTEN="${PASEO_LISTEN:-127.0.0.1:6768}"
|
||||
configure_dev_paseo_home
|
||||
|
||||
DEV_ROOT="${PASEO_DEV_ROOT:-$(default_dev_paseo_root)}"
|
||||
export PASEO_ELECTRON_USER_DATA_DIR="${PASEO_ELECTRON_USER_DATA_DIR:-$DEV_ROOT/.dev/user-data}"
|
||||
mkdir -p "$PASEO_ELECTRON_USER_DATA_DIR"
|
||||
|
||||
if [ -z "${EXPO_PORT:-}" ]; then
|
||||
EXPO_PORT=$(NO_COLOR=1 FORCE_COLOR=0 "$ROOT_DIR/node_modules/.bin/get-port" 8082 8083 8084 8085 8086 8087 8088 8089)
|
||||
fi
|
||||
export EXPO_PORT
|
||||
export EXPO_DEV_URL="http://localhost:${EXPO_PORT}"
|
||||
|
||||
DAEMON_ENDPOINT="$(resolve_dev_daemon_endpoint)"
|
||||
export PASEO_DAEMON_ENDPOINT="$DAEMON_ENDPOINT"
|
||||
|
||||
REMOTE_DEBUGGING_PORT="${PASEO_ELECTRON_REMOTE_DEBUGGING_PORT:-9223}"
|
||||
export PASEO_ELECTRON_FLAGS="${PASEO_ELECTRON_FLAGS:+$PASEO_ELECTRON_FLAGS }--remote-debugging-port=$REMOTE_DEBUGGING_PORT"
|
||||
export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}"
|
||||
|
||||
# Allow any origin in dev so Electron on random localhost ports can reach
|
||||
# the daemon websocket. Safe here because this script is development-only
|
||||
# and the daemon still binds to localhost.
|
||||
export PASEO_CORS_ORIGINS="*"
|
||||
|
||||
# Fully isolate the dev instance from a production Paseo install so `npm run dev`
|
||||
# works while the installed app is open. Without this the dev build (a) loses the
|
||||
# Electron single-instance lock to the installed app and quits, and (b) ends up
|
||||
# pointed at the production daemon, whose CORS allowlist rejects the Metro origin.
|
||||
# PASEO_HOME defaults to a script-managed dev home. If you override it (to point
|
||||
# dev at real data), we DON'T touch your config.json — only the managed home gets
|
||||
# its daemon config seeded below, so we never rewrite a production ~/.paseo config.
|
||||
# - PASEO_ELECTRON_USER_DATA_DIR: a separate Electron profile → separate
|
||||
# single-instance lock, so dev and the installed app coexist.
|
||||
# - PASEO_LISTEN: a distinct port so the dev daemon never collides with prod's 6767.
|
||||
DEV_STATE_DIR="$DESKTOP_DIR/.dev"
|
||||
if [ -n "${PASEO_HOME:-}" ]; then
|
||||
PASEO_HOME_MANAGED=0
|
||||
else
|
||||
PASEO_HOME="$DEV_STATE_DIR/paseo-home"
|
||||
PASEO_HOME_MANAGED=1
|
||||
fi
|
||||
export PASEO_HOME
|
||||
export PASEO_ELECTRON_USER_DATA_DIR="${PASEO_ELECTRON_USER_DATA_DIR:-$DEV_STATE_DIR/user-data}"
|
||||
mkdir -p "$PASEO_HOME" "$PASEO_ELECTRON_USER_DATA_DIR"
|
||||
|
||||
DEV_DAEMON_PORT="${PASEO_DEV_DAEMON_PORT:-6788}"
|
||||
export PASEO_LISTEN="${PASEO_LISTEN:-127.0.0.1:$DEV_DAEMON_PORT}"
|
||||
|
||||
# Seed the isolated daemon config. The desktop daemon-manager decides whether a
|
||||
# daemon is already running by reading `daemon.listen` from this config.json
|
||||
# (it does NOT honor the PASEO_LISTEN env var) and probing that address. Without
|
||||
# this it reads the default 6767, finds a production daemon there, and connects
|
||||
# the dev app to prod — whose CORS allowlist then rejects the Metro origin. Pin
|
||||
# the dev port + wildcard CORS in the file so the dev app starts its OWN daemon.
|
||||
# ONLY seed the script-managed home: never rewrite a user-supplied PASEO_HOME
|
||||
# (that could clobber a production config.json with the dev port + wildcard CORS).
|
||||
if [ "$PASEO_HOME_MANAGED" = "1" ]; then
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const [path, port] = [process.argv[1], process.argv[2]];
|
||||
let cfg = {};
|
||||
try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch {}
|
||||
cfg.version = cfg.version || 1;
|
||||
cfg.daemon = cfg.daemon || {};
|
||||
cfg.daemon.listen = `127.0.0.1:${port}`;
|
||||
cfg.daemon.cors = cfg.daemon.cors || {};
|
||||
cfg.daemon.cors.allowedOrigins = ["*"];
|
||||
fs.writeFileSync(path, JSON.stringify(cfg, null, 2));
|
||||
' "$PASEO_HOME/config.json" "$DEV_DAEMON_PORT"
|
||||
else
|
||||
echo " (custom PASEO_HOME — leaving its config.json untouched)"
|
||||
fi
|
||||
npm run build:main
|
||||
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " Paseo Desktop Dev"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " Metro: http://localhost:${EXPO_PORT}"
|
||||
echo " Metro: ${EXPO_DEV_URL}"
|
||||
echo " CDP: http://127.0.0.1:${REMOTE_DEBUGGING_PORT}"
|
||||
echo " Daemon: ${PASEO_LISTEN} (isolated)"
|
||||
echo " PASEO_HOME: ${PASEO_HOME}"
|
||||
echo " Daemon: ${PASEO_LISTEN}"
|
||||
echo " Home: ${PASEO_HOME}"
|
||||
echo " userData: ${PASEO_ELECTRON_USER_DATA_DIR}"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
|
||||
# Launch Metro + Electron together, kill both on exit
|
||||
exec "$ROOT_DIR/node_modules/.bin/concurrently" \
|
||||
--kill-others \
|
||||
--names "metro,electron" \
|
||||
--prefix-colors "magenta,cyan" \
|
||||
"cd '$APP_DIR' && PASEO_WEB_PLATFORM=electron npx expo start --port $EXPO_PORT" \
|
||||
"$ROOT_DIR/node_modules/.bin/wait-on tcp:$EXPO_PORT && EXPO_DEV_URL=http://localhost:$EXPO_PORT electron '$DESKTOP_DIR'"
|
||||
exec node "$SCRIPT_DIR/dev-runner.mjs"
|
||||
|
||||
Reference in New Issue
Block a user