Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions src/update/transactional-install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: <stageRoot>/lib/node_modules/<pkg> on POSIX, <stageRoot>/node_modules/<pkg>
// 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,
Expand All @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions tests/update-transactional.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <prefix>/lib/node_modules/<pkg>.
const staged = join(stageRoot, "lib", "node_modules", ...PKG.split("/"));
writeTree(staged, version);
if (opts.truncate) rmSync(join(staged, "bin", "ocx.mjs"));
return { status: 0 };
Expand Down
Loading