@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 +1 -1
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/commands/upgrade.ts +55 -11
package/package.json
CHANGED
|
@@ -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 {
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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(),
|