agent-yes 1.192.1 → 1.192.3

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 (30) hide show
  1. package/dist/{SUPPORTED_CLIS-5N015orJ.js → SUPPORTED_CLIS--9MiXfni.js} +2 -2
  2. package/dist/SUPPORTED_CLIS-CZnCl1OU.js +8 -0
  3. package/dist/{agentShare-C4LxU2xT.js → agentShare-B6znJtFD.js} +3 -3
  4. package/dist/cli.js +7 -7
  5. package/dist/index.js +2 -2
  6. package/dist/nodeRuntime-DZRjxpNp.js +56 -0
  7. package/dist/{notifyDaemon--OxBe8nG.js → notifyDaemon-RhUYKA0l.js} +3 -3
  8. package/dist/{remotes-WVJBNajZ.js → remotes-CW-yxSgW.js} +1 -1
  9. package/dist/{remotes-CgT91bMo.js → remotes-oSmwSYaV.js} +2 -2
  10. package/dist/{rustBinary-DFoLw-3W.js → rustBinary-DUEd_K1i.js} +2 -2
  11. package/dist/{schedule-AZACZY6R.js → schedule-Dx_x0HRj.js} +24 -15
  12. package/dist/{serve-CSijvUDO.js → serve-BAeHlY-f.js} +61 -62
  13. package/dist/{setup-ChMQrv6t.js → setup-C0Lc_Mui.js} +2 -2
  14. package/dist/{spawnGate-BejtWiMJ.js → spawnGate-CWbdnJ5K.js} +1 -1
  15. package/dist/{spawnGate-CA5QD7D4.js → spawnGate-DRWa_Fzj.js} +1 -1
  16. package/dist/{subcommands-czdlkI5W.js → subcommands-BM61pkrL.js} +2 -2
  17. package/dist/{subcommands-DMe0U83W.js → subcommands-CH7ZKLkx.js} +9 -9
  18. package/dist/{tray-PMOWFOfk.js → tray-BKTZ5gIf.js} +1 -1
  19. package/dist/{ts-7XqYx0eW.js → ts-9Wbluc4o.js} +2 -2
  20. package/dist/{versionChecker-CUMd5SWt.js → versionChecker-DzFI7V_S.js} +2 -2
  21. package/dist/{webrtcRemote-CD5EQ3uX.js → webrtcRemote-BmuVKcrx.js} +1 -1
  22. package/lab/ui/index.html +58 -6
  23. package/package.json +1 -1
  24. package/ts/nodeRuntime.spec.ts +99 -0
  25. package/ts/nodeRuntime.ts +58 -0
  26. package/ts/oxmgrService.ts +6 -0
  27. package/ts/schedule.ts +8 -0
  28. package/ts/serve.spec.ts +34 -81
  29. package/ts/serve.ts +21 -46
  30. package/dist/SUPPORTED_CLIS-W067Yn4T.js +0 -8
@@ -7,7 +7,7 @@ import { fileURLToPath } from "url";
7
7
 
8
8
  //#region package.json
9
9
  var name = "agent-yes";
10
- var version = "1.192.1";
10
+ var version = "1.192.3";
11
11
 
12
12
  //#endregion
13
13
  //#region ts/versionChecker.ts
