agentroam 0.2.0-preview.7 → 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.
Files changed (44) hide show
  1. package/README.md +42 -8
  2. package/bin/agentroam.mjs +10 -1
  3. package/bin/node-preflight.mjs +138 -0
  4. package/dist/args.js +14 -1
  5. package/dist/args.test.js +27 -0
  6. package/dist/cli.js +59 -7
  7. package/dist/cloudflared/bundled-asset.test.js +2 -2
  8. package/dist/codex-runtime-manager.js +293 -0
  9. package/dist/codex-runtime-manager.test.js +192 -0
  10. package/dist/install-script-contract.test.js +41 -0
  11. package/dist/node-preflight.test.js +109 -0
  12. package/dist/node-runtime-manager.js +251 -0
  13. package/dist/node-runtime-manager.test.js +184 -0
  14. package/dist/opencode-runtime-manager.js +207 -0
  15. package/dist/opencode-runtime-manager.test.js +79 -0
  16. package/dist/platform-packages.js +1 -1
  17. package/dist/platform.js +8 -4
  18. package/dist/platform.test.js +17 -2
  19. package/dist/power/sleep-inhibitor.js +151 -0
  20. package/dist/power/sleep-inhibitor.test.js +104 -0
  21. package/dist/runtime-manager.js +50 -2
  22. package/dist/runtime-manager.test.js +38 -0
  23. package/dist/service/macos-launch-agent.js +250 -0
  24. package/dist/service/macos-launch-agent.test.js +197 -0
  25. package/dist/service/runtime-state.js +40 -0
  26. package/dist/service/runtime-state.test.js +43 -0
  27. package/dist/service/service-command.js +100 -0
  28. package/dist/service/service-command.test.js +102 -0
  29. package/dist/service/service-controller.js +27 -0
  30. package/dist/service/service-files.js +78 -0
  31. package/dist/service/service-files.test.js +48 -0
  32. package/dist/service/windows-service-host.js +53 -0
  33. package/dist/service/windows-service-host.test.js +46 -0
  34. package/dist/service/windows-task-service.js +275 -0
  35. package/dist/service/windows-task-service.test.js +95 -0
  36. package/dist/tunnel/cloudflare-provider.js +4 -1
  37. package/dist/tunnel/cloudflare-provider.test.js +13 -1
  38. package/dist/tunnel/public-readiness.js +1 -1
  39. package/dist/tunnel/relay-orchestrator.js +4 -0
  40. package/dist/tunnel/relay-orchestrator.test.js +11 -0
  41. package/install/README.md +45 -0
  42. package/install/install-agentroam.ps1 +175 -0
  43. package/install/install-agentroam.sh +199 -0
  44. package/package.json +8 -7
