diff --git a/src/update/transactional-install.mjs b/src/update/transactional-install.mjs index 4c96917fc7..1ecad741d3 100644 --- a/src/update/transactional-install.mjs +++ b/src/update/transactional-install.mjs @@ -154,7 +154,16 @@ export function transactionalNpmUpdate({ const rename = deps.rename ?? renameSync; const scopeDir = dirname(packageDir); const stageRoot = join(scopeDir, stampedName(".ocx-staging")); - const stagedPackage = join(stageRoot, "node_modules", ...pkgName.split("/")); + // GLOBAL-style staging (-g --prefix): npm nests the package's dependencies INSIDE the + // package dir, exactly like the live global tree this stage will replace. A local-style + // install would hoist bun/zod to stageRoot/node_modules — siblings that the swap would + // leave behind, shipping a dependency-less live tree (release-audit blocker). + // Layout: /lib/node_modules/ on POSIX, /node_modules/ + // on Windows. + const stagedCandidates = [ + join(stageRoot, "lib", "node_modules", ...pkgName.split("/")), + join(stageRoot, "node_modules", ...pkgName.split("/")), + ]; // D1: stage to the side. --prefix keeps npm entirely inside stageRoot; the live tree // and the npm bin shims are untouched until the swap. A failure HERE (mkdir EACCES, @@ -167,11 +176,16 @@ export function transactionalNpmUpdate({ } const spec = pkgName + "@" + (targetVersion || tag); log("Staging " + spec + " into " + stageRoot); - const install = runNpm(["install", "--prefix", stageRoot, "--no-audit", "--no-fund", spec]); + const install = runNpm(["install", "-g", "--prefix", stageRoot, "--no-audit", "--no-fund", spec]); if (install.status !== 0) { try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ } return { ok: false, phase: "stage", error: "npm staging install failed (" + (install.status ?? "?") + ")" }; } + const stagedPackage = stagedCandidates.find(dir => existsSync(join(dir, "package.json"))); + if (!stagedPackage) { + try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ } + return { ok: false, phase: "verify", error: "staged package directory not found under " + stageRoot }; + } // D2: verify INSIDE the stage. Live is still untouched on any failure here. const staged = verifyInstallTree(stagedPackage, targetVersion || undefined); diff --git a/tests/update-transactional.test.ts b/tests/update-transactional.test.ts index 92a06cee3f..5878216195 100644 --- a/tests/update-transactional.test.ts +++ b/tests/update-transactional.test.ts @@ -36,13 +36,14 @@ function liveVersion(packageDir: string): string | undefined { } } -/** npm stub that materializes a staged tree under the --prefix root. */ +/** npm stub that materializes a GLOBAL-style staged tree (deps nested inside the package). */ function stagingNpm(version: string, opts: { fail?: boolean; truncate?: boolean } = {}) { return (args: string[]) => { if (opts.fail) return { status: 1 }; const prefixIndex = args.indexOf("--prefix"); const stageRoot = args[prefixIndex + 1]!; - const staged = join(stageRoot, "node_modules", ...PKG.split("/")); + // Mirror npm -g layout on POSIX: /lib/node_modules/. + const staged = join(stageRoot, "lib", "node_modules", ...PKG.split("/")); writeTree(staged, version); if (opts.truncate) rmSync(join(staged, "bin", "ocx.mjs")); return { status: 0 };