@@ -215,4 +215,4 @@ async function displayVersion() {
215
215
 
216
216
  //#endregion
217
217
  export { versionString as i, displayVersion as n, getInstalledPackage as r, checkAndAutoUpdate as t };
218
- //# sourceMappingURL=versionChecker-CUMd5SWt.js.map
218
+ //# sourceMappingURL=versionChecker-DzFI7V_S.js.map
@@ -256,4 +256,4 @@ async function startWebrtcBridge(link) {
256
256
 
257
257
  //#endregion
258
258
  export { startWebrtcBridge };
259
- //# sourceMappingURL=webrtcRemote-CD5EQ3uX.js.map
259
+ //# sourceMappingURL=webrtcRemote-BmuVKcrx.js.map
package/lab/ui/index.html CHANGED
@@ -1947,6 +1947,7 @@
1947
1947
  </p>
1948
1948
  <div class="srow">
1949
1949
  <button class="primary" id="exposeGo" type="button">Expose &amp; open</button>
1950
+ <button id="exposeCopy" type="button">Copy link</button>
1950
1951
  <button id="exposeCancel" type="button">Cancel</button>
1951
1952
  </div>
1952
1953
  </div>
@@ -3364,11 +3365,9 @@
3364
3365
 
3365
3366
  // Agree → mint the exposure on its host and open the claim link (which sets
3366
3367
  // the 8h cookie and redirects to the app), then refresh the manager list.
3367
- async function doExpose() {
3368
- const tx = exposePendingTx || localTx;
3369
- const port = exposePendingPort;
3370
- $("exposePrompt").hidden = true;
3371
- if (!port) return;
3368
+ // Create (or reuse) the exposure and mint a fresh single-use claim link.
3369
+ // Returns the {url, claim, …} info, or null on failure (with a message).
3370
+ async function exposeNow(port, tx) {
3372
3371
  let info = null;
3373
3372
  try {
3374
3373
  const r = await tx.post("/api/expose", { port });
@@ -3377,11 +3376,48 @@
3377
3376
  if (!info || !info.claim) {
3378
3377
  $("exposeEmpty").hidden = false;
3379
3378
  $("exposeEmpty").textContent = "Couldn't expose port " + port + " (is `ay serve` reachable?).";
3380
- return;
3379
+ return null;
3381
3380
  }
3381
+ return info;
3382
+ }
3383
+ async function copyText(s) {
3384
+ try {
3385
+ await navigator.clipboard.writeText(s);
3386
+ return true;
3387
+ } catch {
3388
+ return false;
3389
+ }
3390
+ }
3391
+ // Briefly show "Copied!" on a button, then restore its label.
3392
+ function flashCopied(btn) {
3393
+ if (!btn) return;
3394
+ const orig = btn.textContent;
3395
+ btn.textContent = "Copied!";
3396
+ setTimeout(() => {
3397
+ btn.textContent = orig;
3398
+ }, 1200);
3399
+ }
3400
+ async function doExpose() {
3401
+ const tx = exposePendingTx || localTx;
3402
+ const port = exposePendingPort;
3403
+ $("exposePrompt").hidden = true;
3404
+ if (!port) return;
3405
+ const info = await exposeNow(port, tx);
3406
+ if (!info) return;
3382
3407
  window.open(info.claim, "_blank", "noopener,noreferrer");
3383
3408
  refreshExposeList();
3384
3409
  }
3410
+ async function doExposeCopy() {
3411
+ const tx = exposePendingTx || localTx;
3412
+ const port = exposePendingPort;
3413
+ if (!port) return;
3414
+ const info = await exposeNow(port, tx);
3415
+ if (!info) return;
3416
+ await copyText(info.claim);
3417
+ flashCopied($("exposeCopy"));
3418
+ $("exposePrompt").hidden = true;
3419
+ refreshExposeList();
3420
+ }
3385
3421
 
3386
3422
  // Gather active exposures from every live host and render the manager rows.
3387
3423
  async function refreshExposeList() {
@@ -3449,6 +3485,21 @@
3449
3485
  };
3450
3486
  row.appendChild(openBtn);
3451
3487
 
3488
+ const copyBtn = document.createElement("button");
3489
+ copyBtn.textContent = "Copy";
3490
+ // Mint a fresh single-use claim link and copy it (to share access).
3491
+ copyBtn.onclick = async () => {
3492
+ let link = ex.url;
3493
+ try {
3494
+ const r = await ex._tx.post("/api/expose", { port: ex.port });
3495
+ const info = r.ok ? JSON.parse(r.text) : null;
3496
+ if (info && info.claim) link = info.claim;
3497
+ } catch {}
3498
+ await copyText(link);
3499
+ flashCopied(copyBtn);
3500
+ };
3501
+ row.appendChild(copyBtn);
3502
+
3452
3503
  const revoke = document.createElement("button");
3453
3504
  revoke.className = "danger";
3454
3505
  revoke.textContent = "Revoke";
@@ -3514,6 +3565,7 @@
3514
3565
  (function exposeModalBoot() {
3515
3566
  $("portsbtn")?.addEventListener("click", () => openExposeManager(true));
3516
3567
  $("exposeGo")?.addEventListener("click", doExpose);
3568
+ $("exposeCopy")?.addEventListener("click", doExposeCopy);
3517
3569
  $("exposeCancel")?.addEventListener("click", () => {
3518
3570
  $("exposePrompt").hidden = true;
3519
3571
  $("exposeTitle").textContent = "Exposed ports";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-yes",
3
- "version": "1.192.1",
3
+ "version": "1.192.3",
4
4
  "description": "A wrapper tool that automates interactions with various AI CLI tools by automatically handling common prompts and responses.",
5
5
  "keywords": [
6
6
  "ai",
@@ -0,0 +1,99 @@
1
+ import { mkdtemp, readFile, rm, stat } from "fs/promises";
2
+ import { tmpdir } from "os";
3
+ import path from "path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { ensureNodeRuntime, liveEnv } from "./nodeRuntime.ts";
6
+
7
+ // process.platform is stubbed per test (instead of skipIf) so every branch of
8
+ // ensureNodeRuntime is exercised on every CI OS — the win32 early-return on
9
+ // POSIX runners and the POSIX shim path on Windows runners (mkdir/writeFile/
10
+ // chmod all work there; the shim just isn't consulted by real spawns).
11
+ const realPlatform = process.platform;
12
+ function setPlatform(p: string) {
13
+ Object.defineProperty(process, "platform", { value: p, configurable: true });
14
+ }
15
+
16
+ // The bun-only-no-node path (sym003): pm2/oxmgr bins are `#!/usr/bin/env node`
17
+ // scripts, so with no node on PATH they die at the shebang. ensureNodeRuntime
18
+ // must bridge that with a node→bun shim in ay's own bin dir.
19
+ describe("ensureNodeRuntime", () => {
20
+ let home: string;
21
+ let savedPath: string | undefined;
22
+ let savedHome: string | undefined;
23
+
24
+ beforeEach(async () => {
25
+ home = await mkdtemp(path.join(tmpdir(), "ay-node-shim-"));
26
+ savedPath = process.env.PATH;
27
+ savedHome = process.env.AGENT_YES_HOME;
28
+ process.env.AGENT_YES_HOME = home;
29
+ setPlatform("linux");
30
+ });
31
+
32
+ afterEach(async () => {
33
+ setPlatform(realPlatform);
34
+ process.env.PATH = savedPath;
35
+ if (savedHome === undefined) delete process.env.AGENT_YES_HOME;
36
+ else process.env.AGENT_YES_HOME = savedHome;
37
+ await rm(home, { recursive: true, force: true });
38
+ });
39
+
40
+ const noNode = (cmd: string) => (cmd === "bun" ? "/fake/bin/bun" : null);
41
+
42
+ it("writes an executable node→bun shim and prepends it to PATH when node is absent", async () => {
43
+ const shim = await ensureNodeRuntime(noNode);
44
+ expect(shim).toBe(path.join(home, "bin", "node"));
45
+ const body = await readFile(shim!, "utf-8");
46
+ expect(body).toBe("#!/bin/sh\nexec '/fake/bin/bun' \"$@\"\n");
47
+ if (realPlatform !== "win32") {
48
+ expect((await stat(shim!)).mode & 0o111).not.toBe(0); // executable
49
+ }
50
+ expect(process.env.PATH!.split(path.delimiter)[0]).toBe(path.join(home, "bin"));
51
+ });
52
+
53
+ it("does not duplicate the PATH entry", async () => {
54
+ await ensureNodeRuntime(noNode);
55
+ await ensureNodeRuntime(noNode);
56
+ const binDir = path.join(home, "bin");
57
+ const hits = process.env.PATH!.split(path.delimiter).filter((p) => p === binDir);
58
+ expect(hits).toHaveLength(1);
59
+ });
60
+
61
+ it("leaves an up-to-date shim untouched on repeat calls (read-only paths stay read-only)", async () => {
62
+ const shim = (await ensureNodeRuntime(noNode))!;
63
+ const before = await stat(shim);
64
+ await new Promise((r) => setTimeout(r, 20));
65
+ await ensureNodeRuntime(noNode);
66
+ const after = await stat(shim);
67
+ expect(after.mtimeMs).toBe(before.mtimeMs); // not rewritten
68
+ });
69
+
70
+ it("single-quotes a bun path containing sh-special characters", async () => {
71
+ const shim = await ensureNodeRuntime((cmd) => (cmd === "bun" ? "/opt/we$ird `dir'/bun" : null));
72
+ const body = await readFile(shim!, "utf-8");
73
+ expect(body).toBe("#!/bin/sh\nexec '/opt/we$ird `dir'\\''/bun' \"$@\"\n");
74
+ });
75
+
76
+ it("is a no-op when a real node exists", async () => {
77
+ expect(await ensureNodeRuntime(() => "/usr/bin/whatever")).toBeNull();
78
+ });
79
+
80
+ it("is a no-op when bun is missing too (nothing to shim to)", async () => {
81
+ expect(await ensureNodeRuntime(() => null)).toBeNull();
82
+ });
83
+
84
+ it("is a no-op on Windows (npm .cmd shims invoke node.exe directly)", async () => {
85
+ setPlatform("win32");
86
+ expect(await ensureNodeRuntime(noNode)).toBeNull();
87
+ });
88
+ });
89
+
90
+ describe("liveEnv", () => {
91
+ it("carries post-startup process.env mutations (unlike Bun.spawn's implicit env)", () => {
92
+ process.env.AY_LIVE_ENV_TEST = "set-after-start";
93
+ try {
94
+ expect(liveEnv().AY_LIVE_ENV_TEST).toBe("set-after-start");
95
+ } finally {
96
+ delete process.env.AY_LIVE_ENV_TEST;
97
+ }
98
+ });
99
+ });
@@ -0,0 +1,58 @@
1
+ import { chmod, mkdir, readFile, writeFile } from "fs/promises";
2
+ import path from "path";
3
+ import { agentYesHome } from "./agentYesHome.ts";
4
+
5
+ /**
6
+ * Snapshot of the CURRENT process.env for child spawns. Bun.spawn without an
7
+ * explicit `env` hands the child the environ captured at PROCESS STARTUP — not
8
+ * the live process.env — so post-startup mutations (ensureNodeRuntime's shim
9
+ * PATH prepend, installAndVerify's global-bin-dir prepend) silently never reach
10
+ * the child. That is exactly how `ay serve install` kept dying with
11
+ * `env: node: No such file or directory` at the `pm2 start` spawn on a bun-only
12
+ * box even though the probe (which passes env explicitly) had just succeeded.
13
+ * Every spawn of a process-manager binary must pass `env: liveEnv()`.
14
+ */
15
+ export function liveEnv(): Record<string, string | undefined> {
16
+ return { ...process.env };
17
+ }
18
+
19
+ /**
20
+ * Both oxmgr's and pm2's global bins are `#!/usr/bin/env node` JS scripts, so on
21
+ * a bun-only box (setup.sh installs no node) exec dies with "env: node: No such
22
+ * file or directory" — even though pm2 is pure JS and runs perfectly under bun.
23
+ * When node is missing but bun exists, write a node→bun shim into ay's own bin
24
+ * dir and prepend it to PATH so `env node` resolves. NOT solvable with
25
+ * `bun add -g node`: that package (node-bin-gen) lands a broken arch-specific
26
+ * stub and `Bun.which("node")` still finds nothing. POSIX-only — on Windows the
27
+ * npm .cmd shims invoke node.exe directly and an sh script can't stand in.
28
+ * Read-only paths (`ay serve status`) also reach this via the manager probes:
29
+ * an up-to-date shim is left untouched there (a read, not a write), so only the
30
+ * very first run on a node-less box materializes the file.
31
+ * Returns the shim path when the shim is in effect, null when unneeded/impossible.
32
+ */
33
+ export async function ensureNodeRuntime(
34
+ which: (cmd: string) => string | null = Bun.which,
35
+ ): Promise<string | null> {
36
+ if (process.platform === "win32") return null;
37
+ if (which("node")) return null;
38
+ const bun = which("bun");
39
+ if (!bun) return null;
40
+ const binDir = path.join(agentYesHome(), "bin");
41
+ const shim = path.join(binDir, "node");
42
+ // Single-quote the bun path: inside sh double quotes `$`, backtick and `\`
43
+ // would still expand, corrupting the shim for a path containing them.
44
+ const body = `#!/bin/sh\nexec '${bun.replace(/'/g, `'\\''`)}' "$@"\n`;
45
+ try {
46
+ if ((await readFile(shim, "utf-8").catch(() => null)) !== body) {
47
+ await mkdir(binDir, { recursive: true });
48
+ await writeFile(shim, body);
49
+ await chmod(shim, 0o755);
50
+ }
51
+ if (!(process.env.PATH ?? "").split(path.delimiter).includes(binDir)) {
52
+ process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
53
+ }
54
+ return shim;
55
+ } catch {
56
+ return null; // best-effort: the exec probe will fail and name the reason
57
+ }
58
+ }
@@ -1,3 +1,4 @@
1
+ import { liveEnv } from "./nodeRuntime.ts";
1
2
  // Register oxmgr's daemon with the platform init system (launchd on macOS,
2
3
  // systemd on Linux, Task Scheduler on Windows) so managed processes survive a
3
4
  // *reboot*, not just a crash.
@@ -18,8 +19,12 @@ export async function ensureBootAutostart(oxmgrBin: string): Promise<boolean> {
18
19
  try {
19
20
  // Already registered with the init system? Then we're done — don't bounce
20
21
  // the daemon (and all its children) just to re-assert what's already true.
22
+ // env: liveEnv() so the node→bun shim's PATH prepend reaches oxmgr's
23
+ // `#!/usr/bin/env node` launcher (implicit inheritance uses the startup
24
+ // environ, missing post-startup mutations).
21
25
  const status = Bun.spawn([oxmgrBin, "service", "status"], {
22
26
  stdio: ["ignore", "ignore", "ignore"],
27
+ env: liveEnv(),
23
28
  });
24
29
  if ((await status.exited) === 0) return true;
25
30
 
@@ -28,6 +33,7 @@ export async function ensureBootAutostart(oxmgrBin: string): Promise<boolean> {
28
33
  // so passing it after `install` is rejected.
29
34
  const svc = Bun.spawn([oxmgrBin, "service", "install"], {
30
35
  stdio: ["ignore", "ignore", "ignore"],
36
+ env: liveEnv(),
31
37
  });
32
38
  return (await svc.exited) === 0;
33
39
  } catch {
package/ts/schedule.ts CHANGED
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { SUPPORTED_CLIS } from "./SUPPORTED_CLIS.ts";
3
3
  import { resolveSpawnCwd } from "./workspaceConfig.ts";
4
4
  import { ensureBootAutostart } from "./oxmgrService.ts";
5
+ import { ensureNodeRuntime, liveEnv } from "./nodeRuntime.ts";
5
6
 
6
7
  // `ay schedule` — run an agent on a recurring schedule via oxmgr's cron support.
7
8
  // A scheduled job runs once immediately AND on every cron tick (with
@@ -33,16 +34,23 @@ function schedName(explicit: string | undefined, cli: string, key: string): stri
33
34
  }
34
35
 
35
36
  async function run(cmd: string[], capture = false): Promise<{ code: number; out: string }> {
37
+ // env: liveEnv() — oxmgr's global bin is a `#!/usr/bin/env node` script, and
38
+ // ensureNodeRuntime's shim PATH prepend only reaches children given the LIVE
39
+ // process.env (Bun.spawn's implicit inheritance uses the startup environ).
36
40
  const p = Bun.spawn(cmd, {
37
41
  stdin: "ignore",
38
42
  stdout: capture ? "pipe" : "inherit",
39
43
  stderr: capture ? "pipe" : "inherit",
44
+ env: liveEnv(),
40
45
  });
41
46
  const out = capture ? await new Response(p.stdout).text() : "";
42
47
  return { code: (await p.exited) ?? 1, out };
43
48
  }
44
49
 
45
50
  export async function cmdSchedule(rest: string[]): Promise<number> {
51
+ // On a bun-only box oxmgr's `#!/usr/bin/env node` launcher needs the node→bun
52
+ // shim before ANY oxmgr call below can exec (same fix as serve-install).
53
+ await ensureNodeRuntime();
46
54
  const oxmgrBin = Bun.which("oxmgr");
47
55
  if (!oxmgrBin) {
48
56
  process.stderr.write(
package/ts/serve.spec.ts CHANGED
@@ -1,8 +1,6 @@
1
- import { mkdtemp, readFile, rm, stat } from "fs/promises";
2
- import { tmpdir } from "os";
3
- import path from "path";
4
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
- import { ensureNodeRuntime, isNoNodeExecError, oxmgrVersionHasWindowsFix } from "./serve.ts";
1
+ import { readFile } from "fs/promises";
2
+ import { describe, expect, it } from "vitest";
3
+ import { isNoNodeExecError, oxmgrVersionHasWindowsFix } from "./serve.ts";
6
4
 
7
5
  // Guards the Windows daemon-manager selection: on Windows we only PREFER oxmgr
8
6
  // when the installed build carries the daemon-socket-inheritance fix. Stock
@@ -35,82 +33,6 @@ describe("oxmgrVersionHasWindowsFix", () => {
35
33
  });
36
34
  });
37
35
 
38
- // The bun-only-no-node path (sym003): pm2/oxmgr bins are `#!/usr/bin/env node`
39
- // scripts, so with no node on PATH they die at the shebang. ensureNodeRuntime
40
- // must bridge that with a node→bun shim in ay's own bin dir.
41
- describe("ensureNodeRuntime", () => {
42
- let home: string;
43
- let savedPath: string | undefined;
44
- let savedHome: string | undefined;
45
-
46
- beforeEach(async () => {
47
- home = await mkdtemp(path.join(tmpdir(), "ay-node-shim-"));
48
- savedPath = process.env.PATH;
49
- savedHome = process.env.AGENT_YES_HOME;
50
- process.env.AGENT_YES_HOME = home;
51
- });
52
-
53
- afterEach(async () => {
54
- process.env.PATH = savedPath;
55
- if (savedHome === undefined) delete process.env.AGENT_YES_HOME;
56
- else process.env.AGENT_YES_HOME = savedHome;
57
- await rm(home, { recursive: true, force: true });
58
- });
59
-
60
- const noNode = (cmd: string) => (cmd === "bun" ? "/fake/bin/bun" : null);
61
-
62
- it.skipIf(process.platform === "win32")(
63
- "writes an executable node→bun shim and prepends it to PATH when node is absent",
64
- async () => {
65
- const shim = await ensureNodeRuntime(noNode);
66
- expect(shim).toBe(path.join(home, "bin", "node"));
67
- const body = await readFile(shim!, "utf-8");
68
- expect(body).toBe("#!/bin/sh\nexec '/fake/bin/bun' \"$@\"\n");
69
- expect((await stat(shim!)).mode & 0o111).not.toBe(0); // executable
70
- expect(process.env.PATH!.split(path.delimiter)[0]).toBe(path.join(home, "bin"));
71
- },
72
- );
73
-
74
- it.skipIf(process.platform === "win32")("does not duplicate the PATH entry", async () => {
75
- await ensureNodeRuntime(noNode);
76
- await ensureNodeRuntime(noNode);
77
- const binDir = path.join(home, "bin");
78
- const hits = process.env.PATH!.split(path.delimiter).filter((p) => p === binDir);
79
- expect(hits).toHaveLength(1);
80
- });
81
-
82
- it.skipIf(process.platform === "win32")(
83
- "leaves an up-to-date shim untouched on repeat calls (read-only paths stay read-only)",
84
- async () => {
85
- const shim = (await ensureNodeRuntime(noNode))!;
86
- const before = await stat(shim);
87
- await new Promise((r) => setTimeout(r, 20));
88
- await ensureNodeRuntime(noNode);
89
- const after = await stat(shim);
90
- expect(after.mtimeMs).toBe(before.mtimeMs); // not rewritten
91
- },
92
- );
93
-
94
- it.skipIf(process.platform === "win32")(
95
- "single-quotes a bun path containing sh-special characters",
96
- async () => {
97
- const shim = await ensureNodeRuntime((cmd) =>
98
- cmd === "bun" ? "/opt/we$ird `dir'/bun" : null,
99
- );
100
- const body = await readFile(shim!, "utf-8");
101
- expect(body).toBe("#!/bin/sh\nexec '/opt/we$ird `dir'\\''/bun' \"$@\"\n");
102
- },
103
- );
104
-
105
- it("is a no-op when a real node exists", async () => {
106
- expect(await ensureNodeRuntime(() => "/usr/bin/whatever")).toBeNull();
107
- });
108
-
109
- it("is a no-op when bun is missing too (nothing to shim to)", async () => {
110
- expect(await ensureNodeRuntime(() => null)).toBeNull();
111
- });
112
- });
113
-
114
36
  // Guards the serve-install diagnostic: a missing node runtime must be reported
115
37
  // as such, never as a "glibc mismatch" (glibc doesn't even exist on macOS).
116
38
  describe("isNoNodeExecError", () => {
@@ -128,6 +50,12 @@ describe("isNoNodeExecError", () => {
128
50
  ).toBe(true);
129
51
  });
130
52
 
53
+ it("matches localized-then-normalized output (probe pins LC_ALL=C)", () => {
54
+ // managerProbe spawns with LC_ALL=C precisely so this English form is what
55
+ // we always see; this test documents the coupling.
56
+ expect(isNoNodeExecError("env: node: No such file or directory")).toBe(true);
57
+ });
58
+
131
59
  it("does not match a real native/glibc failure", () => {
132
60
  expect(
133
61
  isNoNodeExecError(
@@ -137,3 +65,28 @@ describe("isNoNodeExecError", () => {
137
65
  expect(isNoNodeExecError("")).toBe(false);
138
66
  });
139
67
  });
68
+
69
+ // Bun.spawn without an explicit `env` hands the child the environ captured at
70
+ // process startup, NOT the live process.env — so ensureNodeRuntime's shim PATH
71
+ // prepend never reached children spawned that way (`pm2 start` died with
72
+ // `env: node: No such file or directory` right after the probe passed). Guard
73
+ // the fix pattern: every spawn of a daemon-manager binary in serve.ts must pass
74
+ // an explicit env.
75
+ describe("manager spawns pass a live env snapshot", () => {
76
+ it("every Bun.spawn of mgr.bin/startArgv/installer carries an env option", async () => {
77
+ const src = await readFile(new URL("./serve.ts", import.meta.url), "utf-8");
78
+ // Grab each Bun.spawn(...) call whose argv mentions a manager binary.
79
+ // Capture from the call opening through the options object's closing `})`.
80
+ const calls = src.match(
81
+ /Bun\.spawn\((?:\[[^\]]*mgr\.bin[^\]]*\]|startArgv|installer),[\s\S]*?\}\)/g,
82
+ );
83
+ expect(calls?.length).toBeGreaterThanOrEqual(7);
84
+ for (const call of calls!) {
85
+ // Require a real liveEnv() env option, not just the substring "env:"
86
+ // (which a comment inside the options object could satisfy).
87
+ expect(call, `missing explicit env in: ${call.slice(0, 80)}`).toMatch(
88
+ /env: (?:\{\s*(?:\.\.\.)?)?liveEnv\(\)/,
89
+ );
90
+ }
91
+ });
92
+ });
package/ts/serve.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { chmod, mkdir, open, readFile, stat, unlink, writeFile } from "fs/promises";
1
+ import { mkdir, open, readFile, stat, unlink, writeFile } from "fs/promises";
2
2
  import { renameSync, watch, writeFileSync } from "node:fs";
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
@@ -25,6 +25,7 @@ import {
25
25
  type CommonOpts,
26
26
  } from "./subcommands.ts";
27
27
  import { TYPING_BADGE } from "./badges.ts";
28
+ import { ensureNodeRuntime, liveEnv } from "./nodeRuntime.ts";
28
29
  import { updateGlobalPidStatus } from "./globalPidIndex.ts";
29
30
  import { spawnRejectionReason } from "./spawnGate.ts";
30
31
  import { findSpawnHiddenLauncher } from "./rustBinary.ts";
@@ -359,6 +360,7 @@ function oxmgrHasWindowsFix(bin: string): boolean {
359
360
  encoding: "utf-8",
360
361
  timeout: 3000,
361
362
  stdio: ["ignore", "pipe", "ignore"],
363
+ env: { ...process.env } as NodeJS.ProcessEnv,
362
364
  });
363
365
  oxmgrWinFixCache = oxmgrVersionHasWindowsFix(out);
364
366
  } catch {
@@ -405,45 +407,6 @@ async function globalBinDir(installer: string[]): Promise<string | null> {
405
407
  return isBun ? out : path.join(out, "bin");
406
408
  }
407
409
 
408
- // Both oxmgr's and pm2's global bins are `#!/usr/bin/env node` JS scripts, so on
409
- // a bun-only box (setup.sh installs no node) exec dies with "env: node: No such
410
- // file or directory" — even though pm2 is pure JS and runs perfectly under bun.
411
- // When node is missing but bun exists, write a node→bun shim into ay's own bin
412
- // dir and prepend it to PATH so `env node` resolves. NOT solvable with
413
- // `bun add -g node`: that package (node-bin-gen) lands a broken arch-specific
414
- // stub and `Bun.which("node")` still finds nothing. POSIX-only — on Windows the
415
- // npm .cmd shims invoke node.exe directly and an sh script can't stand in.
416
- // Called from managerRunnable, which read-only paths (`ay serve status`) also
417
- // reach: an up-to-date shim is left untouched there (a read, not a write), so
418
- // only the very first run on a node-less box materializes the file.
419
- // Returns the shim path when the shim is in effect, null when unneeded/impossible.
420
- export async function ensureNodeRuntime(
421
- which: (cmd: string) => string | null = Bun.which,
422
- ): Promise<string | null> {
423
- if (process.platform === "win32") return null;
424
- if (which("node")) return null;
425
- const bun = which("bun");
426
- if (!bun) return null;
427
- const binDir = path.join(agentYesHome(), "bin");
428
- const shim = path.join(binDir, "node");
429
- // Single-quote the bun path: inside sh double quotes `$`, backtick and `\`
430
- // would still expand, corrupting the shim for a path containing them.
431
- const body = `#!/bin/sh\nexec '${bun.replace(/'/g, `'\\''`)}' "$@"\n`;
432
- try {
433
- if ((await readFile(shim, "utf-8").catch(() => null)) !== body) {
434
- await mkdir(binDir, { recursive: true });
435
- await writeFile(shim, body);
436
- await chmod(shim, 0o755);
437
- }
438
- if (!(process.env.PATH ?? "").split(path.delimiter).includes(binDir)) {
439
- process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
440
- }
441
- return shim;
442
- } catch {
443
- return null; // best-effort: the exec probe will fail and name the reason
444
- }
445
- }
446
-
447
410
  // Did an exec attempt die because `env` couldn't find a node runtime? macOS/BSD
448
411
  // env says `env: node: No such file or directory`, GNU coreutils quotes it
449
412
  // (`env: 'node': …`), Windows cmd says `'node' is not recognized`.
@@ -463,7 +426,7 @@ async function managerProbe(mgr: DaemonManager): Promise<{ ok: boolean; stderr:
463
426
  const p = Bun.spawn([mgr.bin, "--version"], {
464
427
  stdout: "ignore",
465
428
  stderr: "pipe",
466
- env: { ...process.env, LC_ALL: "C" },
429
+ env: { ...liveEnv(), LC_ALL: "C" },
467
430
  });
468
431
  const [code, stderr] = await Promise.all([p.exited, new Response(p.stderr).text()]);
469
432
  return { ok: (code ?? 1) === 0, stderr };
@@ -517,7 +480,11 @@ async function installAndVerify(pkg: "oxmgr" | "pm2"): Promise<DaemonManager | n
517
480
  const installer = bun ? [bun, "add", "-g", pkg] : npm ? [npm, "install", "-g", pkg] : null;
518
481
  if (!installer) return null;
519
482
  process.stderr.write(`ay serve install: installing ${pkg}…\n`);
520
- const code = (await Bun.spawn(installer, { stdio: ["ignore", "inherit", "inherit"] }).exited) ?? 1;
483
+ const code =
484
+ (await Bun.spawn(installer, {
485
+ stdio: ["ignore", "inherit", "inherit"],
486
+ env: liveEnv(),
487
+ }).exited) ?? 1;
521
488
  if (code !== 0) {
522
489
  process.stderr.write(`ay serve install: '${installer.join(" ")}' failed (exit ${code})\n`);
523
490
  return null;
@@ -662,7 +629,9 @@ async function ensureBootAutostart(mgr: DaemonManager): Promise<boolean> {
662
629
 
663
630
  async function spawnExit(cmd: string[]): Promise<number> {
664
631
  try {
665
- return (await Bun.spawn(cmd, { stdio: ["ignore", "ignore", "ignore"] }).exited) ?? 1;
632
+ return (
633
+ (await Bun.spawn(cmd, { stdio: ["ignore", "ignore", "ignore"], env: liveEnv() }).exited) ?? 1
634
+ );
666
635
  } catch {
667
636
  return 1;
668
637
  }
@@ -675,7 +644,11 @@ async function spawnExit(cmd: string[]): Promise<number> {
675
644
  async function readDaemonServeArgs(mgr: DaemonManager): Promise<string[] | null> {
676
645
  try {
677
646
  if (mgr.id === "oxmgr") {
678
- const p = Bun.spawn([mgr.bin, "status", DAEMON_NAME], { stdout: "pipe", stderr: "ignore" });
647
+ const p = Bun.spawn([mgr.bin, "status", DAEMON_NAME], {
648
+ stdout: "pipe",
649
+ stderr: "ignore",
650
+ env: liveEnv(),
651
+ });
679
652
  const out = await new Response(p.stdout).text();
680
653
  if ((await p.exited) !== 0) return null;
681
654
  const m = /Command:\s*(.+)/.exec(out);
@@ -683,7 +656,7 @@ async function readDaemonServeArgs(mgr: DaemonManager): Promise<string[] | null>
683
656
  const after = /\bserve\b\s*(.*)$/.exec(m[1]!.trim());
684
657
  return after ? after[1]!.split(/\s+/).filter(Boolean) : [];
685
658
  }
686
- const p = Bun.spawn([mgr.bin, "jlist"], { stdout: "pipe", stderr: "ignore" });
659
+ const p = Bun.spawn([mgr.bin, "jlist"], { stdout: "pipe", stderr: "ignore", env: liveEnv() });
687
660
  const out = await new Response(p.stdout).text();
688
661
  if ((await p.exited) !== 0) return null;
689
662
  const list = JSON.parse(out) as Array<{ name?: string; pm2_env?: { args?: string[] } }>;
@@ -911,7 +884,7 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
911
884
  "--",
912
885
  ...managedArgv.slice(1),
913
886
  ];
914
- const proc = Bun.spawn(startArgv, { stdio: ["ignore", "inherit", "inherit"] });
887
+ const proc = Bun.spawn(startArgv, { stdio: ["ignore", "inherit", "inherit"], env: liveEnv() });
915
888
  const code = await proc.exited;
916
889
  if (code === 0) {
917
890
  const onBoot = await ensureBootAutostart(mgr);
@@ -962,6 +935,7 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
962
935
  if (sub === "uninstall") {
963
936
  const proc = Bun.spawn([mgr.bin, "delete", DAEMON_NAME], {
964
937
  stdio: ["ignore", "inherit", "inherit"],
938
+ env: liveEnv(),
965
939
  });
966
940
  const code = (await proc.exited) ?? 1;
967
941
  if (mgr.id === "pm2" && code === 0) {
@@ -977,6 +951,7 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
977
951
  if (sub === "logs") {
978
952
  const proc = Bun.spawn([mgr.bin, "logs", DAEMON_NAME, ...args], {
979
953
  stdio: ["ignore", "inherit", "inherit"],
954
+ env: liveEnv(),
980
955
  });
981
956
  return (await proc.exited) ?? 1;
982
957
  }
@@ -1,8 +0,0 @@
1
- import "./ts-7XqYx0eW.js";
2
- import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-CUMd5SWt.js";
4
- import "./pidStore-BIvsBQ8X.js";
5
- import "./globalPidIndex-CoNr7tS8.js";
6
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-5N015orJ.js";
7
-
8
- export { SUPPORTED_CLIS };