@openparachute/hub 0.5.14-rc.10 → 0.5.14-rc.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.14-rc.10",
3
+ "version": "0.5.14-rc.11",
4
4
  "description": "parachute \u2014 the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import type { UpgradeRunner } from "../commands/upgrade.ts";
6
- import { compareVersions, detectChannel, upgrade } from "../commands/upgrade.ts";
6
+ import { compareVersions, defaultRunner, detectChannel, upgrade } from "../commands/upgrade.ts";
7
7
  import { upsertService } from "../services-manifest.ts";
8
8
 
9
9
  interface RunCall {
@@ -402,6 +402,83 @@ describe("parachute upgrade", () => {
402
402
  }
403
403
  });
404
404
 
405
+ test("git absent (ENOENT): no crash, isGitCheckout → false, npm path taken", async () => {
406
+ // Real EC2 repro: a published-npm install on a minimal server with no
407
+ // `git` binary. The production runner's Bun.spawn(["git", ...]) throws
408
+ // synchronously with ENOENT; the upgrade flow must degrade to the npm
409
+ // path rather than crashing with an uncaught "Executable not found".
410
+ const h = makeHarness();
411
+ try {
412
+ const installDir = join(h.installRoot, "vault");
413
+ writePackageJson(installDir, { name: "@openparachute/vault", version: "0.4.0" });
414
+ seedVault(h.manifestPath, installDir, "0.4.0");
415
+
416
+ const calls: RunCall[] = [];
417
+ // Simulate the git-absent host: any spawn of `git` ENOENTs, surfaced
418
+ // through the runner as a non-zero captured result (code 127). This is
419
+ // exactly what the patched defaultRunner produces — we assert the
420
+ // upgrade flow handles that result gracefully.
421
+ const runner: UpgradeRunner = {
422
+ async run(cmd, opts) {
423
+ calls.push({ cmd: [...cmd], cwd: opts?.cwd, kind: "run" });
424
+ if (cmd[0] === "git") return 127; // git-less host
425
+ if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
426
+ writePackageJson(installDir, { name: "@openparachute/vault", version: "0.5.0" });
427
+ }
428
+ return 0;
429
+ },
430
+ async capture(cmd, opts) {
431
+ calls.push({ cmd: [...cmd], cwd: opts?.cwd, kind: "capture" });
432
+ if (cmd[0] === "git") return { code: 127, stdout: "git: not found on this host\n" };
433
+ return { code: 0, stdout: "" };
434
+ },
435
+ };
436
+
437
+ let restartedShort: string | undefined;
438
+ const logs: string[] = [];
439
+ const code = await upgrade("vault", {
440
+ manifestPath: h.manifestPath,
441
+ configDir: h.configDir,
442
+ runner,
443
+ findGlobalInstall: () => join(installDir, "package.json"),
444
+ restartFn: async (svc) => {
445
+ restartedShort = svc;
446
+ return 0;
447
+ },
448
+ log: (l) => logs.push(l),
449
+ });
450
+
451
+ // No throw; the npm path ran end-to-end.
452
+ expect(code).toBe(0);
453
+ expect(restartedShort).toBe("vault");
454
+ const joined = logs.join("\n");
455
+ // isGitCheckout returned false → npm-installed branch, not bun-linked.
456
+ expect(joined).toMatch(/npm-installed/);
457
+ expect(joined).not.toMatch(/bun-linked/);
458
+ expect(joined).toMatch(/bun add -g @openparachute\/vault@latest/);
459
+ expect(joined).toMatch(/0\.4\.0 → 0\.5\.0/);
460
+ // We probed git (and degraded) but never reached the git-mutating
461
+ // commands (pull / status) that only run on the bun-linked branch.
462
+ const gitRun = calls.filter((c) => c.kind === "run" && c.cmd[0] === "git");
463
+ expect(gitRun).toHaveLength(0);
464
+ } finally {
465
+ h.cleanup();
466
+ }
467
+ });
468
+
469
+ test("defaultRunner.capture: git-absent ENOENT yields code 127, no throw", async () => {
470
+ // Drive the *production* runner against a binary that doesn't exist, to
471
+ // prove the synchronous-spawn-throw is caught (not just the injectable
472
+ // seam). Bun.spawn throws ENOENT synchronously for a missing binary.
473
+ const missing = `parachute-no-such-binary-${process.pid}`;
474
+ const captured = await defaultRunner.capture([missing, "--version"]);
475
+ expect(captured.code).toBe(127);
476
+ expect(captured.stdout).toContain("not found on this host");
477
+
478
+ const ran = await defaultRunner.run([missing, "--version"]);
479
+ expect(ran).toBe(127);
480
+ });
481
+
405
482
  test("npm-installed: version unchanged → skip restart", async () => {
406
483
  const h = makeHarness();
407
484
  try {
@@ -72,25 +72,69 @@ export interface UpgradeRunner {
72
72
  ): Promise<{ code: number; stdout: string }>;
73
73
  }
74
74
 
75
+ /**
76
+ * Exit code we synthesize when a binary can't be spawned at all. 127 is the
77
+ * POSIX shell convention for "command not found" — it lets every git call
78
+ * degrade to a normal non-zero result instead of crashing the whole command.
79
+ */
80
+ const SPAWN_NOT_FOUND_CODE = 127;
81
+
82
+ /**
83
+ * True when an error thrown by `Bun.spawn` means "the executable doesn't
84
+ * exist on this host" (ENOENT). On a minimal server with no `git` installed —
85
+ * a legitimate, common shape for a published-npm install on the canonical
86
+ * install path — `Bun.spawn(["git", ...])` throws *synchronously* with this
87
+ * shape. We catch it so `parachute upgrade` degrades to the npm path rather
88
+ * than dying with an uncaught `Executable not found in $PATH: "git"`.
89
+ */
90
+ function isSpawnNotFound(err: unknown): boolean {
91
+ if (typeof err !== "object" || err === null) return false;
92
+ const code = (err as { code?: unknown }).code;
93
+ const message = (err as { message?: unknown }).message;
94
+ return (
95
+ code === "ENOENT" ||
96
+ (typeof message === "string" && message.includes("Executable not found in $PATH"))
97
+ );
98
+ }
99
+
75
100
  export const defaultRunner: UpgradeRunner = {
76
101
  async run(cmd, opts) {
77
102
  // Inherit env so `bun add -g` etc. see TMPDIR, BUN_INSTALL, PATH, HOME.
78
103
  // Bun.spawn defaults to empty env — see api-modules-ops.ts:defaultRun.
79
- const proc = Bun.spawn([...cmd], {
80
- cwd: opts?.cwd,
81
- stdio: ["inherit", "inherit", "inherit"],
82
- env: process.env,
83
- });
104
+ let proc: Bun.Subprocess;
105
+ try {
106
+ proc = Bun.spawn([...cmd], {
107
+ cwd: opts?.cwd,
108
+ stdio: ["inherit", "inherit", "inherit"],
109
+ env: process.env,
110
+ });
111
+ } catch (err) {
112
+ // Binary not on this host (e.g. no `git` on a minimal server). Degrade
113
+ // to a non-zero exit rather than letting the throw crash the command.
114
+ if (isSpawnNotFound(err)) return SPAWN_NOT_FOUND_CODE;
115
+ throw err;
116
+ }
84
117
  return await proc.exited;
85
118
  },
86
119
  async capture(cmd, opts) {
87
120
  // Inherit env — same rationale as `run` above.
88
- const proc = Bun.spawn([...cmd], {
89
- cwd: opts?.cwd,
90
- stdout: "pipe",
91
- stderr: "pipe",
92
- env: process.env,
93
- });
121
+ let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
122
+ try {
123
+ proc = Bun.spawn([...cmd], {
124
+ cwd: opts?.cwd,
125
+ stdout: "pipe",
126
+ stderr: "pipe",
127
+ env: process.env,
128
+ });
129
+ } catch (err) {
130
+ // See `run` above: ENOENT (binary-not-found) becomes a captured
131
+ // non-zero result so every git call degrades to "command failed".
132
+ if (isSpawnNotFound(err)) {
133
+ const bin = cmd[0] ?? "command";
134
+ return { code: SPAWN_NOT_FOUND_CODE, stdout: `${bin}: not found on this host\n` };
135
+ }
136
+ throw err;
137
+ }
94
138
  const [stdout, stderr] = await Promise.all([
95
139
  new Response(proc.stdout).text(),
96
140
  new Response(proc.stderr).text(),