mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
955 lines
34 KiB
TypeScript
955 lines
34 KiB
TypeScript
import { execFileSync, spawn, type ChildProcess } from "child_process";
|
|
import { Command } from "commander";
|
|
import { chmodSync, closeSync, cpSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "fs";
|
|
import { dirname, join, resolve } from "path";
|
|
import { DEFAULT_API_URL, DEFAULT_PUBLISHABLE_CLIENT_KEY, resolveLoginConfig } from "../lib/auth.js";
|
|
import { forwardSignals } from "../lib/child-process.js";
|
|
import { resolveConfigFilePathOption } from "../lib/config-file-path.js";
|
|
import { DASHBOARD_SERVER_RELATIVE_PATH, dashboardDirOverride, fetchDashboardManifest, resolveDashboardRuntime, type DashboardManifest } from "../lib/dashboard-release.js";
|
|
import { devEnvStatePath, ensureLocalDashboardSecret, readDevEnvState, recordLocalDashboardProcess } from "../lib/dev-env-state.js";
|
|
import { CliError, errorMessage } from "../lib/errors.js";
|
|
import { DASHBOARD_PORT_ENV_VAR, dashboardPort, dashboardRequest, dashboardUrl, createRemoteDevelopmentEnvironmentSession, type DashboardSessionResponse } from "../lib/local-dashboard.js";
|
|
|
|
type ChildCommand = {
|
|
command: string,
|
|
args: string[],
|
|
};
|
|
|
|
type DevOptions = {
|
|
configFile?: string,
|
|
};
|
|
|
|
type ConfigSyncEventBase = {
|
|
config_file_path: string,
|
|
created_at_millis: number,
|
|
};
|
|
|
|
type ConfigSyncEvent = ConfigSyncEventBase & ({
|
|
status: "success",
|
|
} | {
|
|
status: "error",
|
|
error_message: string,
|
|
});
|
|
|
|
type HeartbeatResponse = {
|
|
ok: true,
|
|
browser_secret_confirmation_code?: string,
|
|
browser_secret_confirmation_code_expires_at_millis?: number,
|
|
config_sync_events?: ConfigSyncEvent[],
|
|
};
|
|
|
|
const HEARTBEAT_INTERVAL_MS = 5_000;
|
|
const HEARTBEAT_STOP_POLL_MS = 100;
|
|
const DASHBOARD_RESTART_MIN_UPTIME_MS = 5_000;
|
|
const DASHBOARD_START_TIMEOUT_MS = 60_000;
|
|
const DASHBOARD_STOP_TIMEOUT_MS = 10_000;
|
|
const DASHBOARD_FORCE_STOP_TIMEOUT_MS = 2_000;
|
|
const DASHBOARD_HEALTH_PATH = "/api/development-environment/health";
|
|
const DEV_DASHBOARD_COMMAND_ENV_VAR = "HEXCLAVE_CLI_DEV_DASHBOARD_COMMAND";
|
|
const DEV_DASHBOARD_DIST_DIR_ENV_VAR = "HEXCLAVE_DASHBOARD_NEXT_DIST_DIR";
|
|
const RDE_DASHBOARD_LOG_PATH_ENV_VAR = "HEXCLAVE_RDE_DASHBOARD_LOG_PATH";
|
|
const DASHBOARD_RUNTIME_DIR_NAME = "rde-dashboard-runtime";
|
|
const SENTINEL_PREFIX = "STACK_ENV_VAR_SENTINEL_";
|
|
const USE_INLINE_ENV_VARS_SENTINEL = "STACK_ENV_VAR_SENTINEL_USE_INLINE_ENV_VARS";
|
|
const SENTINEL_REGEX = /STACK_ENV_VAR_SENTINEL(?:_[A-Z0-9_]+)?/g;
|
|
const LOG_PREFIX = "[Hexclave] ";
|
|
const REQUIRED_DASHBOARD_RUNTIME_ENV_VARS = new Set([
|
|
"NEXT_PUBLIC_STACK_API_URL",
|
|
"NEXT_PUBLIC_BROWSER_STACK_API_URL",
|
|
"NEXT_PUBLIC_SERVER_STACK_API_URL",
|
|
"NEXT_PUBLIC_STACK_DASHBOARD_URL",
|
|
"NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL",
|
|
"NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL",
|
|
"NEXT_PUBLIC_STACK_PROJECT_ID",
|
|
"NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY",
|
|
"NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT",
|
|
"NEXT_PUBLIC_STACK_IS_PREVIEW",
|
|
DASHBOARD_PORT_ENV_VAR,
|
|
]);
|
|
|
|
type ProgressLogger = {
|
|
stop: (finalMessage?: string) => void,
|
|
};
|
|
|
|
type DashboardSessionState = {
|
|
session: DashboardSessionResponse,
|
|
dashboardReachableSinceMs: number,
|
|
};
|
|
|
|
function wait(ms: number): Promise<void> {
|
|
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
}
|
|
|
|
function splitDevCommandArgs(commandArgs: string[]): ChildCommand {
|
|
if (commandArgs.length === 0) {
|
|
throw new CliError("Missing command. Usage: hexclave dev --config-file <path> -- <command> [args...]");
|
|
}
|
|
const command = commandArgs[0];
|
|
return { command, args: commandArgs.slice(1) };
|
|
}
|
|
|
|
export function devDashboardCommandFromEnv(env: NodeJS.ProcessEnv): string | undefined {
|
|
const command = env[DEV_DASHBOARD_COMMAND_ENV_VAR]?.trim();
|
|
return command == null || command.length === 0 ? undefined : command;
|
|
}
|
|
|
|
function normalizeApiBaseUrl(apiBaseUrl: string): string {
|
|
const url = new URL(apiBaseUrl);
|
|
if (url.hostname === "localhost") {
|
|
url.hostname = "127.0.0.1";
|
|
}
|
|
return url.toString().replace(/\/$/, "");
|
|
}
|
|
|
|
function logDev(message: string): void {
|
|
console.warn(`${LOG_PREFIX}${message}`);
|
|
}
|
|
|
|
function stderrSupportsAnsiColor(): boolean {
|
|
return process.stderr.isTTY && process.env.NO_COLOR == null && process.env.TERM !== "dumb";
|
|
}
|
|
|
|
export function configErrorLogPrefix(supportsColor = stderrSupportsAnsiColor()): string {
|
|
const label = supportsColor ? "\x1b[41;37;1m[CONFIG ERROR]\x1b[0m" : "[CONFIG ERROR]";
|
|
return `${LOG_PREFIX}${label} `;
|
|
}
|
|
|
|
function logDevConfigError(message: string): void {
|
|
console.warn(`${configErrorLogPrefix()}${message}`);
|
|
}
|
|
|
|
function openUrlInBrowser(url: string): boolean {
|
|
try {
|
|
if (process.platform === "darwin") {
|
|
execFileSync("open", [url], { stdio: "ignore" });
|
|
return true;
|
|
}
|
|
if (process.platform === "win32") {
|
|
execFileSync("cmd", ["/c", "start", "", url], { stdio: "ignore" });
|
|
return true;
|
|
}
|
|
execFileSync("xdg-open", [url], { stdio: "ignore" });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function maybeOpenOnboardingPage(session: DashboardSessionResponse, port: number): void {
|
|
if (!session.onboarding_outstanding) {
|
|
return;
|
|
}
|
|
const url = `${dashboardUrl(port)}/new-project?project_id=${encodeURIComponent(session.project_id)}`;
|
|
const opened = openUrlInBrowser(url);
|
|
if (opened) {
|
|
logDev(`Onboarding is still pending for project ${session.project_id}. Opened: ${url}`);
|
|
} else {
|
|
logDev(`Onboarding is still pending for project ${session.project_id}. Open this URL manually: ${url}`);
|
|
}
|
|
}
|
|
|
|
function startProgressLog(message: string): ProgressLogger {
|
|
if (!process.stderr.isTTY) {
|
|
logDev(`${message}...`);
|
|
return {
|
|
stop() {
|
|
logDev(`${message}... done!`);
|
|
},
|
|
};
|
|
}
|
|
|
|
let dotCount = 0;
|
|
let stopped = false;
|
|
const render = () => {
|
|
process.stderr.write(`\r\x1b[2K${LOG_PREFIX}${message}${".".repeat(dotCount)}`);
|
|
dotCount = (dotCount + 1) % 4;
|
|
};
|
|
render();
|
|
const timer = setInterval(render, 400);
|
|
timer.unref();
|
|
|
|
return {
|
|
stop() {
|
|
if (stopped) return;
|
|
stopped = true;
|
|
clearInterval(timer);
|
|
process.stderr.write("\r\x1b[2K");
|
|
logDev(`${message}... done!`);
|
|
},
|
|
};
|
|
}
|
|
|
|
function dashboardRuntimeRoot(port: number): string {
|
|
return join(dirname(devEnvStatePath()), `${DASHBOARD_RUNTIME_DIR_NAME}-${port}`);
|
|
}
|
|
|
|
function dashboardLogPath(port: number): string {
|
|
return join(dirname(devEnvStatePath()), `rde-dashboard-${port}.log`);
|
|
}
|
|
|
|
function replaceSentinels(content: string, env: NodeJS.ProcessEnv): string {
|
|
return content.replace(SENTINEL_REGEX, (sentinel) => {
|
|
if (sentinel === USE_INLINE_ENV_VARS_SENTINEL) {
|
|
return "true";
|
|
}
|
|
if (!sentinel.startsWith(SENTINEL_PREFIX)) {
|
|
return sentinel;
|
|
}
|
|
const envVarName = sentinel.slice(SENTINEL_PREFIX.length);
|
|
const value = env[envVarName];
|
|
if (value == null) {
|
|
if (REQUIRED_DASHBOARD_RUNTIME_ENV_VARS.has(envVarName)) {
|
|
throw new CliError(`Missing environment variable ${envVarName} while preparing the bundled dashboard runtime.`);
|
|
}
|
|
return sentinel;
|
|
}
|
|
return value;
|
|
});
|
|
}
|
|
|
|
function replaceDashboardRuntimeSentinels(root: string, env: NodeJS.ProcessEnv): void {
|
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
const path = join(root, entry.name);
|
|
if (entry.isDirectory()) {
|
|
replaceDashboardRuntimeSentinels(path, env);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) {
|
|
continue;
|
|
}
|
|
|
|
const buffer = readFileSync(path);
|
|
if (!buffer.includes("STACK_ENV_VAR_SENTINEL")) {
|
|
continue;
|
|
}
|
|
writeFileSync(path, replaceSentinels(buffer.toString("utf-8"), env));
|
|
}
|
|
}
|
|
|
|
function dashboardRuntimeLockPath(port: number): string {
|
|
return `${dashboardRuntimeRoot(port)}.lock`;
|
|
}
|
|
|
|
function prepareDashboardRuntime(env: NodeJS.ProcessEnv, port: number, dashboardRoot: string): string {
|
|
if (!existsSync(join(dashboardRoot, DASHBOARD_SERVER_RELATIVE_PATH))) {
|
|
throw new CliError("The Hexclave development-environment dashboard is missing its server entrypoint.");
|
|
}
|
|
const runtimeRoot = dashboardRuntimeRoot(port);
|
|
mkdirSync(dirname(runtimeRoot), { recursive: true });
|
|
rmSync(runtimeRoot, { recursive: true, force: true });
|
|
cpSync(dashboardRoot, runtimeRoot, { recursive: true });
|
|
replaceDashboardRuntimeSentinels(runtimeRoot, env);
|
|
|
|
const runtimeServerPath = join(runtimeRoot, DASHBOARD_SERVER_RELATIVE_PATH);
|
|
if (!existsSync(runtimeServerPath)) {
|
|
throw new CliError("The Hexclave development-environment dashboard is missing its server entrypoint.");
|
|
}
|
|
return runtimeServerPath;
|
|
}
|
|
|
|
async function isDashboardReachable(url: string, secret?: string): Promise<boolean> {
|
|
try {
|
|
const headers: Record<string, string> = { Accept: "application/json" };
|
|
if (secret) {
|
|
headers.Authorization = `Bearer ${secret}`;
|
|
}
|
|
const response = await fetch(`${url}${DASHBOARD_HEALTH_PATH}`, { headers });
|
|
if (!secret) {
|
|
// Without a secret we only care whether the port is still bound (used by
|
|
// killLocalDashboard to detect process shutdown), so any HTTP response suffices.
|
|
return true;
|
|
}
|
|
const body: unknown = await response.json();
|
|
return (
|
|
typeof body === "object"
|
|
&& body !== null
|
|
&& "ok" in body
|
|
&& typeof body.ok === "boolean"
|
|
&& "restart_command" in body
|
|
&& typeof body.restart_command === "string"
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
type ParsedVersion = {
|
|
core: [number, number, number],
|
|
hasPrerelease: boolean,
|
|
};
|
|
|
|
function parseVersionCore(version: string): ParsedVersion | null {
|
|
const trimmed = version.trim();
|
|
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(trimmed);
|
|
if (!match) return null;
|
|
return {
|
|
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
// A `-` immediately after the core marks a semver prerelease (e.g.
|
|
// 2.8.109-beta.1). `.test()` returns a plain boolean, sidestepping the
|
|
// optional-capture-group typing.
|
|
hasPrerelease: /^v?\d+\.\d+\.\d+-/.test(trimmed),
|
|
};
|
|
}
|
|
|
|
// Returns true only when `candidate` is strictly newer than `current`. Unknown
|
|
// or unparseable versions return false so we never act on a version we can't
|
|
// reason about (and never downgrade). Prerelease identifiers beyond the
|
|
// "release beats same-core prerelease" rule are intentionally not ordered. Used
|
|
// by the dashboard restart check below. Exported for unit testing.
|
|
export function isVersionNewer(candidate: string, current: string): boolean {
|
|
const a = parseVersionCore(candidate);
|
|
const b = parseVersionCore(current);
|
|
if (a == null || b == null) return false;
|
|
for (let i = 0; i < 3; i++) {
|
|
if (a.core[i] !== b.core[i]) {
|
|
return a.core[i] > b.core[i];
|
|
}
|
|
}
|
|
// Same x.y.z: a final release outranks a prerelease of the same core.
|
|
return !a.hasPrerelease && b.hasPrerelease;
|
|
}
|
|
|
|
// Restart the running dashboard only when the latest published release is
|
|
// strictly newer than the one serving the port. Equal/older/unknown versions (a
|
|
// pre-feature CLI's record, or an unreachable manifest) are reused as-is.
|
|
// Exported for unit testing.
|
|
export function shouldRestartDashboard(latestVersion: string | undefined, runningVersion: string | undefined): boolean {
|
|
return latestVersion != null && runningVersion != null && isVersionNewer(latestVersion, runningVersion);
|
|
}
|
|
|
|
// Whether `pid` refers to a live process. EPERM means it exists but is owned by
|
|
// another user — i.e. the pid was recycled onto something that isn't ours.
|
|
export function processExists(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
}
|
|
}
|
|
|
|
function signalDashboardProcess(pid: number, signal: NodeJS.Signals): void {
|
|
if (process.platform !== "win32") {
|
|
try {
|
|
process.kill(-pid, signal);
|
|
return;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ESRCH") {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
process.kill(pid, signal);
|
|
}
|
|
|
|
function startDashboardProcess(options: {
|
|
dashboardEnv: NodeJS.ProcessEnv,
|
|
logFd: number,
|
|
port: number,
|
|
dashboardRoot?: string,
|
|
}): ChildProcess {
|
|
const devDashboardCommand = devDashboardCommandFromEnv(process.env);
|
|
if (devDashboardCommand != null) {
|
|
writeSync(options.logFd, `Using ${DEV_DASHBOARD_COMMAND_ENV_VAR}: ${devDashboardCommand}\n`);
|
|
return spawn(devDashboardCommand, {
|
|
cwd: process.cwd(),
|
|
detached: true,
|
|
env: options.dashboardEnv,
|
|
shell: true,
|
|
stdio: ["ignore", options.logFd, options.logFd],
|
|
});
|
|
}
|
|
|
|
if (options.dashboardRoot == null) {
|
|
throw new CliError("Internal error: the Hexclave dashboard build was not resolved before starting.");
|
|
}
|
|
const dashboardServerPath = prepareDashboardRuntime(options.dashboardEnv, options.port, options.dashboardRoot);
|
|
return spawn(process.execPath, [dashboardServerPath], {
|
|
cwd: resolve(dirname(dashboardServerPath), "../.."),
|
|
detached: true,
|
|
stdio: ["ignore", options.logFd, options.logFd],
|
|
env: options.dashboardEnv,
|
|
});
|
|
}
|
|
|
|
// Terminate the background dashboard recorded for `port` in dev-env state and
|
|
// wait until the port stops answering, so a fresh (newer) dashboard can rebind
|
|
// without EADDRINUSE.
|
|
export async function killLocalDashboard(url: string, port: number): Promise<void> {
|
|
const pid = readDevEnvState().localDashboardsByPort?.[String(port)]?.pid;
|
|
if (pid == null || pid <= 0) return;
|
|
if (!processExists(pid)) return;
|
|
|
|
try {
|
|
signalDashboardProcess(pid, "SIGTERM");
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
// ESRCH: already gone. EPERM: the pid was recycled onto a process we don't
|
|
// own, so it isn't our dashboard — don't wait on it or escalate to SIGKILL.
|
|
if (code === "ESRCH" || code === "EPERM") return;
|
|
throw error;
|
|
}
|
|
|
|
// Wait for the port to be released — that's the property that actually lets
|
|
// the replacement bind. Don't gate on the pid: once the dashboard exits its
|
|
// pid can be recycled onto an unrelated same-user process, which a pid probe
|
|
// would misreport as "still alive" (spinning the full timeout and then
|
|
// mis-targeting the SIGKILL below). isDashboardReachable only succeeds while
|
|
// the listener is up, so an unreachable port reliably means it's gone.
|
|
const startedAt = performance.now();
|
|
while (performance.now() - startedAt < DASHBOARD_STOP_TIMEOUT_MS) {
|
|
if (!(await isDashboardReachable(url))) return;
|
|
await wait(200);
|
|
}
|
|
|
|
// Still listening after the grace period — the process is genuinely hung and
|
|
// still holding the port, so the recorded pid is necessarily still valid;
|
|
// force it down, then wait for the socket to be released.
|
|
try {
|
|
signalDashboardProcess(pid, "SIGKILL");
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
const killDeadline = performance.now() + DASHBOARD_FORCE_STOP_TIMEOUT_MS;
|
|
while (performance.now() < killDeadline) {
|
|
if (!(await isDashboardReachable(url))) return;
|
|
await wait(200);
|
|
}
|
|
}
|
|
|
|
async function startDashboardIfNeeded(options: { apiBaseUrl: string, secret: string, port: number }): Promise<void> {
|
|
const url = dashboardUrl(options.port);
|
|
const devDashboardCommand = devDashboardCommandFromEnv(process.env);
|
|
|
|
// Look up the newest published release to decide whether to restart a running
|
|
// dashboard and which build to launch. Skipped for a custom dev command or a
|
|
// local-build override; a null manifest (offline) reuses the running dashboard
|
|
// or falls back to cache.
|
|
const dashboardOverride = dashboardDirOverride();
|
|
const skipReleaseLookup = devDashboardCommand != null || dashboardOverride != null;
|
|
const manifest: DashboardManifest | null = skipReleaseLookup ? null : await fetchDashboardManifest();
|
|
const latestVersion = manifest?.version;
|
|
|
|
if (await isDashboardReachable(url, options.secret)) {
|
|
const runningDashboard = readDevEnvState().localDashboardsByPort?.[String(options.port)];
|
|
const runningVersion = runningDashboard?.version;
|
|
if (devDashboardCommand != null && runningVersion != null) {
|
|
// A custom dev command should take over a release/override dashboard left
|
|
// running from a prior run. A custom-command dashboard records no version,
|
|
// so `runningVersion != null` avoids needlessly restarting that one.
|
|
logDev("A custom Hexclave dashboard command is configured; restarting the running dashboard...");
|
|
await killLocalDashboard(url, options.port);
|
|
} else if (dashboardOverride != null && runningVersion !== "local") {
|
|
// A local-build override should win over a release dashboard left running
|
|
// from a prior run; the override always resolves to version "local".
|
|
logDev("A local Hexclave dashboard override is configured; restarting the running dashboard...");
|
|
await killLocalDashboard(url, options.port);
|
|
} else if (shouldRestartDashboard(latestVersion, runningVersion)) {
|
|
logDev(`A newer Hexclave dashboard (${latestVersion}) is available; restarting from ${runningVersion}...`);
|
|
await killLocalDashboard(url, options.port);
|
|
} else {
|
|
logDev(`Using existing Hexclave dashboard on ${url}.`);
|
|
if (runningDashboard?.logPath != null) {
|
|
logDev(`Dashboard logs: ${runningDashboard.logPath}`);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Download (or reuse a cached copy of) the dashboard build to launch. Not
|
|
// needed when a custom dev dashboard command runs the dashboard itself.
|
|
const release = devDashboardCommand == null ? await resolveDashboardRuntime({ manifest }) : null;
|
|
|
|
const progress = startProgressLog(`Hexclave dashboard not found on port ${options.port}. Starting now`);
|
|
const dashboardEnv = {
|
|
...process.env,
|
|
NODE_ENV: devDashboardCommand == null ? "production" : "development",
|
|
PORT: String(options.port),
|
|
HOSTNAME: "0.0.0.0",
|
|
[DEV_DASHBOARD_DIST_DIR_ENV_VAR]: process.env[DEV_DASHBOARD_DIST_DIR_ENV_VAR] ?? ".next-development-environment",
|
|
STACK_API_URL: options.apiBaseUrl,
|
|
NEXT_PUBLIC_STACK_API_URL: options.apiBaseUrl,
|
|
NEXT_PUBLIC_BROWSER_STACK_API_URL: options.apiBaseUrl,
|
|
NEXT_PUBLIC_SERVER_STACK_API_URL: options.apiBaseUrl,
|
|
NEXT_PUBLIC_STACK_DASHBOARD_URL: url,
|
|
NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL: url,
|
|
NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL: url,
|
|
NEXT_PUBLIC_STACK_PROJECT_ID: "internal",
|
|
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: DEFAULT_PUBLISHABLE_CLIENT_KEY,
|
|
NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT: "true",
|
|
NEXT_PUBLIC_STACK_IS_PREVIEW: "false",
|
|
[DASHBOARD_PORT_ENV_VAR]: String(options.port),
|
|
[RDE_DASHBOARD_LOG_PATH_ENV_VAR]: dashboardLogPath(options.port),
|
|
};
|
|
try {
|
|
const logPath = dashboardLogPath(options.port);
|
|
mkdirSync(dirname(logPath), { recursive: true });
|
|
const logFd = openSync(logPath, "a", 0o600);
|
|
chmodSync(logPath, 0o600);
|
|
writeSync(logFd, `\n[${new Date().toISOString()}] Starting Hexclave development-environment dashboard on ${url}\n`);
|
|
// Acquire a filesystem lock so parallel `hexclave dev` invocations don't
|
|
// race on the runtime directory. openSync with 'wx' is an atomic
|
|
// exclusive-create; EEXIST means another process holds the lock.
|
|
let lockAcquired = false;
|
|
const lockPath = dashboardRuntimeLockPath(options.port);
|
|
// Remove stale lock left behind if a previous process was killed mid-prepare
|
|
// (normal hold time is <1 s, so 5 s is certainly stale).
|
|
try {
|
|
const lockStat = statSync(lockPath);
|
|
if (Date.now() - lockStat.mtimeMs > 5000) {
|
|
unlinkSync(lockPath);
|
|
}
|
|
} catch {
|
|
// lock doesn't exist or was already removed — fine
|
|
}
|
|
try {
|
|
closeSync(openSync(lockPath, "wx"));
|
|
lockAcquired = true;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
}
|
|
|
|
if (!lockAcquired) {
|
|
closeSync(logFd);
|
|
logDev("Another process is starting the dashboard; waiting for it...");
|
|
} else {
|
|
try {
|
|
const child = (() => {
|
|
try {
|
|
return startDashboardProcess({ dashboardEnv, logFd, port: options.port, dashboardRoot: release?.root });
|
|
} finally {
|
|
closeSync(logFd);
|
|
}
|
|
})();
|
|
if (child.pid == null) {
|
|
throw new CliError(`Failed to start the development environment dashboard process. Dashboard logs: ${logPath}`);
|
|
}
|
|
recordLocalDashboardProcess(options.port, options.secret, child.pid, logPath, release?.version);
|
|
logDev(`Dashboard logs: ${logPath}`);
|
|
child.unref();
|
|
} finally {
|
|
try {
|
|
unlinkSync(lockPath);
|
|
} catch {
|
|
// best-effort cleanup
|
|
}
|
|
}
|
|
}
|
|
|
|
const startedAt = performance.now();
|
|
while (performance.now() - startedAt < DASHBOARD_START_TIMEOUT_MS) {
|
|
if (await isDashboardReachable(url, options.secret)) {
|
|
progress.stop(`Started Hexclave dashboard`);
|
|
return;
|
|
}
|
|
await wait(500);
|
|
}
|
|
|
|
throw new CliError(`Timed out waiting for the development environment dashboard to start at ${url}. Dashboard logs: ${logPath}`);
|
|
} catch (error) {
|
|
progress.stop();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function isConfigSyncEvent(value: unknown): value is ConfigSyncEvent {
|
|
if (
|
|
!isRecord(value) ||
|
|
!("config_file_path" in value) ||
|
|
typeof value.config_file_path !== "string" ||
|
|
!("status" in value) ||
|
|
!("created_at_millis" in value) ||
|
|
typeof value.created_at_millis !== "number"
|
|
) {
|
|
return false;
|
|
}
|
|
if (value.status === "success") {
|
|
return true;
|
|
}
|
|
return (
|
|
value.status === "error" &&
|
|
"error_message" in value &&
|
|
typeof value.error_message === "string"
|
|
);
|
|
}
|
|
|
|
export function isHeartbeatResponse(value: unknown): value is HeartbeatResponse {
|
|
return (
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
!Array.isArray(value) &&
|
|
"ok" in value &&
|
|
value.ok === true &&
|
|
(
|
|
!("browser_secret_confirmation_code" in value) ||
|
|
typeof value.browser_secret_confirmation_code === "string"
|
|
) &&
|
|
(
|
|
!("browser_secret_confirmation_code_expires_at_millis" in value) ||
|
|
typeof value.browser_secret_confirmation_code_expires_at_millis === "number"
|
|
) &&
|
|
(
|
|
!("config_sync_events" in value) ||
|
|
(Array.isArray(value.config_sync_events) && value.config_sync_events.every(isConfigSyncEvent))
|
|
)
|
|
);
|
|
}
|
|
|
|
function logBrowserSecretConfirmationCode(response: HeartbeatResponse): void {
|
|
if (response.browser_secret_confirmation_code == null) return;
|
|
const expiresAtMillis = response.browser_secret_confirmation_code_expires_at_millis;
|
|
const expiresInSeconds = expiresAtMillis == null
|
|
? undefined
|
|
: Math.max(0, Math.ceil((expiresAtMillis - Date.now()) / 1000));
|
|
logDev(expiresInSeconds == null
|
|
? `Dashboard browser confirmation code: ${response.browser_secret_confirmation_code}`
|
|
: `Dashboard browser confirmation code: ${response.browser_secret_confirmation_code} (expires in ${expiresInSeconds}s)`);
|
|
}
|
|
|
|
function logConfigSyncEvents(response: HeartbeatResponse): void {
|
|
for (const event of response.config_sync_events ?? []) {
|
|
if (event.status === "success") {
|
|
logDev(`Config synced to development environment project: ${event.config_file_path}`);
|
|
} else {
|
|
logDevConfigError(`Config sync failed for ${event.config_file_path}: ${event.error_message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function pendingBrowserSecretConfirmationCodeFromState(port: number): HeartbeatResponse | null {
|
|
const pending = readDevEnvState().pendingBrowserSecretConfirmationCodesByPort?.[String(port)];
|
|
if (pending == null || pending.expiresAtMillis <= Date.now()) {
|
|
return null;
|
|
}
|
|
return {
|
|
ok: true,
|
|
browser_secret_confirmation_code: pending.code,
|
|
browser_secret_confirmation_code_expires_at_millis: pending.expiresAtMillis,
|
|
};
|
|
}
|
|
|
|
function maybeLogPendingBrowserSecretConfirmationCodeFromState(port: number, lastLoggedConfirmationCode: string | null): string | null {
|
|
const pending = pendingBrowserSecretConfirmationCodeFromState(port);
|
|
const code = pending?.browser_secret_confirmation_code;
|
|
if (code == null || code === lastLoggedConfirmationCode) {
|
|
return lastLoggedConfirmationCode;
|
|
}
|
|
if (pending == null) {
|
|
return lastLoggedConfirmationCode;
|
|
}
|
|
logBrowserSecretConfirmationCode(pending);
|
|
return code;
|
|
}
|
|
|
|
async function logPendingBrowserSecretConfirmationCodesUntilStopped(options: {
|
|
port: number,
|
|
shouldStop: () => boolean,
|
|
}): Promise<void> {
|
|
let lastLoggedConfirmationCode: string | null = null;
|
|
while (!options.shouldStop()) {
|
|
lastLoggedConfirmationCode = maybeLogPendingBrowserSecretConfirmationCodeFromState(options.port, lastLoggedConfirmationCode);
|
|
await wait(1_000);
|
|
}
|
|
}
|
|
|
|
const APP_COMMAND_WRAPPER_PARENT_PID_ENV_VAR = "HEXCLAVE_DEV_APP_COMMAND_PARENT_PID";
|
|
const APP_COMMAND_WRAPPER_COMMAND_ENV_VAR = "HEXCLAVE_DEV_APP_COMMAND";
|
|
const APP_COMMAND_WRAPPER_ARGS_ENV_VAR = "HEXCLAVE_DEV_APP_COMMAND_ARGS_JSON";
|
|
|
|
const APP_COMMAND_WRAPPER_SCRIPT = String.raw`
|
|
const { spawn } = require("node:child_process");
|
|
|
|
const parentPid = Number(process.env.HEXCLAVE_DEV_APP_COMMAND_PARENT_PID);
|
|
const command = process.env.HEXCLAVE_DEV_APP_COMMAND;
|
|
const rawArgs = process.env.HEXCLAVE_DEV_APP_COMMAND_ARGS_JSON ?? "[]";
|
|
if (!Number.isSafeInteger(parentPid) || parentPid <= 0 || !command) {
|
|
console.error("[Hexclave] Invalid app-command wrapper configuration.");
|
|
process.exit(1);
|
|
}
|
|
|
|
let args;
|
|
try {
|
|
args = JSON.parse(rawArgs);
|
|
} catch (error) {
|
|
console.error("[Hexclave] Invalid app-command argument payload.", error);
|
|
process.exit(1);
|
|
}
|
|
if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string")) {
|
|
console.error("[Hexclave] Invalid app-command arguments.");
|
|
process.exit(1);
|
|
}
|
|
|
|
const childEnv = { ...process.env };
|
|
delete childEnv.HEXCLAVE_DEV_APP_COMMAND_PARENT_PID;
|
|
delete childEnv.HEXCLAVE_DEV_APP_COMMAND;
|
|
delete childEnv.HEXCLAVE_DEV_APP_COMMAND_ARGS_JSON;
|
|
|
|
const child = spawn(command, args, {
|
|
env: childEnv,
|
|
stdio: "inherit",
|
|
});
|
|
|
|
let stopping = false;
|
|
let forceKillTimer;
|
|
|
|
function signalOwnProcessGroup(signal) {
|
|
try {
|
|
process.kill(-process.pid, signal);
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
|
|
function stopProcessGroup(signal) {
|
|
if (stopping) return;
|
|
stopping = true;
|
|
signalOwnProcessGroup(signal);
|
|
forceKillTimer = setTimeout(() => signalOwnProcessGroup("SIGKILL"), 5000);
|
|
forceKillTimer.unref();
|
|
}
|
|
|
|
process.on("SIGINT", () => stopProcessGroup("SIGINT"));
|
|
process.on("SIGTERM", () => stopProcessGroup("SIGTERM"));
|
|
|
|
const parentWatch = setInterval(() => {
|
|
try {
|
|
process.kill(parentPid, 0);
|
|
} catch {
|
|
stopProcessGroup("SIGTERM");
|
|
}
|
|
}, 1000);
|
|
parentWatch.unref();
|
|
|
|
child.on("close", (code, signal) => {
|
|
clearInterval(parentWatch);
|
|
if (forceKillTimer != null) clearTimeout(forceKillTimer);
|
|
if (code != null) {
|
|
process.exit(code);
|
|
}
|
|
if (signal === "SIGINT") process.exit(130);
|
|
if (signal === "SIGTERM") process.exit(143);
|
|
if (signal === "SIGKILL") process.exit(137);
|
|
process.exit(1);
|
|
});
|
|
|
|
child.on("error", (error) => {
|
|
console.error("[Hexclave] Failed to run app command:", error);
|
|
process.exit(1);
|
|
});
|
|
`;
|
|
|
|
function runChildProcess(command: ChildCommand, env: NodeJS.ProcessEnv): Promise<number> {
|
|
return new Promise((resolvePromise, reject) => {
|
|
const child = process.platform === "win32"
|
|
? spawn(command.command, command.args, { stdio: "inherit", env })
|
|
: spawn(process.execPath, ["-e", APP_COMMAND_WRAPPER_SCRIPT], {
|
|
detached: true,
|
|
stdio: "inherit",
|
|
env: {
|
|
...env,
|
|
[APP_COMMAND_WRAPPER_PARENT_PID_ENV_VAR]: String(process.pid),
|
|
[APP_COMMAND_WRAPPER_COMMAND_ENV_VAR]: command.command,
|
|
[APP_COMMAND_WRAPPER_ARGS_ENV_VAR]: JSON.stringify(command.args),
|
|
},
|
|
});
|
|
const cleanup = forwardSignals(child, {
|
|
forceKillAfterMs: 5_000,
|
|
processGroup: process.platform !== "win32",
|
|
});
|
|
child.on("close", (code) => {
|
|
cleanup();
|
|
resolvePromise(code ?? 1);
|
|
});
|
|
child.on("error", (err) => {
|
|
cleanup();
|
|
reject(new CliError(`Failed to run ${command.command}: ${err.message}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function restartDashboardForHeartbeat(options: {
|
|
apiBaseUrl: string,
|
|
configFilePath: string,
|
|
dashboardReachableSinceMs: number,
|
|
port: number,
|
|
secret: string,
|
|
}): Promise<DashboardSessionResponse> {
|
|
const dashboardUptimeMs = performance.now() - options.dashboardReachableSinceMs;
|
|
if (dashboardUptimeMs < DASHBOARD_RESTART_MIN_UPTIME_MS) {
|
|
throw new CliError(`Local Hexclave dashboard stopped before it had been running for ${DASHBOARD_RESTART_MIN_UPTIME_MS / 1000} seconds. Not restarting to avoid a restart loop.`);
|
|
}
|
|
|
|
logDev("Local Hexclave dashboard stopped. Restarting...");
|
|
await startDashboardIfNeeded({ apiBaseUrl: options.apiBaseUrl, secret: options.secret, port: options.port });
|
|
return await createRemoteDevelopmentEnvironmentSession({
|
|
apiBaseUrl: options.apiBaseUrl,
|
|
configFilePath: options.configFilePath,
|
|
port: options.port,
|
|
secret: options.secret,
|
|
});
|
|
}
|
|
|
|
async function waitForHeartbeatIntervalOrStop(shouldStop: () => boolean): Promise<boolean> {
|
|
const startedAtMs = performance.now();
|
|
while (!shouldStop()) {
|
|
const remainingMs = HEARTBEAT_INTERVAL_MS - (performance.now() - startedAtMs);
|
|
if (remainingMs <= 0) return false;
|
|
await wait(Math.min(remainingMs, HEARTBEAT_STOP_POLL_MS));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function heartbeatUntilStopped(sessionState: DashboardSessionState, options: {
|
|
apiBaseUrl: string,
|
|
configFilePath: string,
|
|
port: number,
|
|
secret: string,
|
|
shouldStop: () => boolean,
|
|
}): Promise<void> {
|
|
let lastLoggedConfirmationCode: string | null = null;
|
|
let heartbeatAttempt = 0;
|
|
while (!options.shouldStop()) {
|
|
if (await waitForHeartbeatIntervalOrStop(options.shouldStop)) {
|
|
return;
|
|
}
|
|
lastLoggedConfirmationCode = maybeLogPendingBrowserSecretConfirmationCodeFromState(options.port, lastLoggedConfirmationCode);
|
|
heartbeatAttempt += 1;
|
|
|
|
let response: Response;
|
|
const controller = new AbortController();
|
|
const abortOnStop = setInterval(() => {
|
|
if (options.shouldStop()) {
|
|
controller.abort();
|
|
}
|
|
}, HEARTBEAT_STOP_POLL_MS);
|
|
try {
|
|
response = await dashboardRequest(`/api/remote-development-environment/sessions/${encodeURIComponent(sessionState.session.session_id)}/heartbeat`, {
|
|
method: "POST",
|
|
signal: controller.signal,
|
|
}, options.secret, options.port);
|
|
} catch (error) {
|
|
lastLoggedConfirmationCode = maybeLogPendingBrowserSecretConfirmationCodeFromState(options.port, lastLoggedConfirmationCode);
|
|
if (options.shouldStop()) return;
|
|
sessionState.session = await restartDashboardForHeartbeat({
|
|
apiBaseUrl: options.apiBaseUrl,
|
|
configFilePath: options.configFilePath,
|
|
dashboardReachableSinceMs: sessionState.dashboardReachableSinceMs,
|
|
port: options.port,
|
|
secret: options.secret,
|
|
});
|
|
sessionState.dashboardReachableSinceMs = performance.now();
|
|
logDev(`Hexclave dashboard running at ${dashboardUrl(options.port)}`);
|
|
continue;
|
|
} finally {
|
|
clearInterval(abortOnStop);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
logDev(`Development environment heartbeat failed (${response.status}): ${await response.text()}`);
|
|
sessionState.session = await restartDashboardForHeartbeat({
|
|
apiBaseUrl: options.apiBaseUrl,
|
|
configFilePath: options.configFilePath,
|
|
dashboardReachableSinceMs: sessionState.dashboardReachableSinceMs,
|
|
port: options.port,
|
|
secret: options.secret,
|
|
});
|
|
sessionState.dashboardReachableSinceMs = performance.now();
|
|
logDev(`Hexclave dashboard running at ${dashboardUrl(options.port)}`);
|
|
continue;
|
|
}
|
|
|
|
let heartbeatBody: unknown;
|
|
try {
|
|
heartbeatBody = await response.json();
|
|
} catch {
|
|
logDev("Development environment heartbeat returned unparseable JSON.");
|
|
continue;
|
|
}
|
|
if (!isHeartbeatResponse(heartbeatBody)) {
|
|
logDev("Development environment heartbeat returned an invalid response.");
|
|
continue;
|
|
}
|
|
// Deduplicate: only log a confirmation code once per unique code value.
|
|
if (heartbeatBody.browser_secret_confirmation_code != null &&
|
|
heartbeatBody.browser_secret_confirmation_code !== lastLoggedConfirmationCode) {
|
|
logBrowserSecretConfirmationCode(heartbeatBody);
|
|
lastLoggedConfirmationCode = heartbeatBody.browser_secret_confirmation_code;
|
|
}
|
|
logConfigSyncEvents(heartbeatBody);
|
|
}
|
|
}
|
|
|
|
async function closeSession(sessionId: string, secret: string, port: number): Promise<void> {
|
|
let response: Response;
|
|
try {
|
|
response = await dashboardRequest(`/api/remote-development-environment/sessions/${encodeURIComponent(sessionId)}`, {
|
|
method: "DELETE",
|
|
}, secret, port);
|
|
} catch (error) {
|
|
logDev(`Failed to close development environment session: ${errorMessage(error)}`);
|
|
return;
|
|
}
|
|
if (!response.ok) {
|
|
logDev(`Failed to close development environment session (${response.status}): ${await response.text()}`);
|
|
}
|
|
}
|
|
|
|
|
|
export async function run(commandArgs: string[], opts: DevOptions) {
|
|
|
|
if (opts.configFile == null) {
|
|
throw new CliError("--config-file is required.");
|
|
}
|
|
|
|
const childCommand = splitDevCommandArgs(commandArgs);
|
|
const port = dashboardPort();
|
|
const localDashboardUrl = dashboardUrl(port);
|
|
const secret = ensureLocalDashboardSecret(port);
|
|
const config = resolveLoginConfig();
|
|
const apiBaseUrl = normalizeApiBaseUrl(config.apiUrl || DEFAULT_API_URL);
|
|
const configFilePath = resolveConfigFilePathOption(opts.configFile, { mustExist: false });
|
|
await startDashboardIfNeeded({ apiBaseUrl, secret, port });
|
|
const sessionState: DashboardSessionState = {
|
|
session: await createRemoteDevelopmentEnvironmentSession({
|
|
apiBaseUrl,
|
|
configFilePath,
|
|
port,
|
|
secret,
|
|
}),
|
|
dashboardReachableSinceMs: performance.now(),
|
|
};
|
|
logDev(`Hexclave dashboard running at ${localDashboardUrl}`);
|
|
maybeOpenOnboardingPage(sessionState.session, port);
|
|
|
|
let stopped = false;
|
|
const heartbeat = heartbeatUntilStopped(sessionState, {
|
|
apiBaseUrl,
|
|
configFilePath,
|
|
port,
|
|
secret,
|
|
shouldStop: () => stopped,
|
|
});
|
|
const browserSecretCodePolling = logPendingBrowserSecretConfirmationCodesUntilStopped({
|
|
port,
|
|
shouldStop: () => stopped,
|
|
});
|
|
let exitCode = 1;
|
|
try {
|
|
exitCode = await runChildProcess(childCommand, {
|
|
...process.env,
|
|
...sessionState.session.env,
|
|
});
|
|
} finally {
|
|
stopped = true;
|
|
await Promise.all([heartbeat, browserSecretCodePolling]);
|
|
await closeSession(sessionState.session.session_id, secret, port);
|
|
}
|
|
process.exit(exitCode);
|
|
|
|
}
|