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.
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,95 @@
1
+ import { mkdtemp, mkdir, 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 { resolveServicePaths, writePrivateText } from "./service-files.js";
6
+ import { WindowsTaskService, buildRegisterTaskScript, quoteWindowsCommandLineArgument, resolveWindowsServiceHost, } from "./windows-task-service.js";
7
+ describe("WindowsTaskService", () => {
8
+ it("registers, stops, starts, and uninstalls a current-user task", async () => {
9
+ const home = await mkdtemp(resolve(tmpdir(), "agentroam-windows-task-"));
10
+ const paths = resolveServicePaths(home, resolve(home, "data"));
11
+ const value = config(home, paths.dataDir);
12
+ await mkdir(value.roots[0], { recursive: true });
13
+ let installed = false;
14
+ let running = false;
15
+ let processRunning = false;
16
+ let pid = 800;
17
+ const scripts = [];
18
+ const runner = async (command, args) => {
19
+ expect(command).toBe("powershell.exe");
20
+ const script = Buffer.from(args.at(-1), "base64").toString("utf16le");
21
+ scripts.push(script);
22
+ if (script.includes("Register-ScheduledTask"))
23
+ installed = true;
24
+ if (script.includes("Start-ScheduledTask")) {
25
+ running = true;
26
+ processRunning = true;
27
+ pid++;
28
+ await writePrivateText(paths.statePath, JSON.stringify({
29
+ status: "ready", pid, version: value.version, startedAt: "x", updatedAt: "x",
30
+ accessUrl: "https://ready.example/web",
31
+ }));
32
+ await writePrivateText(paths.urlPath, "https://ready.example/web\n");
33
+ }
34
+ if (script.includes("Stop-ScheduledTask")) {
35
+ running = false;
36
+ processRunning = false;
37
+ }
38
+ if (script.includes("Unregister-ScheduledTask"))
39
+ installed = false;
40
+ if (script.includes("Get-ScheduledTask")) {
41
+ return { code: 0, stdout: JSON.stringify({ installed, running }), stderr: "" };
42
+ }
43
+ return { code: 0, stdout: "", stderr: "" };
44
+ };
45
+ const service = new WindowsTaskService({
46
+ homeDir: home,
47
+ runner,
48
+ processExists: () => processRunning,
49
+ validateConfig: async () => undefined,
50
+ readyTimeoutMs: 50,
51
+ stopTimeoutMs: 50,
52
+ pollIntervalMs: 1,
53
+ });
54
+ const installedResult = await service.install(value);
55
+ expect(installedResult.state).toMatchObject({ status: "ready", pid: 801 });
56
+ expect(installedResult.definition).toBe("AgentRoam");
57
+ expect(await service.url()).toBe("https://ready.example/web");
58
+ await service.stop();
59
+ expect((await service.status()).running).toBe(false);
60
+ await service.start();
61
+ expect((await service.status()).running).toBe(true);
62
+ await writePrivateText(resolve(paths.dataDir, "data/keep.txt"), "keep");
63
+ await writePrivateText(paths.stdoutPath, "keep logs");
64
+ const result = await service.uninstall();
65
+ expect(result).toEqual({ removed: true, preservedDataDir: paths.dataDir });
66
+ expect(await readFile(resolve(paths.dataDir, "data/keep.txt"), "utf8")).toBe("keep");
67
+ expect(await readFile(paths.stdoutPath, "utf8")).toBe("keep logs");
68
+ expect(scripts.some((script) => script.includes("New-ScheduledTaskPrincipal") && script.includes("RunLevel Limited"))).toBe(true);
69
+ expect(scripts.some((script) => script.includes("cmd.exe") || script.includes("ES_DISPLAY_REQUIRED"))).toBe(false);
70
+ });
71
+ it("builds a safely quoted task action for paths with spaces and apostrophes", () => {
72
+ const value = config("C:\\Users\\O'Brien", "C:\\Users\\O'Brien\\.agentroam");
73
+ const host = resolveWindowsServiceHost(value.cliPath);
74
+ const script = buildRegisterTaskScript(value, host, "C:\\Users\\O'Brien\\config.json");
75
+ expect(host).toBe("C:\\Agent Roam\\node_modules\\agentroam\\dist\\service\\windows-service-host.js");
76
+ expect(script).toContain("O''Brien");
77
+ expect(script).toContain("New-ScheduledTaskAction");
78
+ expect(script).toContain("RestartCount 999");
79
+ expect(quoteWindowsCommandLineArgument("C:\\path with spaces\\")).toBe('"C:\\path with spaces\\\\"');
80
+ });
81
+ });
82
+ function config(home, dataDir) {
83
+ return {
84
+ version: "0.2.0-preview.9",
85
+ nodePath: "C:\\Node Runtime\\node.exe",
86
+ cliPath: "C:\\Agent Roam\\node_modules\\agentroam\\bin\\agentroam.mjs",
87
+ roots: [resolve(home, "workspace")],
88
+ port: null,
89
+ relay: "auto",
90
+ tunnelCommand: null,
91
+ localOnly: false,
92
+ dataDir,
93
+ installedAt: "2026-09-04T00:00:00.000Z",
94
+ };
95
+ }
@@ -6,7 +6,7 @@ export class CloudflareTunnelProvider {
6
6
  this.executable = executable;
7
7
  }
8
8
  async start({ localUrl, signal, log }) {
9
- const child = spawn(this.executable, ["tunnel", "--no-autoupdate", "--url", localUrl], {
9
+ const child = spawn(this.executable, buildCloudflaredArguments(localUrl), {
10
10
  stdio: ["ignore", "pipe", "pipe"],
11
11
  windowsHide: true,
12
12
  detached: process.platform !== "win32",
@@ -26,6 +26,9 @@ export class CloudflareTunnelProvider {
26
26
  }
27
27
  }
28
28
  }
29
+ export function buildCloudflaredArguments(localUrl) {
30
+ return ["tunnel", "--no-autoupdate", "--protocol", "http2", "--url", localUrl];
31
+ }
29
32
  export function waitForConnectedUrl(child, log, timeoutMs) {
30
33
  return new Promise((resolvePromise, reject) => {
31
34
  const buffers = { stdout: "", stderr: "" };
@@ -1,7 +1,19 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { PassThrough } from "node:stream";
3
3
  import { describe, expect, it, vi } from "vitest";
4
- import { waitForConnectedUrl } from "./cloudflare-provider.js";
4
+ import { buildCloudflaredArguments, waitForConnectedUrl } from "./cloudflare-provider.js";
5
+ describe("buildCloudflaredArguments", () => {
6
+ it("uses HTTP/2 so launchd tunnels do not depend on outbound UDP", () => {
7
+ expect(buildCloudflaredArguments("http://127.0.0.1:3000")).toEqual([
8
+ "tunnel",
9
+ "--no-autoupdate",
10
+ "--protocol",
11
+ "http2",
12
+ "--url",
13
+ "http://127.0.0.1:3000",
14
+ ]);
15
+ });
16
+ });
5
17
  describe("waitForConnectedUrl", () => {
6
18
  it("waits for both the public URL and a registered tunnel connection", async () => {
7
19
  const { child, stderr } = fakeChild();
@@ -22,7 +22,7 @@ export async function waitForPublicReadiness(publicUrl, options = {}) {
22
22
  try {
23
23
  const response = await fetchImpl(endpoint, {
24
24
  cache: "no-store",
25
- headers: { "user-agent": "agentroam-readiness/0.2.0-preview.8" },
25
+ headers: { "user-agent": "agentroam-readiness/0.2.0-preview.9" },
26
26
  signal: options.signal,
27
27
  });
28
28
  if (response.ok) {
@@ -27,6 +27,10 @@ export async function selectRelay(options) {
27
27
  break;
28
28
  }
29
29
  }
30
+ if (options.allowLanFallback === false) {
31
+ const detail = failures.map(({ provider, message }) => `${provider}: ${message}`).join("; ");
32
+ throw new Error(`public relay unavailable${detail ? ` (${detail})` : ""}`);
33
+ }
30
34
  return { tunnel: null, publicUrl: options.lanUrl, provider: "lan", failures };
31
35
  }
32
36
  async function createProvider(name, options) {
@@ -27,6 +27,16 @@ describe("selectRelay", () => {
27
27
  expect(result.publicUrl).toBe("http://10.0.0.2:43210");
28
28
  expect(result.failures).toHaveLength(2);
29
29
  });
30
+ it("fails instead of leaving a background service permanently on LAN", async () => {
31
+ const cloudflare = fakeProvider("https://bad.trycloudflare.com");
32
+ const pinggy = fakeProvider("https://bad.run.pinggy-free.link");
33
+ await expect(selectRelay({
34
+ ...options("auto", { cloudflare, pinggy }, async () => { throw new Error("blocked"); }),
35
+ allowLanFallback: false,
36
+ })).rejects.toThrow("public relay unavailable (cloudflare: blocked; pinggy: blocked)");
37
+ expect(cloudflare.handle.close).toHaveBeenCalledOnce();
38
+ expect(pinggy.handle.close).toHaveBeenCalledOnce();
39
+ });
30
40
  it("bypasses every public provider in local-only mode", async () => {
31
41
  const cloudflare = fakeProvider("https://unused.trycloudflare.com");
32
42
  const result = await selectRelay({ ...options("auto", { cloudflare }), cli: cli("auto", true) });
@@ -50,6 +60,7 @@ function options(relay, providers, readiness = async () => { }) {
50
60
  function cli(relay, localOnly = false) {
51
61
  return {
52
62
  command: "start",
63
+ serviceAction: null,
53
64
  roots: [process.cwd()],
54
65
  port: null,
55
66
  relay,
@@ -0,0 +1,45 @@
1
+ # AgentRoam bootstrap installers
2
+
3
+ These versioned installers support macOS Apple Silicon and Windows x64. They reuse a visible Node.js 22 installation when available. Otherwise they install the verified private Node.js `22.22.0` runtime under the AgentRoam data directory. They then register and start the current-user AgentRoam background service. They never modify, replace, or uninstall system Node.js or global power settings.
4
+
5
+ ## macOS Apple Silicon
6
+
7
+ ```sh
8
+ curl -fsSL https://gitee.com/caoqu/team-agent/releases/download/v0.2.0-preview.9/install-agentroam.sh | sh
9
+ ```
10
+
11
+ The command installs the wrapper at `~/.local/bin/agentroam`. If that directory is not already in `PATH`, the installer prints the required shell setting without editing shell profiles.
12
+
13
+ ## Windows x64
14
+
15
+ ```powershell
16
+ irm https://gitee.com/caoqu/team-agent/releases/download/v0.2.0-preview.9/install-agentroam.ps1 | iex
17
+ ```
18
+
19
+ The command installs `%USERPROFILE%\.agentroam\bin\agentroam.cmd` and adds only that user directory to the user-level `PATH`. It does not require administrator access or change the machine-level `PATH`.
20
+
21
+ ## Service behavior
22
+
23
+ Run the installer from the project directory AgentRoam should expose, or set `AGENTROAM_ROOT` explicitly. An installer launched from the home directory without an explicit root stops before service registration so it cannot expose the entire home directory accidentally.
24
+
25
+ The installer registers and starts the service after Node, CLI, and `doctor` validation. While foreground AgentRoam or the service is running, it prevents idle system sleep but still allows the display to dim and turn off. Lid close, explicit sleep, hibernation, shutdown, and low-battery forced sleep remain controlled by the operating system.
26
+
27
+ Use the installer for first installation, upgrades, and repair. Use these commands for routine control:
28
+
29
+ ```text
30
+ agentroam service start
31
+ agentroam service stop
32
+ agentroam service restart
33
+ agentroam service status
34
+ agentroam service url
35
+ agentroam service logs
36
+ agentroam service uninstall
37
+ ```
38
+
39
+ ## Data and updates
40
+
41
+ Set `AGENTROAM_DATA_DIR` before running either installer to override the default `~/.agentroam` data directory. Node is stored at `<data-dir>/runtimes/node/22.22.0`, and the launcher is stored at `<data-dir>/launcher/0.2.0-preview.9`.
42
+
43
+ Each AgentRoam release pins its own Node patch and installer assets. Updates happen only by running a newer versioned installer or installing a newer AgentRoam release; startup never follows a moving Node.js release channel.
44
+
45
+ To uninstall the wrapper, remove `~/.local/bin/agentroam` on macOS or `%USERPROFILE%\.agentroam\bin\agentroam.cmd` on Windows. The private runtime, launcher, session data, and configuration under the data directory are preserved until explicitly removed by the user.
@@ -0,0 +1,175 @@
1
+ $ErrorActionPreference = "Stop"
2
+ $ProgressPreference = "SilentlyContinue"
3
+
4
+ $NodeVersion = "22.22.0"
5
+ $AgentRoamVersion = "0.2.0-preview.9"
6
+ $NodeArchive = "node-v22.22.0-win-x64.zip"
7
+ $NodeSha256 = "c97fa376d2becdc8863fcd3ca2dd9a83a9f3468ee7ccf7a6d076ec66a645c77a"
8
+ $NodeUrl = "https://nodejs.org/dist/v22.22.0/$NodeArchive"
9
+ $NpmRegistry = "https://registry.npmjs.org"
10
+ $DataDir = if ($env:AGENTROAM_DATA_DIR) { $env:AGENTROAM_DATA_DIR } else { Join-Path $HOME ".agentroam" }
11
+ $NodeParent = Join-Path $DataDir "runtimes\node"
12
+ $NodeRoot = Join-Path $NodeParent $NodeVersion
13
+ $NodeLock = "$NodeRoot.lock"
14
+ $LauncherParent = Join-Path $DataDir "launcher"
15
+ $LauncherRoot = Join-Path $LauncherParent $AgentRoamVersion
16
+ $LauncherLock = "$LauncherRoot.lock"
17
+ $WrapperDir = Join-Path $HOME ".agentroam\bin"
18
+ $WrapperPath = Join-Path $WrapperDir "agentroam.cmd"
19
+ $ServiceRootExplicit = -not [string]::IsNullOrWhiteSpace($env:AGENTROAM_ROOT)
20
+ $ServiceRoot = if ($ServiceRootExplicit) { $env:AGENTROAM_ROOT } else { (Get-Location).Path }
21
+ if (-not (Test-Path -LiteralPath $ServiceRoot -PathType Container)) { throw "AgentRoam root is not a directory: $ServiceRoot" }
22
+ $ServiceRoot = [System.IO.Path]::GetFullPath($ServiceRoot).TrimEnd([System.IO.Path]::DirectorySeparatorChar)
23
+ $HomeRoot = [System.IO.Path]::GetFullPath($HOME).TrimEnd([System.IO.Path]::DirectorySeparatorChar)
24
+ if (-not $ServiceRootExplicit -and $ServiceRoot -eq $HomeRoot) {
25
+ throw "Refusing to expose the entire home directory implicitly; run from a project directory or set AGENTROAM_ROOT explicitly"
26
+ }
27
+
28
+ New-Item -ItemType Directory -Force -Path $NodeParent, $LauncherParent, $WrapperDir | Out-Null
29
+
30
+ function Test-Node22([string]$NodeBin) {
31
+ if (-not (Test-Path -LiteralPath $NodeBin -PathType Leaf)) { return $false }
32
+ try { return (& $NodeBin -p 'process.versions.node.split(".")[0]' 2>$null) -eq "22" } catch { return $false }
33
+ }
34
+
35
+ function Test-ManagedNode {
36
+ $NodeBin = Join-Path $NodeRoot "node.exe"
37
+ $NpmCli = Join-Path $NodeRoot "node_modules\npm\bin\npm-cli.js"
38
+ if (-not (Test-Path -LiteralPath $NodeBin -PathType Leaf) -or -not (Test-Path -LiteralPath $NpmCli -PathType Leaf)) { return $false }
39
+ try { return (& $NodeBin --version 2>$null) -eq "v$NodeVersion" } catch { return $false }
40
+ }
41
+
42
+ function Enter-InstallLock([string]$Path) {
43
+ $Deadline = [DateTime]::UtcNow.AddMinutes(15)
44
+ while ($true) {
45
+ try {
46
+ $Stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
47
+ $Bytes = [Text.Encoding]::UTF8.GetBytes("$PID`n$([DateTime]::UtcNow.ToString('o'))`n")
48
+ $Stream.Write($Bytes, 0, $Bytes.Length)
49
+ return $Stream
50
+ } catch [System.IO.IOException] {
51
+ if (Test-Path -LiteralPath $Path) {
52
+ $Age = [DateTime]::UtcNow - (Get-Item -LiteralPath $Path).LastWriteTimeUtc
53
+ if ($Age.TotalMinutes -gt 15) { Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue; continue }
54
+ }
55
+ if ([DateTime]::UtcNow -ge $Deadline) { throw "Timed out waiting for install lock $Path" }
56
+ Start-Sleep -Milliseconds 500
57
+ }
58
+ }
59
+ }
60
+
61
+ $SystemNode = Get-Command node.exe -ErrorAction SilentlyContinue
62
+ if ($env:AGENTROAM_BOOTSTRAP_TEST -eq "1" -and $env:AGENTROAM_FORCE_PRIVATE_NODE -eq "1") { $SystemNode = $null }
63
+ if ($SystemNode -and (Test-Node22 $SystemNode.Source)) {
64
+ $NodeBin = $SystemNode.Source
65
+ } else {
66
+ if (-not (Test-ManagedNode)) {
67
+ $NodeLockHandle = Enter-InstallLock $NodeLock
68
+ try {
69
+ if (-not (Test-ManagedNode)) {
70
+ $TempRoot = Join-Path $NodeParent ".node-$NodeVersion-$PID-$([Guid]::NewGuid().ToString('N'))"
71
+ $ArchivePath = Join-Path $TempRoot $NodeArchive
72
+ $ExtractPath = Join-Path $TempRoot "extract"
73
+ New-Item -ItemType Directory -Force -Path $ExtractPath | Out-Null
74
+ try {
75
+ Write-Host "Downloading Node.js $NodeVersion..."
76
+ if ($env:AGENTROAM_NODE_ARCHIVE_FILE) {
77
+ Copy-Item -LiteralPath $env:AGENTROAM_NODE_ARCHIVE_FILE -Destination $ArchivePath
78
+ } else {
79
+ Invoke-WebRequest -UseBasicParsing -TimeoutSec 600 -Uri $NodeUrl -OutFile $ArchivePath
80
+ }
81
+ $ActualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $ArchivePath).Hash.ToLowerInvariant()
82
+ if ($ActualHash -ne $NodeSha256) { throw "Node.js archive checksum mismatch" }
83
+ Expand-Archive -LiteralPath $ArchivePath -DestinationPath $ExtractPath -Force
84
+ $Entries = @(Get-ChildItem -LiteralPath $ExtractPath)
85
+ if ($Entries.Count -ne 1 -or $Entries[0].Name -ne "node-v$NodeVersion-win-x64") { throw "Unexpected Node.js archive layout" }
86
+ $Extracted = $Entries[0].FullName
87
+ $ExtractedNode = Join-Path $Extracted "node.exe"
88
+ $ExtractedNpm = Join-Path $Extracted "node_modules\npm\bin\npm-cli.js"
89
+ if (-not (Test-Path -LiteralPath $ExtractedNpm) -or (& $ExtractedNode --version) -ne "v$NodeVersion") { throw "Node.js runtime validation failed" }
90
+ Remove-Item -LiteralPath $NodeRoot -Recurse -Force -ErrorAction SilentlyContinue
91
+ Move-Item -LiteralPath $Extracted -Destination $NodeRoot
92
+ if (-not (Test-ManagedNode)) { throw "Node.js activation validation failed" }
93
+ } finally {
94
+ Remove-Item -LiteralPath $TempRoot -Recurse -Force -ErrorAction SilentlyContinue
95
+ }
96
+ }
97
+ } finally {
98
+ $NodeLockHandle.Dispose()
99
+ Remove-Item -LiteralPath $NodeLock -Force -ErrorAction SilentlyContinue
100
+ }
101
+ }
102
+ $NodeBin = Join-Path $NodeRoot "node.exe"
103
+ }
104
+
105
+ $NodeDirectory = Split-Path -Parent $NodeBin
106
+ $NpmCandidates = @(
107
+ (Join-Path $NodeDirectory "node_modules\npm\bin\npm-cli.js"),
108
+ (Join-Path $NodeDirectory "..\node_modules\npm\bin\npm-cli.js")
109
+ )
110
+ $NpmCli = $NpmCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
111
+ if (-not $NpmCli) { throw "npm CLI was not found beside $NodeBin" }
112
+
113
+ $PackageSpec = "agentroam@$AgentRoamVersion"
114
+ $ExtraPackageSpecs = @()
115
+ if ($env:AGENTROAM_BOOTSTRAP_TEST -eq "1" -and $env:AGENTROAM_PACKAGE_SPEC) {
116
+ $PackageSpec = $env:AGENTROAM_PACKAGE_SPEC
117
+ if ($env:AGENTROAM_BOOTSTRAP_EXTRA_SPECS) { $ExtraPackageSpecs = @($env:AGENTROAM_BOOTSTRAP_EXTRA_SPECS.Split(';') | Where-Object { $_ }) }
118
+ }
119
+ $Entry = Join-Path $LauncherRoot "node_modules\agentroam\bin\agentroam.mjs"
120
+
121
+ function Test-Launcher {
122
+ if (-not (Test-Path -LiteralPath $Entry -PathType Leaf)) { return $false }
123
+ try {
124
+ $Output = & $NodeBin $Entry version 2>$null
125
+ return $LASTEXITCODE -eq 0 -and $Output -match [Regex]::Escape($AgentRoamVersion)
126
+ } catch { return $false }
127
+ }
128
+
129
+ if (-not (Test-Launcher)) {
130
+ $LauncherLockHandle = Enter-InstallLock $LauncherLock
131
+ try {
132
+ if (-not (Test-Launcher)) {
133
+ $TempLauncher = Join-Path $LauncherParent ".launcher-$AgentRoamVersion-$PID-$([Guid]::NewGuid().ToString('N'))"
134
+ try {
135
+ $NpmArgs = @("install", "--no-audit", "--no-fund", "--registry", $NpmRegistry, "--prefix", $TempLauncher) + $ExtraPackageSpecs + @($PackageSpec)
136
+ & $NodeBin $NpmCli @NpmArgs
137
+ if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE" }
138
+ $TempEntry = Join-Path $TempLauncher "node_modules\agentroam\bin\agentroam.mjs"
139
+ $VersionOutput = & $NodeBin $TempEntry version
140
+ if ($LASTEXITCODE -ne 0 -or $VersionOutput -notmatch [Regex]::Escape($AgentRoamVersion)) { throw "AgentRoam version validation failed" }
141
+ Remove-Item -LiteralPath $LauncherRoot -Recurse -Force -ErrorAction SilentlyContinue
142
+ Move-Item -LiteralPath $TempLauncher -Destination $LauncherRoot
143
+ } finally {
144
+ Remove-Item -LiteralPath $TempLauncher -Recurse -Force -ErrorAction SilentlyContinue
145
+ }
146
+ }
147
+ } finally {
148
+ $LauncherLockHandle.Dispose()
149
+ Remove-Item -LiteralPath $LauncherLock -Force -ErrorAction SilentlyContinue
150
+ }
151
+ }
152
+
153
+ $WrapperTemp = "$WrapperPath.$PID.tmp"
154
+ $WrapperContent = "@echo off`r`n`"$NodeBin`" `"$Entry`" %*`r`n"
155
+ [System.IO.File]::WriteAllText($WrapperTemp, $WrapperContent, [Text.Encoding]::ASCII)
156
+ Move-Item -LiteralPath $WrapperTemp -Destination $WrapperPath -Force
157
+
158
+ $UserPath = [Environment]::GetEnvironmentVariable("Path", "User")
159
+ $PathParts = @($UserPath -split ";" | Where-Object { $_ })
160
+ if ($PathParts -notcontains $WrapperDir) {
161
+ $UpdatedPath = if ($UserPath) { "$UserPath;$WrapperDir" } else { $WrapperDir }
162
+ [Environment]::SetEnvironmentVariable("Path", $UpdatedPath, "User")
163
+ $env:Path = "$env:Path;$WrapperDir"
164
+ }
165
+
166
+ & $NodeBin $Entry doctor --data-dir $DataDir
167
+ if ($LASTEXITCODE -ne 0) { throw "AgentRoam doctor failed with exit code $LASTEXITCODE" }
168
+ if ($env:AGENTROAM_INSTALL_SKIP_SERVICE -eq "1") {
169
+ Write-Host "AgentRoam service registration skipped for isolated verification."
170
+ } else {
171
+ & $NodeBin $Entry service install --root $ServiceRoot --data-dir $DataDir
172
+ if ($LASTEXITCODE -ne 0) { throw "AgentRoam service installation failed with exit code $LASTEXITCODE" }
173
+ }
174
+ Write-Host "`nAgentRoam $AgentRoamVersion installed: $WrapperPath"
175
+ Write-Host "Open a new terminal, then run: agentroam service status"
@@ -0,0 +1,199 @@
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ NODE_VERSION="22.22.0"
5
+ AGENTROAM_VERSION="0.2.0-preview.9"
6
+ NODE_ARCHIVE="node-v22.22.0-darwin-arm64.tar.xz"
7
+ NODE_SHA256="2bd596bbfc4a275ceb8721a5954ee97daea5ebe673e96a185ebd732f6fb023ac"
8
+ NODE_URL="https://nodejs.org/dist/v22.22.0/$NODE_ARCHIVE"
9
+ NPM_REGISTRY="https://registry.npmjs.org"
10
+ DATA_DIR="${AGENTROAM_DATA_DIR:-$HOME/.agentroam}"
11
+ NODE_PARENT="$DATA_DIR/runtimes/node"
12
+ NODE_ROOT="$NODE_PARENT/$NODE_VERSION"
13
+ NODE_LOCK="$NODE_ROOT.lock"
14
+ LAUNCHER_PARENT="$DATA_DIR/launcher"
15
+ LAUNCHER_ROOT="$LAUNCHER_PARENT/$AGENTROAM_VERSION"
16
+ LAUNCHER_LOCK="$LAUNCHER_ROOT.lock"
17
+ WRAPPER_DIR="$HOME/.local/bin"
18
+ WRAPPER_PATH="$WRAPPER_DIR/agentroam"
19
+ SERVICE_ROOT_EXPLICIT=0
20
+ if [ "${AGENTROAM_ROOT+x}" = "x" ]; then
21
+ SERVICE_ROOT="$AGENTROAM_ROOT"
22
+ SERVICE_ROOT_EXPLICIT=1
23
+ else
24
+ SERVICE_ROOT="$PWD"
25
+ fi
26
+ TEMP_ROOT=""
27
+ OWN_NODE_LOCK=0
28
+ OWN_LAUNCHER_LOCK=0
29
+
30
+ fail() {
31
+ printf 'agentroam installer: %s\n' "$*" >&2
32
+ exit 1
33
+ }
34
+
35
+ cleanup() {
36
+ if [ -n "$TEMP_ROOT" ] && [ -d "$TEMP_ROOT" ]; then
37
+ rm -rf "$TEMP_ROOT"
38
+ fi
39
+ if [ "$OWN_NODE_LOCK" -eq 1 ]; then rmdir "$NODE_LOCK" 2>/dev/null || true; fi
40
+ if [ "$OWN_LAUNCHER_LOCK" -eq 1 ]; then rmdir "$LAUNCHER_LOCK" 2>/dev/null || true; fi
41
+ }
42
+ trap cleanup EXIT HUP INT TERM
43
+
44
+ [ "$(uname -s)" = "Darwin" ] || fail "this installer supports macOS only"
45
+ [ "$(uname -m)" = "arm64" ] || fail "managed Node.js supports Apple Silicon only"
46
+ command -v curl >/dev/null 2>&1 || fail "curl is required"
47
+ command -v shasum >/dev/null 2>&1 || fail "shasum is required"
48
+ [ -x /usr/bin/tar ] || fail "/usr/bin/tar is required"
49
+ [ -d "$SERVICE_ROOT" ] || fail "AgentRoam root is not a directory: $SERVICE_ROOT"
50
+ service_root_physical=$(cd "$SERVICE_ROOT" && pwd -P)
51
+ home_physical=$(cd "$HOME" && pwd -P)
52
+ if [ "$SERVICE_ROOT_EXPLICIT" -eq 0 ] && [ "$service_root_physical" = "$home_physical" ]; then
53
+ fail "refusing to expose the entire home directory implicitly; run from a project directory or set AGENTROAM_ROOT explicitly"
54
+ fi
55
+ SERVICE_ROOT="$service_root_physical"
56
+
57
+ mkdir -p "$NODE_PARENT" "$LAUNCHER_PARENT" "$WRAPPER_DIR"
58
+
59
+ node_is_22() {
60
+ [ -x "$1" ] && [ "$("$1" -p 'process.versions.node.split(".")[0]' 2>/dev/null || true)" = "22" ]
61
+ }
62
+
63
+ runtime_is_valid() {
64
+ [ -x "$NODE_ROOT/bin/node" ] \
65
+ && [ -f "$NODE_ROOT/lib/node_modules/npm/bin/npm-cli.js" ] \
66
+ && [ "$("$NODE_ROOT/bin/node" --version 2>/dev/null || true)" = "v$NODE_VERSION" ]
67
+ }
68
+
69
+ acquire_directory_lock() {
70
+ lock_path=$1
71
+ waited=0
72
+ while ! mkdir "$lock_path" 2>/dev/null; do
73
+ if [ "$waited" -ge 900 ]; then
74
+ lock_mtime=$(stat -f %m "$lock_path" 2>/dev/null || printf '0')
75
+ now=$(date +%s)
76
+ if [ $((now - lock_mtime)) -gt 900 ]; then
77
+ rmdir "$lock_path" 2>/dev/null || true
78
+ waited=0
79
+ continue
80
+ fi
81
+ fail "timed out waiting for install lock $lock_path"
82
+ fi
83
+ sleep 1
84
+ waited=$((waited + 1))
85
+ done
86
+ }
87
+
88
+ SYSTEM_NODE=$(command -v node 2>/dev/null || true)
89
+ if [ "${AGENTROAM_BOOTSTRAP_TEST:-}" = "1" ] && [ "${AGENTROAM_FORCE_PRIVATE_NODE:-}" = "1" ]; then
90
+ SYSTEM_NODE=""
91
+ fi
92
+ if [ -n "$SYSTEM_NODE" ] && node_is_22 "$SYSTEM_NODE"; then
93
+ NODE_BIN="$SYSTEM_NODE"
94
+ else
95
+ if ! runtime_is_valid; then
96
+ acquire_directory_lock "$NODE_LOCK"
97
+ OWN_NODE_LOCK=1
98
+ if ! runtime_is_valid; then
99
+ TEMP_ROOT=$(mktemp -d "$NODE_PARENT/.node-$NODE_VERSION.XXXXXX")
100
+ archive_path="$TEMP_ROOT/$NODE_ARCHIVE"
101
+ extract_path="$TEMP_ROOT/extract"
102
+ mkdir "$extract_path"
103
+ printf 'Downloading Node.js %s...\n' "$NODE_VERSION"
104
+ if [ -n "${AGENTROAM_NODE_ARCHIVE_FILE:-}" ]; then
105
+ cp "$AGENTROAM_NODE_ARCHIVE_FILE" "$archive_path"
106
+ else
107
+ download_attempt=1
108
+ while ! curl -fL -C - --connect-timeout 15 --max-time 600 "$NODE_URL" -o "$archive_path"; do
109
+ [ "$download_attempt" -lt 3 ] || fail "Node.js download failed after 3 attempts"
110
+ download_attempt=$((download_attempt + 1))
111
+ sleep "$download_attempt"
112
+ done
113
+ fi
114
+ actual_sha=$(shasum -a 256 "$archive_path" | awk '{print $1}')
115
+ [ "$actual_sha" = "$NODE_SHA256" ] || fail "Node.js archive checksum mismatch"
116
+ /usr/bin/tar -xJf "$archive_path" -C "$extract_path"
117
+ entry_count=$(find "$extract_path" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')
118
+ [ "$entry_count" = "1" ] || fail "unexpected Node.js archive layout"
119
+ extracted="$extract_path/node-v$NODE_VERSION-darwin-arm64"
120
+ [ -x "$extracted/bin/node" ] || fail "Node.js executable is missing"
121
+ [ -f "$extracted/lib/node_modules/npm/bin/npm-cli.js" ] || fail "npm CLI is missing"
122
+ [ "$("$extracted/bin/node" --version)" = "v$NODE_VERSION" ] || fail "Node.js version validation failed"
123
+ rm -rf "$NODE_ROOT"
124
+ mv "$extracted" "$NODE_ROOT"
125
+ runtime_is_valid || fail "Node.js activation validation failed"
126
+ rm -rf "$TEMP_ROOT"
127
+ TEMP_ROOT=""
128
+ fi
129
+ rmdir "$NODE_LOCK" 2>/dev/null || true
130
+ OWN_NODE_LOCK=0
131
+ fi
132
+ NODE_BIN="$NODE_ROOT/bin/node"
133
+ fi
134
+
135
+ find_npm_cli() {
136
+ node_dir=$(dirname "$NODE_BIN")
137
+ for candidate in \
138
+ "$node_dir/../lib/node_modules/npm/bin/npm-cli.js" \
139
+ "$node_dir/lib/node_modules/npm/bin/npm-cli.js"; do
140
+ if [ -f "$candidate" ]; then
141
+ printf '%s\n' "$candidate"
142
+ return
143
+ fi
144
+ done
145
+ fail "npm CLI was not found beside $NODE_BIN"
146
+ }
147
+
148
+ NPM_CLI=$(find_npm_cli)
149
+ PACKAGE_SPEC="agentroam@$AGENTROAM_VERSION"
150
+ EXTRA_PACKAGE_SPECS=""
151
+ if [ "${AGENTROAM_BOOTSTRAP_TEST:-}" = "1" ] && [ -n "${AGENTROAM_PACKAGE_SPEC:-}" ]; then
152
+ PACKAGE_SPEC="$AGENTROAM_PACKAGE_SPEC"
153
+ EXTRA_PACKAGE_SPECS="${AGENTROAM_BOOTSTRAP_EXTRA_SPECS:-}"
154
+ fi
155
+
156
+ launcher_is_valid() {
157
+ [ -f "$LAUNCHER_ROOT/node_modules/agentroam/bin/agentroam.mjs" ] \
158
+ && [ "$("$NODE_BIN" "$LAUNCHER_ROOT/node_modules/agentroam/bin/agentroam.mjs" version 2>/dev/null | awk '{print $2}' || true)" = "$AGENTROAM_VERSION" ]
159
+ }
160
+
161
+ if ! launcher_is_valid; then
162
+ acquire_directory_lock "$LAUNCHER_LOCK"
163
+ OWN_LAUNCHER_LOCK=1
164
+ if ! launcher_is_valid; then
165
+ TEMP_ROOT=$(mktemp -d "$LAUNCHER_PARENT/.launcher-$AGENTROAM_VERSION.XXXXXX")
166
+ # Test-only extra specs are whitespace-delimited artifact paths in isolated CI directories.
167
+ # shellcheck disable=SC2086
168
+ "$NODE_BIN" "$NPM_CLI" install --no-audit --no-fund --registry "$NPM_REGISTRY" --prefix "$TEMP_ROOT" $EXTRA_PACKAGE_SPECS "$PACKAGE_SPEC"
169
+ entry="$TEMP_ROOT/node_modules/agentroam/bin/agentroam.mjs"
170
+ [ -f "$entry" ] || fail "AgentRoam launcher is missing after npm install"
171
+ [ "$("$NODE_BIN" "$entry" version | awk '{print $2}')" = "$AGENTROAM_VERSION" ] || fail "AgentRoam version validation failed"
172
+ rm -rf "$LAUNCHER_ROOT"
173
+ mv "$TEMP_ROOT" "$LAUNCHER_ROOT"
174
+ TEMP_ROOT=""
175
+ fi
176
+ rmdir "$LAUNCHER_LOCK" 2>/dev/null || true
177
+ OWN_LAUNCHER_LOCK=0
178
+ fi
179
+
180
+ entry="$LAUNCHER_ROOT/node_modules/agentroam/bin/agentroam.mjs"
181
+ wrapper_temp=$(mktemp "$WRAPPER_DIR/.agentroam.XXXXXX")
182
+ {
183
+ printf '#!/bin/sh\n'
184
+ printf 'exec "%s" "%s" "$@"\n' "$NODE_BIN" "$entry"
185
+ } > "$wrapper_temp"
186
+ chmod 755 "$wrapper_temp"
187
+ mv "$wrapper_temp" "$WRAPPER_PATH"
188
+
189
+ "$NODE_BIN" "$entry" doctor --data-dir "$DATA_DIR"
190
+ if [ "${AGENTROAM_INSTALL_SKIP_SERVICE:-}" = "1" ]; then
191
+ printf 'AgentRoam service registration skipped for isolated verification.\n'
192
+ else
193
+ "$NODE_BIN" "$entry" service install --root "$SERVICE_ROOT" --data-dir "$DATA_DIR"
194
+ fi
195
+ printf '\nAgentRoam %s installed: %s\n' "$AGENTROAM_VERSION" "$WRAPPER_PATH"
196
+ case ":${PATH:-}:" in
197
+ *":$WRAPPER_DIR:"*) ;;
198
+ *) printf 'Add this directory to PATH: export PATH="%s:$PATH"\n' "$WRAPPER_DIR" ;;
199
+ esac
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentroam",
3
- "version": "0.2.0-preview.8",
3
+ "version": "0.2.0-preview.9",
4
4
  "description": "Zero-install mobile remote terminal and file console",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,7 @@
13
13
  "files": [
14
14
  "bin",
15
15
  "dist",
16
+ "install",
16
17
  "README.md"
17
18
  ],
18
19
  "scripts": {
@@ -25,12 +26,12 @@
25
26
  "undici": "^6.25.0"
26
27
  },
27
28
  "optionalDependencies": {
28
- "agentroam-runtime-darwin-arm64": "0.2.0-preview.8",
29
- "agentroam-runtime-win32-x64": "0.2.0-preview.8",
30
- "agentroam-cloudflared-darwin-arm64": "0.2.0-preview.8",
31
- "agentroam-cloudflared-win32-x64": "0.2.0-preview.8",
32
- "agentroam-tui-darwin-arm64": "0.2.0-preview.8",
33
- "@caoqu/agentroam-tui-win32-x64": "0.2.0-preview.8"
29
+ "agentroam-runtime-darwin-arm64": "0.2.0-preview.9",
30
+ "agentroam-runtime-win32-x64": "0.2.0-preview.9",
31
+ "agentroam-cloudflared-darwin-arm64": "0.2.0-preview.9",
32
+ "agentroam-cloudflared-win32-x64": "0.2.0-preview.9",
33
+ "agentroam-tui-darwin-arm64": "0.2.0-preview.9",
34
+ "@caoqu/agentroam-tui-win32-x64": "0.2.0-preview.9"
34
35
  },
35
36
  "devDependencies": {
36
37
  "@types/node": "^22.0.0",