agentroam 0.2.0-preview.8 → 0.2.0-preview.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -8
- package/bin/agentroam.mjs +10 -1
- package/bin/node-preflight.mjs +138 -0
- package/dist/args.js +14 -1
- package/dist/args.test.js +27 -0
- package/dist/cli.js +59 -7
- package/dist/cloudflared/bundled-asset.test.js +2 -2
- package/dist/codex-runtime-manager.js +293 -0
- package/dist/codex-runtime-manager.test.js +192 -0
- package/dist/install-script-contract.test.js +41 -0
- package/dist/node-preflight.test.js +109 -0
- package/dist/node-runtime-manager.js +251 -0
- package/dist/node-runtime-manager.test.js +184 -0
- package/dist/opencode-runtime-manager.js +207 -0
- package/dist/opencode-runtime-manager.test.js +79 -0
- package/dist/platform-packages.js +1 -1
- package/dist/platform.js +8 -4
- package/dist/platform.test.js +17 -2
- package/dist/power/sleep-inhibitor.js +151 -0
- package/dist/power/sleep-inhibitor.test.js +104 -0
- package/dist/runtime-manager.js +50 -2
- package/dist/runtime-manager.test.js +38 -0
- package/dist/service/macos-launch-agent.js +250 -0
- package/dist/service/macos-launch-agent.test.js +197 -0
- package/dist/service/runtime-state.js +40 -0
- package/dist/service/runtime-state.test.js +43 -0
- package/dist/service/service-command.js +100 -0
- package/dist/service/service-command.test.js +102 -0
- package/dist/service/service-controller.js +27 -0
- package/dist/service/service-files.js +78 -0
- package/dist/service/service-files.test.js +48 -0
- package/dist/service/windows-service-host.js +53 -0
- package/dist/service/windows-service-host.test.js +46 -0
- package/dist/service/windows-task-service.js +275 -0
- package/dist/service/windows-task-service.test.js +95 -0
- package/dist/tunnel/cloudflare-provider.js +4 -1
- package/dist/tunnel/cloudflare-provider.test.js +13 -1
- package/dist/tunnel/public-readiness.js +1 -1
- package/dist/tunnel/relay-orchestrator.js +4 -0
- package/dist/tunnel/relay-orchestrator.test.js +11 -0
- package/install/README.md +45 -0
- package/install/install-agentroam.ps1 +175 -0
- package/install/install-agentroam.sh +199 -0
- package/package.json +8 -7
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { runServiceCommand } from "./service-command.js";
|
|
4
|
+
describe("runServiceCommand", () => {
|
|
5
|
+
it("installs the service using absolute runtime context and parsed start options", async () => {
|
|
6
|
+
const install = vi.fn(async (config) => ({
|
|
7
|
+
paths: { plistPath: "/Users/test/Library/LaunchAgents/com.agentroam.service.plist" },
|
|
8
|
+
state: { status: "ready", accessUrl: "https://ready.example/web" },
|
|
9
|
+
definition: "/Users/test/Library/LaunchAgents/com.agentroam.service.plist",
|
|
10
|
+
}));
|
|
11
|
+
const log = vi.fn();
|
|
12
|
+
await runServiceCommand(options("install"), {
|
|
13
|
+
platform: "darwin",
|
|
14
|
+
nodePath: "/node",
|
|
15
|
+
cliPath: "/agentroam.mjs",
|
|
16
|
+
version: "0.2.0-preview.9",
|
|
17
|
+
nodeVersion: "22.22.0",
|
|
18
|
+
now: () => new Date("2026-09-03T00:00:00.000Z"),
|
|
19
|
+
log,
|
|
20
|
+
controller: controller({ install }),
|
|
21
|
+
});
|
|
22
|
+
expect(install).toHaveBeenCalledWith(expect.objectContaining({
|
|
23
|
+
nodePath: "/node",
|
|
24
|
+
cliPath: "/agentroam.mjs",
|
|
25
|
+
roots: [resolve("workspace")],
|
|
26
|
+
version: "0.2.0-preview.9",
|
|
27
|
+
installedAt: "2026-09-03T00:00:00.000Z",
|
|
28
|
+
}));
|
|
29
|
+
expect(log).toHaveBeenCalledWith("Open: https://ready.example/web");
|
|
30
|
+
});
|
|
31
|
+
it("reports status without mutating an uninstalled service", async () => {
|
|
32
|
+
const log = vi.fn();
|
|
33
|
+
await runServiceCommand(options("status"), {
|
|
34
|
+
platform: "darwin",
|
|
35
|
+
nodePath: "/node",
|
|
36
|
+
cliPath: "/agentroam.mjs",
|
|
37
|
+
version: "test",
|
|
38
|
+
log,
|
|
39
|
+
controller: controller({ status: vi.fn(async () => ({ installed: false })) }),
|
|
40
|
+
});
|
|
41
|
+
expect(log).toHaveBeenCalledWith("AgentRoam service: not installed");
|
|
42
|
+
});
|
|
43
|
+
it("starts and stops a Windows service through the same command contract", async () => {
|
|
44
|
+
const start = vi.fn(async () => ({ status: "ready", accessUrl: "https://ready.example/web" }));
|
|
45
|
+
const stop = vi.fn(async () => undefined);
|
|
46
|
+
const log = vi.fn();
|
|
47
|
+
await runServiceCommand(options("start"), {
|
|
48
|
+
platform: "win32",
|
|
49
|
+
nodePath: "C:\\node.exe",
|
|
50
|
+
cliPath: "C:\\agentroam.mjs",
|
|
51
|
+
version: "test",
|
|
52
|
+
controller: controller({ start }),
|
|
53
|
+
log,
|
|
54
|
+
});
|
|
55
|
+
await runServiceCommand(options("stop"), {
|
|
56
|
+
platform: "win32",
|
|
57
|
+
nodePath: "C:\\node.exe",
|
|
58
|
+
cliPath: "C:\\agentroam.mjs",
|
|
59
|
+
version: "test",
|
|
60
|
+
controller: controller({ stop }),
|
|
61
|
+
log,
|
|
62
|
+
});
|
|
63
|
+
expect(start).toHaveBeenCalledOnce();
|
|
64
|
+
expect(stop).toHaveBeenCalledOnce();
|
|
65
|
+
expect(log).toHaveBeenCalledWith("✓ AgentRoam service started");
|
|
66
|
+
expect(log).toHaveBeenCalledWith("✓ AgentRoam service stopped");
|
|
67
|
+
});
|
|
68
|
+
it("rejects service management outside supported platforms", async () => {
|
|
69
|
+
await expect(runServiceCommand(options("status"), {
|
|
70
|
+
platform: "linux",
|
|
71
|
+
nodePath: "/node",
|
|
72
|
+
cliPath: "/agentroam.mjs",
|
|
73
|
+
version: "test",
|
|
74
|
+
})).rejects.toThrow("supported platforms are macOS and Windows");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
function controller(overrides) {
|
|
78
|
+
return {
|
|
79
|
+
install: vi.fn(),
|
|
80
|
+
start: vi.fn(),
|
|
81
|
+
stop: vi.fn(),
|
|
82
|
+
status: vi.fn(),
|
|
83
|
+
url: vi.fn(),
|
|
84
|
+
logs: vi.fn(),
|
|
85
|
+
restart: vi.fn(),
|
|
86
|
+
uninstall: vi.fn(),
|
|
87
|
+
...overrides,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function options(serviceAction) {
|
|
91
|
+
return {
|
|
92
|
+
command: "service",
|
|
93
|
+
serviceAction,
|
|
94
|
+
roots: [resolve("workspace")],
|
|
95
|
+
port: null,
|
|
96
|
+
relay: "auto",
|
|
97
|
+
tunnelCommand: null,
|
|
98
|
+
localOnly: false,
|
|
99
|
+
qr: false,
|
|
100
|
+
dataDir: resolve("data"),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export function buildStartArguments(config) {
|
|
3
|
+
const args = [config.nodePath, config.cliPath, "start"];
|
|
4
|
+
for (const root of config.roots)
|
|
5
|
+
args.push("--root", root);
|
|
6
|
+
if (config.port !== null)
|
|
7
|
+
args.push("--port", String(config.port));
|
|
8
|
+
if (config.localOnly)
|
|
9
|
+
args.push("--local-only");
|
|
10
|
+
else
|
|
11
|
+
args.push("--relay", config.relay);
|
|
12
|
+
if (!config.localOnly && config.tunnelCommand)
|
|
13
|
+
args.push("--tunnel-command", config.tunnelCommand);
|
|
14
|
+
args.push("--data-dir", config.dataDir, "--no-qr");
|
|
15
|
+
return args;
|
|
16
|
+
}
|
|
17
|
+
export function runCommand(command, args) {
|
|
18
|
+
return new Promise((resolveResult, reject) => {
|
|
19
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
20
|
+
let stdout = "";
|
|
21
|
+
let stderr = "";
|
|
22
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
23
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
24
|
+
child.once("error", reject);
|
|
25
|
+
child.once("exit", (code) => resolveResult({ code: code ?? 1, stdout, stderr }));
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { chmod, mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
|
5
|
+
export const SERVICE_LABEL = "com.agentroam.service";
|
|
6
|
+
export const WINDOWS_SERVICE_NAME = "AgentRoam";
|
|
7
|
+
export function resolveServicePaths(homeDir = homedir(), dataDir = resolve(homeDir, ".agentroam")) {
|
|
8
|
+
const normalizedHome = resolve(homeDir);
|
|
9
|
+
const normalizedData = resolve(dataDir);
|
|
10
|
+
const launchAgentsDir = resolve(normalizedHome, "Library", "LaunchAgents");
|
|
11
|
+
const controlDir = resolve(normalizedHome, ".agentroam", "service");
|
|
12
|
+
const logsDir = resolve(normalizedData, "logs");
|
|
13
|
+
return {
|
|
14
|
+
homeDir: normalizedHome,
|
|
15
|
+
dataDir: normalizedData,
|
|
16
|
+
launchAgentsDir,
|
|
17
|
+
plistPath: resolve(launchAgentsDir, `${SERVICE_LABEL}.plist`),
|
|
18
|
+
controlDir,
|
|
19
|
+
configPath: resolve(controlDir, "config.json"),
|
|
20
|
+
statePath: resolve(controlDir, "state.json"),
|
|
21
|
+
urlPath: resolve(normalizedData, "tunnel.url"),
|
|
22
|
+
logsDir,
|
|
23
|
+
stdoutPath: resolve(logsDir, "service.stdout.log"),
|
|
24
|
+
stderrPath: resolve(logsDir, "service.stderr.log"),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export async function ensurePrivateDirectory(path) {
|
|
28
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
29
|
+
await chmod(path, 0o700);
|
|
30
|
+
}
|
|
31
|
+
export async function ensurePrivateFile(path) {
|
|
32
|
+
await ensurePrivateDirectory(dirname(path));
|
|
33
|
+
const handle = await open(path, "a", 0o600);
|
|
34
|
+
await handle.close();
|
|
35
|
+
await chmod(path, 0o600);
|
|
36
|
+
}
|
|
37
|
+
export async function writePrivateJson(path, value) {
|
|
38
|
+
await writePrivateText(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
39
|
+
}
|
|
40
|
+
export async function writePrivateText(path, value) {
|
|
41
|
+
await ensurePrivateDirectory(dirname(path));
|
|
42
|
+
const temporary = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
43
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
44
|
+
try {
|
|
45
|
+
await handle.writeFile(value, "utf8");
|
|
46
|
+
await handle.sync();
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
await handle.close();
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
await rename(temporary, path);
|
|
53
|
+
await chmod(path, 0o600);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
await rm(temporary, { force: true });
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function readServiceConfig(paths) {
|
|
61
|
+
return readJson(paths.configPath);
|
|
62
|
+
}
|
|
63
|
+
export async function readServiceState(paths) {
|
|
64
|
+
return readJson(paths.statePath);
|
|
65
|
+
}
|
|
66
|
+
export async function removeIfExists(path) {
|
|
67
|
+
await rm(path, { force: true });
|
|
68
|
+
}
|
|
69
|
+
async function readJson(path) {
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error.code === "ENOENT")
|
|
75
|
+
return null;
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { mkdtemp, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { ensurePrivateFile, readServiceConfig, resolveServicePaths, writePrivateJson, writePrivateText, } from "./service-files.js";
|
|
6
|
+
describe("service files", () => {
|
|
7
|
+
it("resolves control files under the home directory and runtime files under data-dir", async () => {
|
|
8
|
+
const home = await mkdtemp(resolve(tmpdir(), "agentroam-service-home-"));
|
|
9
|
+
const data = resolve(home, "custom-data");
|
|
10
|
+
const paths = resolveServicePaths(home, data);
|
|
11
|
+
expect(paths.plistPath).toBe(resolve(home, "Library/LaunchAgents/com.agentroam.service.plist"));
|
|
12
|
+
expect(paths.configPath).toBe(resolve(home, ".agentroam/service/config.json"));
|
|
13
|
+
expect(paths.statePath).toBe(resolve(home, ".agentroam/service/state.json"));
|
|
14
|
+
expect(paths.urlPath).toBe(resolve(data, "tunnel.url"));
|
|
15
|
+
expect(paths.stdoutPath).toBe(resolve(data, "logs/service.stdout.log"));
|
|
16
|
+
});
|
|
17
|
+
it("atomically replaces private JSON and text files", async () => {
|
|
18
|
+
const home = await mkdtemp(resolve(tmpdir(), "agentroam-service-private-"));
|
|
19
|
+
const paths = resolveServicePaths(home);
|
|
20
|
+
const first = config(paths.dataDir, "first");
|
|
21
|
+
const second = config(paths.dataDir, "second");
|
|
22
|
+
await writePrivateJson(paths.configPath, first);
|
|
23
|
+
await writePrivateJson(paths.configPath, second);
|
|
24
|
+
await writePrivateText(paths.urlPath, "https://example.trycloudflare.com/web\n");
|
|
25
|
+
await ensurePrivateFile(paths.stdoutPath);
|
|
26
|
+
expect(await readServiceConfig(paths)).toEqual(second);
|
|
27
|
+
expect(await readFile(paths.urlPath, "utf8")).toContain("trycloudflare.com");
|
|
28
|
+
for (const path of [paths.configPath, paths.urlPath, paths.stdoutPath]) {
|
|
29
|
+
expect((await stat(path)).mode & 0o777).toBe(0o600);
|
|
30
|
+
}
|
|
31
|
+
expect((await stat(paths.controlDir)).mode & 0o777).toBe(0o700);
|
|
32
|
+
expect((await stat(paths.logsDir)).mode & 0o777).toBe(0o700);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
function config(dataDir, version) {
|
|
36
|
+
return {
|
|
37
|
+
version,
|
|
38
|
+
nodePath: "/node",
|
|
39
|
+
cliPath: "/agentroam.mjs",
|
|
40
|
+
roots: ["/workspace"],
|
|
41
|
+
port: null,
|
|
42
|
+
relay: "auto",
|
|
43
|
+
tunnelCommand: null,
|
|
44
|
+
localOnly: false,
|
|
45
|
+
dataDir,
|
|
46
|
+
installedAt: "2026-09-03T00:00:00.000Z",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { open, readFile } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { buildStartArguments } from "./service-controller.js";
|
|
6
|
+
import { ensurePrivateFile, resolveServicePaths } from "./service-files.js";
|
|
7
|
+
export async function runWindowsServiceHost(configPath, dependencies = {}) {
|
|
8
|
+
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
9
|
+
const paths = resolveServicePaths(undefined, config.dataDir);
|
|
10
|
+
await Promise.all([ensurePrivateFile(paths.stdoutPath), ensurePrivateFile(paths.stderrPath)]);
|
|
11
|
+
const [stdoutHandle, stderrHandle] = await Promise.all([open(paths.stdoutPath, "a"), open(paths.stderrPath, "a")]);
|
|
12
|
+
const [command, ...args] = buildStartArguments(config);
|
|
13
|
+
let child = null;
|
|
14
|
+
const requestStop = () => child?.kill("SIGTERM");
|
|
15
|
+
try {
|
|
16
|
+
child = (dependencies.spawnProcess ?? spawn)(command, args, {
|
|
17
|
+
cwd: config.roots[0],
|
|
18
|
+
env: { ...process.env, AGENTROAM_SERVICE: "1" },
|
|
19
|
+
stdio: ["ignore", stdoutHandle.fd, stderrHandle.fd],
|
|
20
|
+
windowsHide: true,
|
|
21
|
+
});
|
|
22
|
+
process.once("SIGINT", requestStop);
|
|
23
|
+
process.once("SIGTERM", requestStop);
|
|
24
|
+
return await waitForExit(child);
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
process.removeListener("SIGINT", requestStop);
|
|
28
|
+
process.removeListener("SIGTERM", requestStop);
|
|
29
|
+
await Promise.all([stdoutHandle.close(), stderrHandle.close()]);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function waitForExit(child) {
|
|
33
|
+
return new Promise((resolveExit, rejectExit) => {
|
|
34
|
+
child.once("error", rejectExit);
|
|
35
|
+
child.once("exit", (code, signal) => resolveExit(code ?? (signal ? 1 : 0)));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
39
|
+
if (process.argv[1] && resolve(process.argv[1]) === resolve(currentFile)) {
|
|
40
|
+
const configPath = process.argv[2];
|
|
41
|
+
if (!configPath) {
|
|
42
|
+
console.error("AgentRoam Windows service host requires a config path");
|
|
43
|
+
process.exitCode = 2;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
runWindowsServiceHost(configPath)
|
|
47
|
+
.then((code) => { process.exitCode = code; })
|
|
48
|
+
.catch((error) => {
|
|
49
|
+
console.error(error instanceof Error ? error.message : error);
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { mkdtemp, mkdir } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { writePrivateJson } from "./service-files.js";
|
|
7
|
+
import { runWindowsServiceHost } from "./windows-service-host.js";
|
|
8
|
+
describe("Windows service host", () => {
|
|
9
|
+
it("starts the configured CLI in service mode with private log descriptors", async () => {
|
|
10
|
+
const root = await mkdtemp(resolve(tmpdir(), "agentroam-windows-host-"));
|
|
11
|
+
const workspace = resolve(root, "workspace with spaces");
|
|
12
|
+
const configPath = resolve(root, "service/config.json");
|
|
13
|
+
await mkdir(workspace, { recursive: true });
|
|
14
|
+
const config = {
|
|
15
|
+
version: "0.2.0-preview.9",
|
|
16
|
+
nodePath: "C:\\Node Runtime\\node.exe",
|
|
17
|
+
cliPath: "C:\\AgentRoam\\bin\\agentroam.mjs",
|
|
18
|
+
roots: [workspace],
|
|
19
|
+
port: 3010,
|
|
20
|
+
relay: "cloudflare",
|
|
21
|
+
tunnelCommand: null,
|
|
22
|
+
localOnly: false,
|
|
23
|
+
dataDir: resolve(root, "data"),
|
|
24
|
+
installedAt: "2026-09-04T00:00:00.000Z",
|
|
25
|
+
};
|
|
26
|
+
await writePrivateJson(configPath, config);
|
|
27
|
+
const child = new EventEmitter();
|
|
28
|
+
Object.assign(child, { kill: vi.fn(() => true) });
|
|
29
|
+
const spawnProcess = vi.fn(() => {
|
|
30
|
+
queueMicrotask(() => child.emit("exit", 0, null));
|
|
31
|
+
return child;
|
|
32
|
+
});
|
|
33
|
+
await expect(runWindowsServiceHost(configPath, { spawnProcess: spawnProcess })).resolves.toBe(0);
|
|
34
|
+
const calls = spawnProcess.mock.calls;
|
|
35
|
+
const [command, args, options] = calls[0];
|
|
36
|
+
expect(command).toBe(config.nodePath);
|
|
37
|
+
expect(args).toEqual([
|
|
38
|
+
config.cliPath, "start", "--root", workspace, "--port", "3010", "--relay", "cloudflare",
|
|
39
|
+
"--data-dir", config.dataDir, "--no-qr",
|
|
40
|
+
]);
|
|
41
|
+
expect(options).toMatchObject({ cwd: workspace, windowsHide: true });
|
|
42
|
+
expect(options.env.AGENTROAM_SERVICE).toBe("1");
|
|
43
|
+
expect(typeof options.stdio[1]).toBe("number");
|
|
44
|
+
expect(typeof options.stdio[2]).toBe("number");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { win32 } from "node:path";
|
|
4
|
+
import { WINDOWS_SERVICE_NAME, ensurePrivateDirectory, ensurePrivateFile, readServiceConfig, readServiceState, removeIfExists, resolveServicePaths, writePrivateJson, } from "./service-files.js";
|
|
5
|
+
import { runCommand, } from "./service-controller.js";
|
|
6
|
+
export class WindowsTaskService {
|
|
7
|
+
homeDir;
|
|
8
|
+
runner;
|
|
9
|
+
processExists;
|
|
10
|
+
now;
|
|
11
|
+
readyTimeoutMs;
|
|
12
|
+
stopTimeoutMs;
|
|
13
|
+
pollIntervalMs;
|
|
14
|
+
validate;
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.homeDir = options.homeDir;
|
|
17
|
+
this.runner = options.runner ?? runCommand;
|
|
18
|
+
this.processExists = options.processExists ?? isProcessRunning;
|
|
19
|
+
this.now = options.now ?? (() => new Date());
|
|
20
|
+
this.readyTimeoutMs = options.readyTimeoutMs ?? 120_000;
|
|
21
|
+
this.stopTimeoutMs = options.stopTimeoutMs ?? 15_000;
|
|
22
|
+
this.pollIntervalMs = options.pollIntervalMs ?? 250;
|
|
23
|
+
this.validate = options.validateConfig ?? validateWindowsConfig;
|
|
24
|
+
}
|
|
25
|
+
async install(config) {
|
|
26
|
+
const hostPath = resolveWindowsServiceHost(config.cliPath);
|
|
27
|
+
await this.validate(config, hostPath);
|
|
28
|
+
const paths = resolveServicePaths(this.homeDir, config.dataDir);
|
|
29
|
+
await Promise.all([
|
|
30
|
+
ensurePrivateDirectory(paths.controlDir),
|
|
31
|
+
ensurePrivateDirectory(paths.logsDir),
|
|
32
|
+
ensurePrivateFile(paths.stdoutPath),
|
|
33
|
+
ensurePrivateFile(paths.stderrPath),
|
|
34
|
+
]);
|
|
35
|
+
await writePrivateJson(paths.configPath, config);
|
|
36
|
+
await Promise.all([removeIfExists(paths.statePath), removeIfExists(paths.urlPath)]);
|
|
37
|
+
await this.runRequired(buildRegisterTaskScript(config, hostPath, paths.configPath));
|
|
38
|
+
await this.runRequired(buildStartTaskScript());
|
|
39
|
+
return {
|
|
40
|
+
paths,
|
|
41
|
+
state: await this.waitForReady(paths),
|
|
42
|
+
definition: WINDOWS_SERVICE_NAME,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
async start() {
|
|
46
|
+
const status = await this.status();
|
|
47
|
+
if (!status.installed)
|
|
48
|
+
throw new Error("AgentRoam service is not installed");
|
|
49
|
+
if (status.running)
|
|
50
|
+
return status.state;
|
|
51
|
+
const paths = resolveServicePaths(this.homeDir, status.config?.dataDir);
|
|
52
|
+
await Promise.all([removeIfExists(paths.statePath), removeIfExists(paths.urlPath)]);
|
|
53
|
+
await this.runRequired(buildStartTaskScript());
|
|
54
|
+
return this.waitForReady(paths, status.state?.pid);
|
|
55
|
+
}
|
|
56
|
+
async stop() {
|
|
57
|
+
const status = await this.status();
|
|
58
|
+
if (!status.installed)
|
|
59
|
+
throw new Error("AgentRoam service is not installed");
|
|
60
|
+
const paths = resolveServicePaths(this.homeDir, status.config?.dataDir);
|
|
61
|
+
if (status.running) {
|
|
62
|
+
await this.runRequired(buildStopTaskScript());
|
|
63
|
+
if (status.state?.pid)
|
|
64
|
+
await this.waitForProcessExit(status.state.pid);
|
|
65
|
+
}
|
|
66
|
+
await removeIfExists(paths.urlPath);
|
|
67
|
+
if (status.state) {
|
|
68
|
+
await writePrivateJson(paths.statePath, {
|
|
69
|
+
status: "stopped",
|
|
70
|
+
pid: status.state.pid,
|
|
71
|
+
version: status.state.version,
|
|
72
|
+
startedAt: status.state.startedAt,
|
|
73
|
+
updatedAt: this.now().toISOString(),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async status() {
|
|
78
|
+
const basePaths = resolveServicePaths(this.homeDir);
|
|
79
|
+
const config = await readServiceConfig(basePaths);
|
|
80
|
+
const paths = resolveServicePaths(this.homeDir, config?.dataDir);
|
|
81
|
+
const task = await this.inspectTask();
|
|
82
|
+
return {
|
|
83
|
+
installed: task.installed,
|
|
84
|
+
loaded: task.installed,
|
|
85
|
+
running: task.running,
|
|
86
|
+
config,
|
|
87
|
+
state: await readServiceState(paths),
|
|
88
|
+
definition: WINDOWS_SERVICE_NAME,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
async url() {
|
|
92
|
+
const status = await this.status();
|
|
93
|
+
if (!status.installed)
|
|
94
|
+
throw new Error("AgentRoam service is not installed");
|
|
95
|
+
if (!status.running)
|
|
96
|
+
throw new Error("AgentRoam service is installed but not running");
|
|
97
|
+
if (status.state?.status !== "ready" || !status.state.accessUrl) {
|
|
98
|
+
throw new Error("AgentRoam service is still starting; try again shortly");
|
|
99
|
+
}
|
|
100
|
+
const paths = resolveServicePaths(this.homeDir, status.config?.dataDir);
|
|
101
|
+
const value = (await readFile(paths.urlPath, "utf8")).trim();
|
|
102
|
+
if (!value || value !== status.state.accessUrl)
|
|
103
|
+
throw new Error("AgentRoam service URL is unavailable or stale");
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
async logs(maxBytes = 64 * 1024) {
|
|
107
|
+
const basePaths = resolveServicePaths(this.homeDir);
|
|
108
|
+
const config = await readServiceConfig(basePaths);
|
|
109
|
+
const paths = resolveServicePaths(this.homeDir, config?.dataDir);
|
|
110
|
+
const [stdout, stderr] = await Promise.all([
|
|
111
|
+
readTail(paths.stdoutPath, maxBytes),
|
|
112
|
+
readTail(paths.stderrPath, maxBytes),
|
|
113
|
+
]);
|
|
114
|
+
return { stdoutPath: paths.stdoutPath, stderrPath: paths.stderrPath, stdout, stderr };
|
|
115
|
+
}
|
|
116
|
+
async restart() {
|
|
117
|
+
const status = await this.status();
|
|
118
|
+
if (!status.installed)
|
|
119
|
+
throw new Error("AgentRoam service is not installed");
|
|
120
|
+
const paths = resolveServicePaths(this.homeDir, status.config?.dataDir);
|
|
121
|
+
if (status.running) {
|
|
122
|
+
await this.runRequired(buildStopTaskScript());
|
|
123
|
+
if (status.state?.pid)
|
|
124
|
+
await this.waitForProcessExit(status.state.pid);
|
|
125
|
+
}
|
|
126
|
+
await Promise.all([removeIfExists(paths.statePath), removeIfExists(paths.urlPath)]);
|
|
127
|
+
await this.runRequired(buildStartTaskScript());
|
|
128
|
+
return this.waitForReady(paths, status.state?.pid);
|
|
129
|
+
}
|
|
130
|
+
async uninstall() {
|
|
131
|
+
const status = await this.status();
|
|
132
|
+
const paths = resolveServicePaths(this.homeDir, status.config?.dataDir);
|
|
133
|
+
if (status.running) {
|
|
134
|
+
await this.runRequired(buildStopTaskScript());
|
|
135
|
+
if (status.state?.pid)
|
|
136
|
+
await this.waitForProcessExit(status.state.pid);
|
|
137
|
+
}
|
|
138
|
+
if (status.installed)
|
|
139
|
+
await this.runRequired(buildUnregisterTaskScript());
|
|
140
|
+
await Promise.all([
|
|
141
|
+
removeIfExists(paths.configPath),
|
|
142
|
+
removeIfExists(paths.statePath),
|
|
143
|
+
removeIfExists(paths.urlPath),
|
|
144
|
+
]);
|
|
145
|
+
return { removed: status.installed || Boolean(status.config), preservedDataDir: paths.dataDir };
|
|
146
|
+
}
|
|
147
|
+
async inspectTask() {
|
|
148
|
+
const result = await this.runPowerShell(buildStatusTaskScript());
|
|
149
|
+
if (result.code !== 0) {
|
|
150
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`;
|
|
151
|
+
throw new Error(`PowerShell task status failed: ${detail}`);
|
|
152
|
+
}
|
|
153
|
+
const line = result.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
|
|
154
|
+
if (!line)
|
|
155
|
+
throw new Error("PowerShell task status returned no JSON");
|
|
156
|
+
const value = JSON.parse(line);
|
|
157
|
+
return { installed: value.installed === true, running: value.running === true };
|
|
158
|
+
}
|
|
159
|
+
async runRequired(script) {
|
|
160
|
+
const result = await this.runPowerShell(script);
|
|
161
|
+
if (result.code !== 0) {
|
|
162
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`;
|
|
163
|
+
throw new Error(`PowerShell service command failed: ${detail}`);
|
|
164
|
+
}
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
runPowerShell(script) {
|
|
168
|
+
return this.runner("powershell.exe", [
|
|
169
|
+
"-NoProfile",
|
|
170
|
+
"-NonInteractive",
|
|
171
|
+
"-ExecutionPolicy", "Bypass",
|
|
172
|
+
"-EncodedCommand", Buffer.from(script, "utf16le").toString("base64"),
|
|
173
|
+
]);
|
|
174
|
+
}
|
|
175
|
+
async waitForReady(paths, previousPid) {
|
|
176
|
+
const deadline = Date.now() + this.readyTimeoutMs;
|
|
177
|
+
do {
|
|
178
|
+
const state = await readServiceState(paths);
|
|
179
|
+
if (state?.status === "ready" && (!previousPid || state.pid !== previousPid))
|
|
180
|
+
return state;
|
|
181
|
+
if (Date.now() >= deadline)
|
|
182
|
+
return state;
|
|
183
|
+
await delay(this.pollIntervalMs);
|
|
184
|
+
} while (true);
|
|
185
|
+
}
|
|
186
|
+
async waitForProcessExit(pid) {
|
|
187
|
+
const deadline = Date.now() + this.stopTimeoutMs;
|
|
188
|
+
while (this.processExists(pid)) {
|
|
189
|
+
if (Date.now() >= deadline)
|
|
190
|
+
throw new Error(`service process ${pid} did not stop after task termination`);
|
|
191
|
+
await delay(this.pollIntervalMs);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
export function resolveWindowsServiceHost(cliPath) {
|
|
196
|
+
return win32.resolve(win32.dirname(cliPath), "..", "dist", "service", "windows-service-host.js");
|
|
197
|
+
}
|
|
198
|
+
export function quoteWindowsCommandLineArgument(value) {
|
|
199
|
+
return `"${value.replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\+)$/g, "$1$1")}"`;
|
|
200
|
+
}
|
|
201
|
+
export function buildRegisterTaskScript(config, hostPath, configPath) {
|
|
202
|
+
const argumentsValue = `${quoteWindowsCommandLineArgument(hostPath)} ${quoteWindowsCommandLineArgument(configPath)}`;
|
|
203
|
+
return String.raw `
|
|
204
|
+
$ErrorActionPreference = "Stop"
|
|
205
|
+
$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
206
|
+
$action = New-ScheduledTaskAction -Execute ${powerShellLiteral(config.nodePath)} -Argument ${powerShellLiteral(argumentsValue)} -WorkingDirectory ${powerShellLiteral(config.roots[0])}
|
|
207
|
+
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $user
|
|
208
|
+
$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
|
|
209
|
+
$settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -RestartCount 999 -RestartInterval (New-TimeSpan -Seconds 5) -ExecutionTimeLimit ([TimeSpan]::Zero) -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
|
210
|
+
Register-ScheduledTask -TaskName ${powerShellLiteral(WINDOWS_SERVICE_NAME)} -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
|
|
211
|
+
`;
|
|
212
|
+
}
|
|
213
|
+
export function buildStatusTaskScript() {
|
|
214
|
+
return String.raw `
|
|
215
|
+
$ErrorActionPreference = "Stop"
|
|
216
|
+
$task = Get-ScheduledTask -TaskName ${powerShellLiteral(WINDOWS_SERVICE_NAME)} -ErrorAction SilentlyContinue
|
|
217
|
+
if ($null -eq $task) {
|
|
218
|
+
[pscustomobject]@{ installed = $false; running = $false } | ConvertTo-Json -Compress
|
|
219
|
+
} else {
|
|
220
|
+
[pscustomobject]@{ installed = $true; running = ([string]$task.State -eq "Running") } | ConvertTo-Json -Compress
|
|
221
|
+
}
|
|
222
|
+
`;
|
|
223
|
+
}
|
|
224
|
+
function buildStartTaskScript() {
|
|
225
|
+
return `Start-ScheduledTask -TaskName ${powerShellLiteral(WINDOWS_SERVICE_NAME)}\n`;
|
|
226
|
+
}
|
|
227
|
+
function buildStopTaskScript() {
|
|
228
|
+
return `Stop-ScheduledTask -TaskName ${powerShellLiteral(WINDOWS_SERVICE_NAME)}\n`;
|
|
229
|
+
}
|
|
230
|
+
function buildUnregisterTaskScript() {
|
|
231
|
+
return `Unregister-ScheduledTask -TaskName ${powerShellLiteral(WINDOWS_SERVICE_NAME)} -Confirm:$false\n`;
|
|
232
|
+
}
|
|
233
|
+
function powerShellLiteral(value) {
|
|
234
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
235
|
+
}
|
|
236
|
+
async function validateWindowsConfig(config, hostPath) {
|
|
237
|
+
if (!win32.isAbsolute(config.nodePath) || !win32.isAbsolute(config.cliPath)) {
|
|
238
|
+
throw new Error("AgentRoam service requires absolute Node and CLI paths");
|
|
239
|
+
}
|
|
240
|
+
if (config.cliPath.split(/[\\/]/).includes("_npx")) {
|
|
241
|
+
throw new Error("service install cannot use an npx temporary package; install AgentRoam with the versioned installer first");
|
|
242
|
+
}
|
|
243
|
+
await Promise.all([
|
|
244
|
+
access(config.nodePath, constants.X_OK),
|
|
245
|
+
access(config.cliPath, constants.R_OK),
|
|
246
|
+
access(hostPath, constants.R_OK),
|
|
247
|
+
...config.roots.map(async (root) => {
|
|
248
|
+
if (!(await stat(root)).isDirectory())
|
|
249
|
+
throw new Error(`service root is not a directory: ${root}`);
|
|
250
|
+
}),
|
|
251
|
+
]);
|
|
252
|
+
}
|
|
253
|
+
function isProcessRunning(pid) {
|
|
254
|
+
try {
|
|
255
|
+
process.kill(pid, 0);
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
return error.code === "EPERM";
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
async function readTail(path, maxBytes) {
|
|
263
|
+
try {
|
|
264
|
+
const value = await readFile(path);
|
|
265
|
+
return value.subarray(Math.max(0, value.length - maxBytes)).toString("utf8");
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
if (error.code === "ENOENT")
|
|
269
|
+
return "";
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function delay(timeoutMs) {
|
|
274
|
+
return new Promise((resolveDelay) => setTimeout(resolveDelay, timeoutMs));
|
|
275
|
+
}
|