Skip to content

Commit fb884b3

Browse files
committed
fix(npm): resolve latest bootstrap for upgrades
1 parent 9d94310 commit fb884b3

3 files changed

Lines changed: 105 additions & 1 deletion

File tree

npm/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ jacobian setup [--client <id>...] [--all] [--yes] [--dry-run] [--json]
5353
[--source <checkout> --state-dir <path> --profile <name>]
5454
Configure MCP clients to use Jacobian.
5555
jacobian upgrade
56-
Refresh the launcher-managed Python package.
56+
Resolve the latest npm bootstrap, then refresh the launcher-managed Python package.
5757
jacobian doctor [--json]
5858
Verify the MCP handshake and tool catalog.
5959
jacobian remove [--client <id>...] [--all] [--yes] [--json]
@@ -83,6 +83,8 @@ state initialization, source doctor, and client configuration workflow.
8383
- `JACOBIAN_STATE_DIR` — state directory (default: `./.jacobian`)
8484
- `JACOBIAN_PACKAGE` — Python package spec override (default: the Python package
8585
version matching the installed npm launcher)
86+
- `JACOBIAN_NPM_UPGRADE_HANDOFF` — internal one-time guard used while `upgrade`
87+
resolves `jacobian@latest`
8688
- `JACOBIAN_DATA_DIR` — guided installer release data root
8789
- `JACOBIAN_BIN_DIR` — guided installer directory for the stable command
8890

npm/bin/jacobian.cjs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
"use strict";
44

55
const { stderr } = require("node:process");
6+
const { spawn } = require("node:child_process");
7+
8+
const NPM_UPGRADE_HANDOFF = "JACOBIAN_NPM_UPGRADE_HANDOFF";
69

710
/**
811
* Jacobian CLI entry point.
@@ -58,6 +61,62 @@ function reportSetupFailure(error) {
5861
process.exitCode = 1;
5962
}
6063

64+
/**
65+
* Resolve the latest npm bootstrap before upgrading the managed Python package.
66+
*
67+
* An unqualified npx invocation can execute an older cached launcher. The
68+
* handoff guard lets the latest package enter the pinned-runtime path once.
69+
*
70+
* @param {string[]} args
71+
*/
72+
function resolveLatestUpgrade(args) {
73+
const executable =
74+
process.env.JACOBIAN_NPX_EXECUTABLE ||
75+
(process.platform === "win32" ? "npx.cmd" : "npx");
76+
const child = spawn(
77+
executable,
78+
["--yes", "--prefer-online", "jacobian@latest", "upgrade", ...args.slice(1)],
79+
{
80+
env: { ...process.env, [NPM_UPGRADE_HANDOFF]: "1" },
81+
shell: process.platform === "win32",
82+
stdio: "inherit",
83+
},
84+
);
85+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
86+
const handlers = new Map(
87+
signals.map((signal) => [
88+
signal,
89+
() => {
90+
if (!child.killed) child.kill(signal);
91+
},
92+
]),
93+
);
94+
for (const [signal, handler] of handlers) process.on(signal, handler);
95+
96+
const removeSignalHandlers = () => {
97+
for (const [signal, handler] of handlers) {
98+
process.removeListener(signal, handler);
99+
}
100+
};
101+
102+
child.once("error", (error) => {
103+
removeSignalHandlers();
104+
stderr.write(
105+
`Jacobian could not resolve its latest npm bootstrap: ${error.message}\n` +
106+
"Run `npx jacobian@latest upgrade` after checking that npx is available.\n",
107+
);
108+
process.exitCode = 1;
109+
});
110+
child.once("exit", (code, signal) => {
111+
removeSignalHandlers();
112+
if (signal) {
113+
process.kill(process.pid, signal);
114+
return;
115+
}
116+
process.exitCode = code ?? 1;
117+
});
118+
}
119+
61120
function main() {
62121
const args = process.argv.slice(2);
63122
const command = args[0];
@@ -152,6 +211,13 @@ function main() {
152211
}
153212

154213
if (command === "upgrade") {
214+
if (process.env[NPM_UPGRADE_HANDOFF] !== "1") {
215+
stderr.write(
216+
"Resolving the latest Jacobian bootstrap before upgrading the managed Python runtime.\n",
217+
);
218+
resolveLatestUpgrade(args);
219+
return;
220+
}
155221
const { PACKAGE_SPEC, upgrade } = require("./launcher.cjs");
156222
try {
157223
upgrade();

npm/cli-e2e.test.mjs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ if (process.argv[2] === "--version") {
237237

238238
const result = await runNpx(tarball, ["upgrade"], base, {
239239
FAKE_UV_LOG: uvLog,
240+
JACOBIAN_NPM_UPGRADE_HANDOFF: "1",
240241
JACOBIAN_PACKAGE: `jacobian==${packageMetadata.version}`,
241242
PATH: `${dirname(uv)}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`,
242243
XDG_DATA_HOME: xdgDataHome,
@@ -264,3 +265,38 @@ if (process.argv[2] === "--version") {
264265
}
265266
},
266267
);
268+
269+
test(
270+
"npx jacobian upgrade resolves the latest npm bootstrap before Python upgrade",
271+
{ skip: process.platform === "win32" },
272+
async () => {
273+
const base = await mkdtemp(join(tmpdir(), "jacobian-npx-upgrade-handoff-"));
274+
try {
275+
const tarball = await packNpmPackage(base);
276+
const npx = join(base, "bin", "npx");
277+
const handoffLog = join(base, "handoff.json");
278+
await writeExecutable(
279+
npx,
280+
`#!/usr/bin/env node
281+
const fs = require("node:fs");
282+
fs.writeFileSync(process.env.JACOBIAN_HANDOFF_LOG, JSON.stringify({
283+
args: process.argv.slice(2),
284+
marker: process.env.JACOBIAN_NPM_UPGRADE_HANDOFF,
285+
}));
286+
`,
287+
);
288+
289+
const result = await runNpx(tarball, ["upgrade", "--yes"], base, {
290+
JACOBIAN_HANDOFF_LOG: handoffLog,
291+
JACOBIAN_NPX_EXECUTABLE: npx,
292+
});
293+
assert.equal(result.status, 0, result.stderr);
294+
assert.deepEqual(JSON.parse(await readFile(handoffLog, "utf8")), {
295+
args: ["--yes", "--prefer-online", "jacobian@latest", "upgrade", "--yes"],
296+
marker: "1",
297+
});
298+
} finally {
299+
await rm(base, { recursive: true, force: true });
300+
}
301+
},
302+
);

0 commit comments

Comments
 (0)