@@ -0,0 +1,250 @@
1
+ import { constants } from "node:fs";
2
+ import { access, chmod, mkdir, readFile, rename, stat } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { SERVICE_LABEL, ensurePrivateDirectory, ensurePrivateFile, readServiceConfig, readServiceState, removeIfExists, resolveServicePaths, writePrivateJson, } from "./service-files.js";
5
+ import { buildStartArguments, runCommand, } from "./service-controller.js";
6
+ export { buildStartArguments } from "./service-controller.js";
7
+ export class MacLaunchAgent {
8
+ homeDir;
9
+ uid;
10
+ runner;
11
+ processExists;
12
+ now;
13
+ readyTimeoutMs;
14
+ stopTimeoutMs;
15
+ pollIntervalMs;
16
+ constructor(options = {}) {
17
+ this.homeDir = options.homeDir;
18
+ this.uid = options.uid ?? process.getuid?.() ?? 0;
19
+ this.runner = options.runner ?? runCommand;
20
+ this.processExists = options.processExists ?? isProcessRunning;
21
+ this.now = options.now ?? (() => new Date());
22
+ this.readyTimeoutMs = options.readyTimeoutMs ?? 120_000;
23
+ this.stopTimeoutMs = options.stopTimeoutMs ?? 15_000;
24
+ this.pollIntervalMs = options.pollIntervalMs ?? 250;
25
+ }
26
+ async install(config) {
27
+ await this.validateConfig(config);
28
+ const paths = resolveServicePaths(this.homeDir, config.dataDir);
29
+ await Promise.all([
30
+ mkdir(paths.launchAgentsDir, { recursive: true }),
31
+ ensurePrivateDirectory(paths.controlDir),
32
+ ensurePrivateDirectory(paths.logsDir),
33
+ ]);
34
+ await Promise.all([ensurePrivateFile(paths.stdoutPath), ensurePrivateFile(paths.stderrPath)]);
35
+ const jsonPath = resolve(paths.controlDir, "launch-agent.json.tmp");
36
+ const plistTempPath = resolve(paths.controlDir, "launch-agent.plist.tmp");
37
+ await removeIfExists(plistTempPath);
38
+ await writePrivateJson(jsonPath, buildLaunchAgentPlist(config, paths));
39
+ try {
40
+ await this.runRequired("plutil", ["-convert", "xml1", "-o", plistTempPath, jsonPath]);
41
+ await this.runRequired("plutil", ["-lint", plistTempPath]);
42
+ await chmod(plistTempPath, 0o600);
43
+ if ((await this.inspectJob()).loaded)
44
+ await this.runRequired("launchctl", ["bootout", this.serviceTarget]);
45
+ await rename(plistTempPath, paths.plistPath);
46
+ await chmod(paths.plistPath, 0o600);
47
+ await writePrivateJson(paths.configPath, config);
48
+ await Promise.all([removeIfExists(paths.statePath), removeIfExists(paths.urlPath)]);
49
+ await this.runRequired("launchctl", ["bootstrap", this.domainTarget, paths.plistPath]);
50
+ }
51
+ finally {
52
+ await Promise.all([removeIfExists(jsonPath), removeIfExists(plistTempPath)]);
53
+ }
54
+ return { paths, state: await this.waitForReady(paths), definition: paths.plistPath };
55
+ }
56
+ async start() {
57
+ const status = await this.status();
58
+ if (!status.installed)
59
+ throw new Error("AgentRoam service is not installed");
60
+ if (status.running)
61
+ return status.state;
62
+ if (status.loaded)
63
+ await this.runRequired("launchctl", ["kickstart", this.serviceTarget]);
64
+ else
65
+ await this.runRequired("launchctl", ["bootstrap", this.domainTarget, status.definition]);
66
+ const paths = resolveServicePaths(this.homeDir, status.config?.dataDir);
67
+ return this.waitForReady(paths, status.state?.pid);
68
+ }
69
+ async stop() {
70
+ const status = await this.status();
71
+ if (!status.installed)
72
+ throw new Error("AgentRoam service is not installed");
73
+ if (!status.loaded)
74
+ return;
75
+ await this.runRequired("launchctl", ["bootout", this.serviceTarget]);
76
+ if (status.state?.pid)
77
+ await this.waitForProcessExit(status.state.pid);
78
+ }
79
+ async status() {
80
+ const basePaths = resolveServicePaths(this.homeDir);
81
+ const config = await readServiceConfig(basePaths);
82
+ const paths = resolveServicePaths(this.homeDir, config?.dataDir);
83
+ const installed = await pathExists(paths.plistPath);
84
+ const job = installed ? await this.inspectJob() : { loaded: false, running: false };
85
+ return {
86
+ installed,
87
+ loaded: job.loaded,
88
+ running: job.running,
89
+ config,
90
+ state: await readServiceState(paths),
91
+ definition: paths.plistPath,
92
+ };
93
+ }
94
+ async url() {
95
+ const status = await this.status();
96
+ if (!status.installed)
97
+ throw new Error("AgentRoam service is not installed");
98
+ if (!status.running)
99
+ throw new Error("AgentRoam service is installed but not running");
100
+ if (status.state?.status !== "ready" || !status.state.accessUrl) {
101
+ throw new Error("AgentRoam service is still starting; try again shortly");
102
+ }
103
+ const paths = resolveServicePaths(this.homeDir, status.config?.dataDir);
104
+ const value = (await readFile(paths.urlPath, "utf8")).trim();
105
+ if (!value || value !== status.state.accessUrl)
106
+ throw new Error("AgentRoam service URL is unavailable or stale");
107
+ return value;
108
+ }
109
+ async logs(maxBytes = 64 * 1024) {
110
+ const basePaths = resolveServicePaths(this.homeDir);
111
+ const config = await readServiceConfig(basePaths);
112
+ const paths = resolveServicePaths(this.homeDir, config?.dataDir);
113
+ const [stdout, stderr] = await Promise.all([
114
+ readTail(paths.stdoutPath, maxBytes),
115
+ readTail(paths.stderrPath, maxBytes),
116
+ ]);
117
+ return { stdoutPath: paths.stdoutPath, stderrPath: paths.stderrPath, stdout, stderr };
118
+ }
119
+ async restart() {
120
+ const status = await this.status();
121
+ if (!status.installed)
122
+ throw new Error("AgentRoam service is not installed");
123
+ if (status.loaded)
124
+ await this.runRequired("launchctl", ["kickstart", "-k", this.serviceTarget]);
125
+ else
126
+ await this.runRequired("launchctl", ["bootstrap", this.domainTarget, status.definition]);
127
+ const paths = resolveServicePaths(this.homeDir, status.config?.dataDir);
128
+ return this.waitForReady(paths, status.state?.pid);
129
+ }
130
+ async uninstall() {
131
+ const basePaths = resolveServicePaths(this.homeDir);
132
+ const config = await readServiceConfig(basePaths);
133
+ const paths = resolveServicePaths(this.homeDir, config?.dataDir);
134
+ const installed = await pathExists(paths.plistPath);
135
+ const state = await readServiceState(paths);
136
+ if ((await this.inspectJob()).loaded) {
137
+ await this.runRequired("launchctl", ["bootout", this.serviceTarget]);
138
+ if (state?.pid)
139
+ await this.waitForProcessExit(state.pid);
140
+ }
141
+ await Promise.all([
142
+ removeIfExists(paths.plistPath),
143
+ removeIfExists(paths.configPath),
144
+ removeIfExists(paths.statePath),
145
+ removeIfExists(paths.urlPath),
146
+ ]);
147
+ return { removed: installed || Boolean(config), preservedDataDir: paths.dataDir };
148
+ }
149
+ get domainTarget() {
150
+ return `gui/${this.uid}`;
151
+ }
152
+ get serviceTarget() {
153
+ return `${this.domainTarget}/${SERVICE_LABEL}`;
154
+ }
155
+ async inspectJob() {
156
+ const result = await this.runner("launchctl", ["print", this.serviceTarget]);
157
+ return {
158
+ loaded: result.code === 0,
159
+ running: result.code === 0 && /\bstate\s*=\s*running\b/.test(result.stdout),
160
+ };
161
+ }
162
+ async runRequired(command, args) {
163
+ const result = await this.runner(command, args);
164
+ if (result.code !== 0) {
165
+ const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`;
166
+ throw new Error(`${command} ${args[0] ?? ""} failed: ${detail}`);
167
+ }
168
+ return result;
169
+ }
170
+ async validateConfig(config) {
171
+ if (!config.nodePath.startsWith("/") || !config.cliPath.startsWith("/")) {
172
+ throw new Error("AgentRoam service requires absolute Node and CLI paths");
173
+ }
174
+ if (config.cliPath.split("/").includes("_npx")) {
175
+ throw new Error("service install cannot use an npx temporary package; install AgentRoam with npm install -g first");
176
+ }
177
+ await Promise.all([
178
+ access(config.nodePath, constants.X_OK),
179
+ access(config.cliPath, constants.R_OK),
180
+ ...config.roots.map(async (root) => {
181
+ if (!(await stat(root)).isDirectory())
182
+ throw new Error(`service root is not a directory: ${root}`);
183
+ }),
184
+ ]);
185
+ }
186
+ async waitForReady(paths, previousPid) {
187
+ const deadline = Date.now() + this.readyTimeoutMs;
188
+ do {
189
+ const state = await readServiceState(paths);
190
+ if (state?.status === "ready" && (!previousPid || state.pid !== previousPid))
191
+ return state;
192
+ if (Date.now() >= deadline)
193
+ return state;
194
+ await delay(this.pollIntervalMs);
195
+ } while (true);
196
+ }
197
+ async waitForProcessExit(pid) {
198
+ const deadline = Date.now() + this.stopTimeoutMs;
199
+ while (this.processExists(pid)) {
200
+ if (Date.now() >= deadline)
201
+ throw new Error(`service process ${pid} did not stop after launchctl bootout`);
202
+ await delay(this.pollIntervalMs);
203
+ }
204
+ }
205
+ }
206
+ export function buildLaunchAgentPlist(config, paths) {
207
+ return {
208
+ Label: SERVICE_LABEL,
209
+ ProgramArguments: buildStartArguments(config),
210
+ WorkingDirectory: config.roots[0],
211
+ EnvironmentVariables: { AGENTROAM_SERVICE: "1" },
212
+ RunAtLoad: true,
213
+ KeepAlive: true,
214
+ ThrottleInterval: 5,
215
+ StandardOutPath: paths.stdoutPath,
216
+ StandardErrorPath: paths.stderrPath,
217
+ };
218
+ }
219
+ async function pathExists(path) {
220
+ try {
221
+ await access(path);
222
+ return true;
223
+ }
224
+ catch {
225
+ return false;
226
+ }
227
+ }
228
+ function isProcessRunning(pid) {
229
+ try {
230
+ process.kill(pid, 0);
231
+ return true;
232
+ }
233
+ catch (error) {
234
+ return error.code === "EPERM";
235
+ }
236
+ }
237
+ async function readTail(path, maxBytes) {
238
+ try {
239
+ const value = await readFile(path);
240
+ return value.subarray(Math.max(0, value.length - maxBytes)).toString("utf8");
241
+ }
242
+ catch (error) {
243
+ if (error.code === "ENOENT")
244
+ return "";
245
+ throw error;
246
+ }
247
+ }
248
+ function delay(timeoutMs) {
249
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, timeoutMs));
250
+ }
@@ -0,0 +1,197 @@
1
+ import { chmod, mkdtemp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { resolve } from "node:path";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { MacLaunchAgent, buildLaunchAgentPlist, buildStartArguments, } from "./macos-launch-agent.js";
6
+ import { resolveServicePaths, writePrivateJson, writePrivateText } from "./service-files.js";
7
+ describe("MacLaunchAgent", () => {
8
+ it("builds one launchd job with separate absolute arguments", async () => {
9
+ const home = await mkdtemp(resolve(tmpdir(), "agentroam-launch-agent-plist-"));
10
+ const paths = resolveServicePaths(home, resolve(home, "data with spaces"));
11
+ const value = config(home, paths.dataDir);
12
+ expect(buildStartArguments(value)).toEqual([
13
+ value.nodePath, value.cliPath, "start", "--root", value.roots[0], "--root", value.roots[1],
14
+ "--port", "3001", "--relay", "custom", "--tunnel-command", "relay --port {port}",
15
+ "--data-dir", paths.dataDir, "--no-qr",
16
+ ]);
17
+ expect(buildLaunchAgentPlist(value, paths)).toMatchObject({
18
+ Label: "com.agentroam.service",
19
+ WorkingDirectory: value.roots[0],
20
+ RunAtLoad: true,
21
+ KeepAlive: true,
22
+ ThrottleInterval: 5,
23
+ EnvironmentVariables: { AGENTROAM_SERVICE: "1" },
24
+ });
25
+ });
26
+ it("installs, reloads, and waits for a ready service", async () => {
27
+ const home = await mkdtemp(resolve(tmpdir(), "agentroam-launch-agent-install-"));
28
+ const paths = resolveServicePaths(home);
29
+ const value = config(home, paths.dataDir);
30
+ await createConfigFiles(value);
31
+ const calls = [];
32
+ let printCount = 0;
33
+ const runner = async (command, args) => {
34
+ calls.push([command, ...args]);
35
+ if (command === "plutil" && args[0] === "-convert") {
36
+ await writeFile(args[3], "<?xml version=\"1.0\"?><plist version=\"1.0\"><dict/></plist>");
37
+ }
38
+ if (command === "launchctl" && args[0] === "print") {
39
+ printCount++;
40
+ return { code: printCount === 1 ? 0 : 1, stdout: "state = exited", stderr: "" };
41
+ }
42
+ if (command === "launchctl" && args[0] === "bootstrap") {
43
+ await writePrivateJson(paths.statePath, {
44
+ status: "ready", pid: 777, version: value.version, startedAt: value.installedAt,
45
+ updatedAt: value.installedAt, accessUrl: "https://ready.example/web",
46
+ });
47
+ }
48
+ return { code: 0, stdout: "", stderr: "" };
49
+ };
50
+ const result = await new MacLaunchAgent({ homeDir: home, uid: 501, runner, readyTimeoutMs: 10 }).install(value);
51
+ expect(result.state).toMatchObject({ status: "ready", pid: 777 });
52
+ expect(calls).toContainEqual(["launchctl", "bootout", "gui/501/com.agentroam.service"]);
53
+ expect(calls).toContainEqual(["launchctl", "bootstrap", "gui/501", paths.plistPath]);
54
+ expect((await stat(paths.plistPath)).mode & 0o777).toBe(0o600);
55
+ expect(JSON.parse(await readFile(paths.configPath, "utf8"))).toMatchObject({ roots: value.roots });
56
+ });
57
+ it("reports ready URLs only while the installed job is running", async () => {
58
+ const home = await mkdtemp(resolve(tmpdir(), "agentroam-launch-agent-url-"));
59
+ const paths = resolveServicePaths(home);
60
+ const value = config(home, paths.dataDir);
61
+ await mkdir(paths.launchAgentsDir, { recursive: true });
62
+ await writeFile(paths.plistPath, "plist");
63
+ await writePrivateJson(paths.configPath, value);
64
+ await writePrivateJson(paths.statePath, {
65
+ status: "ready", pid: 777, version: value.version, startedAt: value.installedAt,
66
+ updatedAt: value.installedAt, accessUrl: "https://ready.example/web",
67
+ });
68
+ await writePrivateText(paths.urlPath, "https://ready.example/web\n");
69
+ const running = async () => ({ code: 0, stdout: "state = running", stderr: "" });
70
+ expect(await new MacLaunchAgent({ homeDir: home, uid: 501, runner: running }).url())
71
+ .toBe("https://ready.example/web");
72
+ await writePrivateText(paths.urlPath, "https://stale.example/web\n");
73
+ await expect(new MacLaunchAgent({ homeDir: home, uid: 501, runner: running }).url()).rejects.toThrow("stale");
74
+ });
75
+ it("starts and stops an installed service while preserving registration", async () => {
76
+ const home = await mkdtemp(resolve(tmpdir(), "agentroam-launch-agent-start-stop-"));
77
+ const paths = resolveServicePaths(home);
78
+ const value = config(home, paths.dataDir);
79
+ await mkdir(paths.launchAgentsDir, { recursive: true });
80
+ await writeFile(paths.plistPath, "plist");
81
+ await writePrivateJson(paths.configPath, value);
82
+ await writePrivateJson(paths.statePath, {
83
+ status: "stopped", pid: 700, version: value.version, startedAt: "x", updatedAt: "x",
84
+ });
85
+ let loaded = false;
86
+ let processRunning = false;
87
+ const calls = [];
88
+ const runner = async (command, args) => {
89
+ calls.push([command, ...args]);
90
+ if (command === "launchctl" && args[0] === "print") {
91
+ return { code: loaded ? 0 : 1, stdout: loaded ? "state = running" : "", stderr: "" };
92
+ }
93
+ if (command === "launchctl" && args[0] === "bootstrap") {
94
+ loaded = true;
95
+ processRunning = true;
96
+ await writePrivateJson(paths.statePath, {
97
+ status: "ready", pid: 701, version: value.version, startedAt: "y", updatedAt: "y",
98
+ accessUrl: "https://ready.example/web",
99
+ });
100
+ }
101
+ if (command === "launchctl" && args[0] === "bootout") {
102
+ loaded = false;
103
+ processRunning = false;
104
+ }
105
+ return { code: 0, stdout: "", stderr: "" };
106
+ };
107
+ const launchAgent = new MacLaunchAgent({
108
+ homeDir: home,
109
+ uid: 501,
110
+ runner,
111
+ processExists: () => processRunning,
112
+ readyTimeoutMs: 50,
113
+ stopTimeoutMs: 50,
114
+ pollIntervalMs: 1,
115
+ });
116
+ const state = await launchAgent.start();
117
+ expect(state).toMatchObject({ status: "ready", pid: 701 });
118
+ expect((await launchAgent.status()).definition).toBe(paths.plistPath);
119
+ await launchAgent.stop();
120
+ expect(calls).toContainEqual(["launchctl", "bootstrap", "gui/501", paths.plistPath]);
121
+ expect(calls).toContainEqual(["launchctl", "bootout", "gui/501/com.agentroam.service"]);
122
+ expect(await readFile(paths.plistPath, "utf8")).toBe("plist");
123
+ expect(JSON.parse(await readFile(paths.configPath, "utf8"))).toMatchObject({ version: value.version });
124
+ });
125
+ it("uninstalls service control files but preserves application data and logs", async () => {
126
+ const home = await mkdtemp(resolve(tmpdir(), "agentroam-launch-agent-uninstall-"));
127
+ const paths = resolveServicePaths(home);
128
+ const value = config(home, paths.dataDir);
129
+ await mkdir(paths.launchAgentsDir, { recursive: true });
130
+ await writeFile(paths.plistPath, "plist");
131
+ await writePrivateJson(paths.configPath, value);
132
+ await writePrivateJson(paths.statePath, { status: "starting", pid: 1, version: value.version, startedAt: "x", updatedAt: "x" });
133
+ await writePrivateText(paths.urlPath, "url\n");
134
+ await writePrivateText(resolve(paths.dataDir, "data/keep.txt"), "keep");
135
+ await writePrivateText(paths.stdoutPath, "keep logs");
136
+ const calls = [];
137
+ let processRunning = true;
138
+ let exitWrite = null;
139
+ const runner = async (command, args) => {
140
+ calls.push([command, ...args]);
141
+ if (command === "launchctl" && args[0] === "bootout") {
142
+ exitWrite = writePrivateJson(paths.statePath, {
143
+ status: "stopped", pid: 1, version: value.version, startedAt: "x", updatedAt: "x",
144
+ }).then(() => { processRunning = false; });
145
+ }
146
+ return { code: 0, stdout: "", stderr: "" };
147
+ };
148
+ const processExists = vi.fn(() => processRunning);
149
+ const result = await new MacLaunchAgent({
150
+ homeDir: home,
151
+ uid: 501,
152
+ runner,
153
+ processExists,
154
+ pollIntervalMs: 1,
155
+ stopTimeoutMs: 100,
156
+ }).uninstall();
157
+ await exitWrite;
158
+ expect(result).toEqual({ removed: true, preservedDataDir: paths.dataDir });
159
+ expect(calls).toContainEqual(["launchctl", "bootout", "gui/501/com.agentroam.service"]);
160
+ expect(processExists).toHaveBeenCalled();
161
+ await expect(readFile(paths.configPath)).rejects.toMatchObject({ code: "ENOENT" });
162
+ await expect(readFile(paths.statePath)).rejects.toMatchObject({ code: "ENOENT" });
163
+ expect(await readFile(resolve(paths.dataDir, "data/keep.txt"), "utf8")).toBe("keep");
164
+ expect(await readFile(paths.stdoutPath, "utf8")).toBe("keep logs");
165
+ });
166
+ it("rejects an npx cache entrypoint", async () => {
167
+ const home = await mkdtemp(resolve(tmpdir(), "agentroam-launch-agent-npx-"));
168
+ const value = config(home, resolve(home, ".agentroam"));
169
+ value.cliPath = resolve(home, ".npm/_npx/hash/node_modules/agentroam/bin/agentroam.mjs");
170
+ await expect(new MacLaunchAgent({ homeDir: home }).install(value)).rejects.toThrow("npm install -g");
171
+ });
172
+ });
173
+ function config(home, dataDir) {
174
+ const nodePath = resolve(home, "bin/node");
175
+ const cliPath = resolve(home, "lib/agentroam/bin/agentroam.mjs");
176
+ const roots = [resolve(home, "workspace one"), resolve(home, "workspace-two")];
177
+ return {
178
+ version: "0.2.0-preview.9",
179
+ nodePath,
180
+ cliPath,
181
+ roots,
182
+ port: 3001,
183
+ relay: "custom",
184
+ tunnelCommand: "relay --port {port}",
185
+ localOnly: false,
186
+ dataDir,
187
+ installedAt: "2026-09-03T00:00:00.000Z",
188
+ };
189
+ }
190
+ async function createConfigFiles(value) {
191
+ await Promise.all([
192
+ writePrivateText(value.nodePath, "node"),
193
+ writePrivateText(value.cliPath, "cli"),
194
+ ...value.roots.map((root) => mkdir(root, { recursive: true })),
195
+ ]);
196
+ await chmod(value.nodePath, 0o700);
197
+ }
@@ -0,0 +1,40 @@
1
+ import { readServiceState, removeIfExists, writePrivateJson, writePrivateText, } from "./service-files.js";
2
+ export class ServiceRuntimeReporter {
3
+ paths;
4
+ version;
5
+ pid;
6
+ now;
7
+ startedAt;
8
+ constructor(paths, version, pid = process.pid, now = () => new Date()) {
9
+ this.paths = paths;
10
+ this.version = version;
11
+ this.pid = pid;
12
+ this.now = now;
13
+ this.startedAt = this.now().toISOString();
14
+ }
15
+ async starting() {
16
+ await removeIfExists(this.paths.urlPath);
17
+ await this.write({ status: "starting" });
18
+ }
19
+ async ready(ready) {
20
+ await this.write({ status: "ready", ...ready });
21
+ await writePrivateText(this.paths.urlPath, `${ready.accessUrl}\n`);
22
+ }
23
+ async stopped() {
24
+ const current = await readServiceState(this.paths);
25
+ if (!current || current.pid !== this.pid)
26
+ return false;
27
+ await this.write({ status: "stopped" });
28
+ await removeIfExists(this.paths.urlPath);
29
+ return true;
30
+ }
31
+ async write(details) {
32
+ await writePrivateJson(this.paths.statePath, {
33
+ pid: this.pid,
34
+ version: this.version,
35
+ startedAt: this.startedAt,
36
+ updatedAt: this.now().toISOString(),
37
+ ...details,
38
+ });
39
+ }
40
+ }
@@ -0,0 +1,43 @@
1
+ import { access, mkdtemp, readFile } 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 { ServiceRuntimeReporter } from "./runtime-state.js";
6
+ import { readServiceState, resolveServicePaths, writePrivateJson } from "./service-files.js";
7
+ describe("ServiceRuntimeReporter", () => {
8
+ it("publishes starting, ready, and stopped state without leaving a stale URL", async () => {
9
+ const home = await mkdtemp(resolve(tmpdir(), "agentroam-service-state-"));
10
+ const paths = resolveServicePaths(home);
11
+ let tick = 0;
12
+ const reporter = new ServiceRuntimeReporter(paths, "0.2.0-preview.9", 123, () => new Date(1_000 + tick++));
13
+ await reporter.starting();
14
+ expect(await readServiceState(paths)).toMatchObject({ status: "starting", pid: 123, version: "0.2.0-preview.9" });
15
+ await expect(access(paths.urlPath)).rejects.toMatchObject({ code: "ENOENT" });
16
+ await reporter.ready({
17
+ localUrl: "http://127.0.0.1:3000",
18
+ publicUrl: "https://example.trycloudflare.com",
19
+ accessUrl: "https://example.trycloudflare.com/web?pair=secret",
20
+ provider: "cloudflare",
21
+ });
22
+ expect(await readServiceState(paths)).toMatchObject({ status: "ready", provider: "cloudflare" });
23
+ expect(await readFile(paths.urlPath, "utf8")).toBe("https://example.trycloudflare.com/web?pair=secret\n");
24
+ expect(await reporter.stopped()).toBe(true);
25
+ expect(await readServiceState(paths)).toMatchObject({ status: "stopped", pid: 123 });
26
+ await expect(access(paths.urlPath)).rejects.toMatchObject({ code: "ENOENT" });
27
+ });
28
+ it("does not let an older process clear a newer process state", async () => {
29
+ const home = await mkdtemp(resolve(tmpdir(), "agentroam-service-state-race-"));
30
+ const paths = resolveServicePaths(home);
31
+ const reporter = new ServiceRuntimeReporter(paths, "old", 123);
32
+ await writePrivateJson(paths.statePath, {
33
+ status: "ready",
34
+ pid: 456,
35
+ version: "new",
36
+ startedAt: "2026-09-03T00:00:00.000Z",
37
+ updatedAt: "2026-09-03T00:00:01.000Z",
38
+ accessUrl: "https://new.example/web",
39
+ });
40
+ expect(await reporter.stopped()).toBe(false);
41
+ expect(await readServiceState(paths)).toMatchObject({ status: "ready", pid: 456 });
42
+ });
43
+ });
@@ -0,0 +1,100 @@
1
+ import { MacLaunchAgent } from "./macos-launch-agent.js";
2
+ import { WindowsTaskService } from "./windows-task-service.js";
3
+ export async function runServiceCommand(options, context) {
4
+ const platform = context.platform ?? process.platform;
5
+ const log = context.log ?? console.log;
6
+ const controller = context.controller ?? context.launchAgent ?? createController(platform, context.homeDir);
7
+ switch (options.serviceAction) {
8
+ case "install": {
9
+ const nodeVersion = context.nodeVersion ?? process.versions.node;
10
+ if (Number(nodeVersion.split(".")[0]) !== 22) {
11
+ throw cliError(`Node.js 22 is required to install the service (current ${nodeVersion})`);
12
+ }
13
+ const now = context.now?.() ?? new Date();
14
+ const config = {
15
+ version: context.version,
16
+ nodePath: context.nodePath,
17
+ cliPath: context.cliPath,
18
+ roots: options.roots,
19
+ port: options.port,
20
+ relay: options.relay,
21
+ tunnelCommand: options.tunnelCommand,
22
+ localOnly: options.localOnly,
23
+ dataDir: options.dataDir,
24
+ installedAt: now.toISOString(),
25
+ };
26
+ const { definition, state } = await controller.install(config);
27
+ log(`✓ AgentRoam service installed: ${definition}`);
28
+ if (state?.status === "ready" && state.accessUrl)
29
+ log(`Open: ${state.accessUrl}`);
30
+ else
31
+ log("Service is starting. Run `agentroam service url` shortly.");
32
+ return;
33
+ }
34
+ case "start": {
35
+ const state = await controller.start();
36
+ log("✓ AgentRoam service started");
37
+ if (state?.status === "ready" && state.accessUrl)
38
+ log(`Open: ${state.accessUrl}`);
39
+ return;
40
+ }
41
+ case "stop":
42
+ await controller.stop();
43
+ log("✓ AgentRoam service stopped");
44
+ return;
45
+ case "status": {
46
+ const status = await controller.status();
47
+ if (!status.installed) {
48
+ log("AgentRoam service: not installed");
49
+ return;
50
+ }
51
+ log(`AgentRoam service: ${status.running ? "running" : "installed but stopped"}`);
52
+ if (status.config) {
53
+ log(`Version: ${status.config.version}`);
54
+ log(`Root: ${status.config.roots.join(", ")}`);
55
+ }
56
+ if (status.running && status.state?.status === "ready" && status.state.accessUrl) {
57
+ log(`Open: ${status.state.accessUrl}`);
58
+ }
59
+ return;
60
+ }
61
+ case "url":
62
+ log(await controller.url());
63
+ return;
64
+ case "logs": {
65
+ const logs = await controller.logs();
66
+ log(`stdout: ${logs.stdoutPath}`);
67
+ if (logs.stdout)
68
+ log(logs.stdout.trimEnd());
69
+ log(`stderr: ${logs.stderrPath}`);
70
+ if (logs.stderr)
71
+ log(logs.stderr.trimEnd());
72
+ return;
73
+ }
74
+ case "restart": {
75
+ const state = await controller.restart();
76
+ log("✓ AgentRoam service restarted");
77
+ if (state?.status === "ready" && state.accessUrl)
78
+ log(`Open: ${state.accessUrl}`);
79
+ return;
80
+ }
81
+ case "uninstall": {
82
+ const result = await controller.uninstall();
83
+ log(result.removed ? "✓ AgentRoam service uninstalled" : "AgentRoam service was not installed");
84
+ log(`Application data preserved: ${result.preservedDataDir}`);
85
+ return;
86
+ }
87
+ default:
88
+ throw cliError("service action is required");
89
+ }
90
+ }
91
+ function createController(platform, homeDir) {
92
+ if (platform === "darwin")
93
+ return new MacLaunchAgent({ homeDir });
94
+ if (platform === "win32")
95
+ return new WindowsTaskService({ homeDir });
96
+ throw cliError(`agentroam service is unavailable on ${platform}; supported platforms are macOS and Windows`);
97
+ }
98
+ function cliError(message) {
99
+ return Object.assign(new Error(message), { exitCode: 2 });
100
+ }