Merge pull request #92 from DenchHQ/kumareth/web-ui-launchagent
feat(cli): persist web UI across reboots via macOS LaunchAgent
This commit is contained in:
commit
ac033c1b21
@ -72,6 +72,20 @@ vi.mock("node:child_process", () => ({
|
||||
spawn: spawnMock,
|
||||
}));
|
||||
|
||||
const launchdMocks = vi.hoisted(() => ({
|
||||
installWebRuntimeLaunchAgent: vi.fn(() => ({
|
||||
started: true,
|
||||
pid: 7788,
|
||||
runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js",
|
||||
})),
|
||||
uninstallWebRuntimeLaunchAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./web-runtime-launchd.js", () => ({
|
||||
installWebRuntimeLaunchAgent: launchdMocks.installWebRuntimeLaunchAgent,
|
||||
uninstallWebRuntimeLaunchAgent: launchdMocks.uninstallWebRuntimeLaunchAgent,
|
||||
}));
|
||||
|
||||
vi.mock("./web-runtime.js", () => ({
|
||||
DEFAULT_WEB_APP_PORT: webRuntimeMocks.DEFAULT_WEB_APP_PORT,
|
||||
ensureManagedWebRuntime: webRuntimeMocks.ensureManagedWebRuntime,
|
||||
@ -131,6 +145,14 @@ describe("updateWebRuntimeCommand", () => {
|
||||
promptMocks.isCancel.mockReset();
|
||||
promptMocks.isCancel.mockImplementation(() => false);
|
||||
|
||||
launchdMocks.installWebRuntimeLaunchAgent.mockReset();
|
||||
launchdMocks.installWebRuntimeLaunchAgent.mockReturnValue({
|
||||
started: true,
|
||||
pid: 7788,
|
||||
runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js",
|
||||
});
|
||||
launchdMocks.uninstallWebRuntimeLaunchAgent.mockReset();
|
||||
|
||||
workspaceSeedMocks.discoverWorkspaceDirs.mockReset();
|
||||
workspaceSeedMocks.discoverWorkspaceDirs.mockReturnValue(["/tmp/.openclaw-dench/workspace"]);
|
||||
workspaceSeedMocks.syncManagedSkills.mockReset();
|
||||
@ -324,6 +346,13 @@ describe("startWebRuntimeCommand", () => {
|
||||
reason: string;
|
||||
},
|
||||
);
|
||||
launchdMocks.installWebRuntimeLaunchAgent.mockReset();
|
||||
launchdMocks.installWebRuntimeLaunchAgent.mockReturnValue({
|
||||
started: true,
|
||||
pid: 7788,
|
||||
runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js",
|
||||
});
|
||||
launchdMocks.uninstallWebRuntimeLaunchAgent.mockReset();
|
||||
});
|
||||
|
||||
it("fails closed when non-dench listeners still own the port (prevents cross-process takeover)", async () => {
|
||||
@ -340,11 +369,13 @@ describe("startWebRuntimeCommand", () => {
|
||||
});
|
||||
|
||||
it("fails with actionable remediation when managed runtime is missing (requires explicit update/bootstrap)", async () => {
|
||||
webRuntimeMocks.startManagedWebRuntime.mockReturnValue({
|
||||
started: false,
|
||||
const missingResult = {
|
||||
started: false as const,
|
||||
runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js",
|
||||
reason: "runtime-missing",
|
||||
});
|
||||
};
|
||||
webRuntimeMocks.startManagedWebRuntime.mockReturnValue(missingResult);
|
||||
launchdMocks.installWebRuntimeLaunchAgent.mockReturnValue(missingResult);
|
||||
const runtime = runtimeStub();
|
||||
|
||||
await expect(startWebRuntimeCommand({}, runtime)).rejects.toThrow("npx denchclaw update");
|
||||
@ -365,7 +396,11 @@ describe("startWebRuntimeCommand", () => {
|
||||
port: 3100,
|
||||
includeLegacyStandalone: true,
|
||||
});
|
||||
expect(webRuntimeMocks.startManagedWebRuntime).toHaveBeenCalledWith({
|
||||
const startMock =
|
||||
process.platform === "darwin"
|
||||
? launchdMocks.installWebRuntimeLaunchAgent
|
||||
: webRuntimeMocks.startManagedWebRuntime;
|
||||
expect(startMock).toHaveBeenCalledWith({
|
||||
stateDir: "/tmp/.openclaw-dench",
|
||||
port: 3100,
|
||||
gatewayPort: 19001,
|
||||
@ -386,7 +421,11 @@ describe("startWebRuntimeCommand", () => {
|
||||
const runtime = runtimeStub();
|
||||
await startWebRuntimeCommand({ webPort: "3100" }, runtime);
|
||||
|
||||
expect(webRuntimeMocks.startManagedWebRuntime).toHaveBeenCalledWith(
|
||||
const startMock =
|
||||
process.platform === "darwin"
|
||||
? launchdMocks.installWebRuntimeLaunchAgent
|
||||
: webRuntimeMocks.startManagedWebRuntime;
|
||||
expect(startMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gatewayPort: 19001 }),
|
||||
);
|
||||
});
|
||||
@ -396,7 +435,11 @@ describe("startWebRuntimeCommand", () => {
|
||||
const runtime = runtimeStub();
|
||||
await startWebRuntimeCommand({ webPort: "3100" }, runtime);
|
||||
|
||||
expect(webRuntimeMocks.startManagedWebRuntime).toHaveBeenCalledWith(
|
||||
const startMock =
|
||||
process.platform === "darwin"
|
||||
? launchdMocks.installWebRuntimeLaunchAgent
|
||||
: webRuntimeMocks.startManagedWebRuntime;
|
||||
expect(startMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gatewayPort: 19001 }),
|
||||
);
|
||||
});
|
||||
@ -428,6 +471,13 @@ describe("restartWebRuntimeCommand", () => {
|
||||
reason: string;
|
||||
},
|
||||
);
|
||||
launchdMocks.installWebRuntimeLaunchAgent.mockReset();
|
||||
launchdMocks.installWebRuntimeLaunchAgent.mockReturnValue({
|
||||
started: true,
|
||||
pid: 7788,
|
||||
runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js",
|
||||
});
|
||||
launchdMocks.uninstallWebRuntimeLaunchAgent.mockReset();
|
||||
});
|
||||
|
||||
it("stops and restarts managed runtime (same stop+start lifecycle as start command)", async () => {
|
||||
@ -444,7 +494,11 @@ describe("restartWebRuntimeCommand", () => {
|
||||
port: 3100,
|
||||
includeLegacyStandalone: true,
|
||||
});
|
||||
expect(webRuntimeMocks.startManagedWebRuntime).toHaveBeenCalledWith({
|
||||
const startMock =
|
||||
process.platform === "darwin"
|
||||
? launchdMocks.installWebRuntimeLaunchAgent
|
||||
: webRuntimeMocks.startManagedWebRuntime;
|
||||
expect(startMock).toHaveBeenCalledWith({
|
||||
stateDir: "/tmp/.openclaw-dench",
|
||||
port: 3100,
|
||||
gatewayPort: 19001,
|
||||
|
||||
@ -24,6 +24,10 @@ import {
|
||||
stopManagedWebRuntime,
|
||||
waitForWebRuntime,
|
||||
} from "./web-runtime.js";
|
||||
import {
|
||||
installWebRuntimeLaunchAgent,
|
||||
uninstallWebRuntimeLaunchAgent,
|
||||
} from "./web-runtime-launchd.js";
|
||||
import { discoverWorkspaceDirs, syncManagedSkills, type SkillSyncResult } from "./workspace-seed.js";
|
||||
|
||||
type SpawnResult = {
|
||||
@ -399,6 +403,11 @@ export async function updateWebRuntimeCommand(
|
||||
readLastKnownWebPort(stateDir) ??
|
||||
DEFAULT_WEB_APP_PORT;
|
||||
const gatewayPort = resolveGatewayPort(stateDir);
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
uninstallWebRuntimeLaunchAgent();
|
||||
}
|
||||
|
||||
const stopResult = await stopManagedWebRuntime({
|
||||
stateDir,
|
||||
port: selectedPort,
|
||||
@ -420,6 +429,10 @@ export async function updateWebRuntimeCommand(
|
||||
denchVersion: VERSION,
|
||||
port: selectedPort,
|
||||
gatewayPort,
|
||||
startFn:
|
||||
process.platform === "darwin"
|
||||
? (p) => installWebRuntimeLaunchAgent(p)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const summary: UpdateWebRuntimeSummary = {
|
||||
@ -494,6 +507,11 @@ export async function stopWebRuntimeCommand(
|
||||
|
||||
const stateDir = resolveProfileStateDir(profile);
|
||||
const selectedPort = parseOptionalPort(opts.webPort) ?? readLastKnownWebPort(stateDir);
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
uninstallWebRuntimeLaunchAgent();
|
||||
}
|
||||
|
||||
const stopResult = await stopManagedWebRuntime({
|
||||
stateDir,
|
||||
port: selectedPort,
|
||||
@ -556,6 +574,10 @@ export async function startWebRuntimeCommand(
|
||||
const selectedPort = parseOptionalPort(opts.webPort) ?? readLastKnownWebPort(stateDir);
|
||||
const gatewayPort = resolveGatewayPort(stateDir);
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
uninstallWebRuntimeLaunchAgent();
|
||||
}
|
||||
|
||||
const stopResult = await stopManagedWebRuntime({
|
||||
stateDir,
|
||||
port: selectedPort,
|
||||
@ -574,11 +596,15 @@ export async function startWebRuntimeCommand(
|
||||
json: Boolean(opts.json),
|
||||
});
|
||||
|
||||
const startResult = startManagedWebRuntime({
|
||||
stateDir,
|
||||
port: selectedPort,
|
||||
gatewayPort,
|
||||
});
|
||||
let startResult;
|
||||
if (process.platform === "darwin") {
|
||||
startResult = installWebRuntimeLaunchAgent({ stateDir, port: selectedPort, gatewayPort });
|
||||
if (!startResult.started && startResult.reason !== "runtime-missing") {
|
||||
startResult = startManagedWebRuntime({ stateDir, port: selectedPort, gatewayPort });
|
||||
}
|
||||
} else {
|
||||
startResult = startManagedWebRuntime({ stateDir, port: selectedPort, gatewayPort });
|
||||
}
|
||||
|
||||
if (!startResult.started) {
|
||||
const runtimeServerPath = resolveManagedWebRuntimeServerPath(stateDir);
|
||||
|
||||
193
src/cli/web-runtime-launchd.ts
Normal file
193
src/cli/web-runtime-launchd.ts
Normal file
@ -0,0 +1,193 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import {
|
||||
resolveManagedWebRuntimeServerPath,
|
||||
updateManifestLastPort,
|
||||
writeManagedWebRuntimeProcess,
|
||||
type StartManagedWebRuntimeResult,
|
||||
} from "./web-runtime.js";
|
||||
|
||||
const LAUNCH_AGENT_LABEL = "ai.denchclaw.web-runtime";
|
||||
|
||||
export function resolveLaunchAgentPlistPath(): string {
|
||||
return path.join(
|
||||
os.homedir(),
|
||||
"Library",
|
||||
"LaunchAgents",
|
||||
`${LAUNCH_AGENT_LABEL}.plist`,
|
||||
);
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function buildPlistXml(params: {
|
||||
nodePath: string;
|
||||
serverPath: string;
|
||||
workingDirectory: string;
|
||||
port: number;
|
||||
gatewayPort: number;
|
||||
stdoutPath: string;
|
||||
stderrPath: string;
|
||||
}): string {
|
||||
const nodeDir = path.dirname(params.nodePath);
|
||||
const envPath = [nodeDir, "/usr/local/bin", "/usr/bin", "/bin"]
|
||||
.filter((seg, i, arr) => arr.indexOf(seg) === i)
|
||||
.join(":");
|
||||
|
||||
return [
|
||||
`<?xml version="1.0" encoding="UTF-8"?>`,
|
||||
`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
|
||||
`<plist version="1.0">`,
|
||||
`<dict>`,
|
||||
` <key>Label</key>`,
|
||||
` <string>${escapeXml(LAUNCH_AGENT_LABEL)}</string>`,
|
||||
` <key>ProgramArguments</key>`,
|
||||
` <array>`,
|
||||
` <string>${escapeXml(params.nodePath)}</string>`,
|
||||
` <string>${escapeXml(params.serverPath)}</string>`,
|
||||
` </array>`,
|
||||
` <key>WorkingDirectory</key>`,
|
||||
` <string>${escapeXml(params.workingDirectory)}</string>`,
|
||||
` <key>EnvironmentVariables</key>`,
|
||||
` <dict>`,
|
||||
` <key>PORT</key>`,
|
||||
` <string>${params.port}</string>`,
|
||||
` <key>HOSTNAME</key>`,
|
||||
` <string>127.0.0.1</string>`,
|
||||
` <key>OPENCLAW_GATEWAY_PORT</key>`,
|
||||
` <string>${params.gatewayPort}</string>`,
|
||||
` <key>NODE_ENV</key>`,
|
||||
` <string>production</string>`,
|
||||
` <key>PATH</key>`,
|
||||
` <string>${escapeXml(envPath)}</string>`,
|
||||
` </dict>`,
|
||||
` <key>RunAtLoad</key>`,
|
||||
` <true/>`,
|
||||
` <key>StandardOutPath</key>`,
|
||||
` <string>${escapeXml(params.stdoutPath)}</string>`,
|
||||
` <key>StandardErrorPath</key>`,
|
||||
` <string>${escapeXml(params.stderrPath)}</string>`,
|
||||
`</dict>`,
|
||||
`</plist>`,
|
||||
``,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function isWebRuntimeLaunchAgentLoaded(): boolean {
|
||||
try {
|
||||
execFileSync("launchctl", ["list", LAUNCH_AGENT_LABEL], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLaunchAgentPid(): number | null {
|
||||
try {
|
||||
const output = execFileSync("launchctl", ["list", LAUNCH_AGENT_LABEL], {
|
||||
encoding: "utf-8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const match = output.match(/"PID"\s*=\s*(\d+)/);
|
||||
if (match?.[1]) {
|
||||
const pid = Number.parseInt(match[1], 10);
|
||||
if (Number.isFinite(pid) && pid > 0) return pid;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function uninstallWebRuntimeLaunchAgent(): void {
|
||||
const plistPath = resolveLaunchAgentPlistPath();
|
||||
|
||||
if (isWebRuntimeLaunchAgentLoaded()) {
|
||||
try {
|
||||
execFileSync("launchctl", ["unload", "-w", plistPath], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch {
|
||||
try {
|
||||
execFileSync("launchctl", ["remove", LAUNCH_AGENT_LABEL], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rmSync(plistPath, { force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a macOS LaunchAgent for the web runtime so it auto-starts on login.
|
||||
* Writes the plist to ~/Library/LaunchAgents/ and loads it via launchctl.
|
||||
* RunAtLoad causes launchd to start the process immediately on load.
|
||||
*/
|
||||
export function installWebRuntimeLaunchAgent(params: {
|
||||
stateDir: string;
|
||||
port: number;
|
||||
gatewayPort: number;
|
||||
}): StartManagedWebRuntimeResult {
|
||||
const runtimeServerPath = resolveManagedWebRuntimeServerPath(params.stateDir);
|
||||
if (!existsSync(runtimeServerPath)) {
|
||||
return { started: false, runtimeServerPath, reason: "runtime-missing" };
|
||||
}
|
||||
|
||||
const appDir = path.dirname(runtimeServerPath);
|
||||
const logsDir = path.join(params.stateDir, "logs");
|
||||
mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
uninstallWebRuntimeLaunchAgent();
|
||||
|
||||
const plistPath = resolveLaunchAgentPlistPath();
|
||||
mkdirSync(path.dirname(plistPath), { recursive: true });
|
||||
|
||||
const plistXml = buildPlistXml({
|
||||
nodePath: process.execPath,
|
||||
serverPath: runtimeServerPath,
|
||||
workingDirectory: appDir,
|
||||
port: params.port,
|
||||
gatewayPort: params.gatewayPort,
|
||||
stdoutPath: path.join(logsDir, "web-app.log"),
|
||||
stderrPath: path.join(logsDir, "web-app.err.log"),
|
||||
});
|
||||
|
||||
writeFileSync(plistPath, plistXml, "utf-8");
|
||||
|
||||
try {
|
||||
execFileSync("launchctl", ["load", "-w", plistPath], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch {
|
||||
rmSync(plistPath, { force: true });
|
||||
return { started: false, runtimeServerPath, reason: "launchctl-load-failed" };
|
||||
}
|
||||
|
||||
const pid = resolveLaunchAgentPid() ?? -1;
|
||||
|
||||
writeManagedWebRuntimeProcess(params.stateDir, {
|
||||
pid,
|
||||
port: params.port,
|
||||
gatewayPort: params.gatewayPort,
|
||||
startedAt: new Date().toISOString(),
|
||||
runtimeAppDir: appDir,
|
||||
});
|
||||
updateManifestLastPort(params.stateDir, params.port, params.gatewayPort);
|
||||
|
||||
return { started: true, pid, runtimeServerPath };
|
||||
}
|
||||
@ -84,7 +84,7 @@ export type StartManagedWebRuntimeResult =
|
||||
| {
|
||||
started: false;
|
||||
runtimeServerPath: string;
|
||||
reason: "runtime-missing";
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type WebPortListenerOwnership = "managed" | "legacy-standalone" | "foreign";
|
||||
@ -279,7 +279,7 @@ function writeManagedWebRuntimeManifest(
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function writeManagedWebRuntimeProcess(
|
||||
export function writeManagedWebRuntimeProcess(
|
||||
stateDir: string,
|
||||
processMeta: ManagedWebRuntimeProcess,
|
||||
): void {
|
||||
@ -290,7 +290,7 @@ function clearManagedWebRuntimeProcess(stateDir: string): void {
|
||||
rmSync(resolveManagedWebRuntimeProcessPath(stateDir), { force: true });
|
||||
}
|
||||
|
||||
function updateManifestLastPort(
|
||||
export function updateManifestLastPort(
|
||||
stateDir: string,
|
||||
webPort: number,
|
||||
gatewayPort: number,
|
||||
@ -839,6 +839,11 @@ export async function ensureManagedWebRuntime(params: {
|
||||
denchVersion: string;
|
||||
port: number;
|
||||
gatewayPort: number;
|
||||
startFn?: (p: {
|
||||
stateDir: string;
|
||||
port: number;
|
||||
gatewayPort: number;
|
||||
}) => StartManagedWebRuntimeResult;
|
||||
}): Promise<{ ready: boolean; reason: string }> {
|
||||
const install = installManagedWebRuntime({
|
||||
stateDir: params.stateDir,
|
||||
@ -869,7 +874,8 @@ export async function ensureManagedWebRuntime(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const start = startManagedWebRuntime({
|
||||
const doStart = params.startFn ?? startManagedWebRuntime;
|
||||
const start = doStart({
|
||||
stateDir: params.stateDir,
|
||||
port: params.port,
|
||||
gatewayPort: params.gatewayPort,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user