mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:40:44 +00:00
fix(update): stage npm-prefix package updates cleanly
Co-authored-by: Josh Lehman <josh@martian.engineering>
This commit is contained in:
committed by
Peter Steinberger
parent
be8b4dc845
commit
b63336186a
@@ -29,6 +29,15 @@ function createNpmTarget(globalRoot: string): ResolvedGlobalInstallTarget {
|
||||
};
|
||||
}
|
||||
|
||||
function createPnpmTarget(globalRoot: string): ResolvedGlobalInstallTarget {
|
||||
return {
|
||||
manager: "pnpm",
|
||||
command: "pnpm",
|
||||
globalRoot,
|
||||
packageRoot: path.join(globalRoot, "openclaw"),
|
||||
};
|
||||
}
|
||||
|
||||
function createRootRunner(globalRoot: string): CommandRunner {
|
||||
return async (argv) => {
|
||||
if (argv.join(" ") === "npm root -g") {
|
||||
@@ -115,6 +124,99 @@ describe("runGlobalPackageUpdateSteps", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("stages pnpm-detected updates through npm when the global root has npm prefix layout", async () => {
|
||||
await withTempDir({ prefix: "openclaw-package-update-pnpm-staged-" }, async (base) => {
|
||||
const prefix = path.join(base, "prefix");
|
||||
const globalRoot = path.join(prefix, "lib", "node_modules");
|
||||
const packageRoot = path.join(globalRoot, "openclaw");
|
||||
const staleChunk = path.join(packageRoot, "dist", "install-C_GuuNz6.js");
|
||||
await writePackageRoot(packageRoot, "1.0.0");
|
||||
await fs.writeFile(staleChunk, 'import "./install.runtime-Xom5hOHq.js";\n', "utf8");
|
||||
|
||||
const runStep = vi.fn(async ({ name, argv, cwd }): Promise<PackageUpdateStepResult> => {
|
||||
if (name !== "global update") {
|
||||
throw new Error(`unexpected step ${name}`);
|
||||
}
|
||||
expect(argv[0]).toBe("npm");
|
||||
expect(argv).toEqual(expect.arrayContaining(["i", "-g", "--prefix", "openclaw@2.0.0"]));
|
||||
expect(argv).not.toContain("pnpm");
|
||||
const prefixIndex = argv.indexOf("--prefix");
|
||||
const stagePrefix = argv[prefixIndex + 1];
|
||||
if (!stagePrefix) {
|
||||
throw new Error("missing staged prefix");
|
||||
}
|
||||
await writePackageRoot(path.join(stagePrefix, "lib", "node_modules", "openclaw"), "2.0.0");
|
||||
return {
|
||||
name,
|
||||
command: argv.join(" "),
|
||||
cwd: cwd ?? process.cwd(),
|
||||
durationMs: 1,
|
||||
exitCode: 0,
|
||||
};
|
||||
});
|
||||
|
||||
const result = await runGlobalPackageUpdateSteps({
|
||||
installTarget: createPnpmTarget(globalRoot),
|
||||
installSpec: "openclaw@2.0.0",
|
||||
packageName: "openclaw",
|
||||
packageRoot,
|
||||
runCommand: createRootRunner(globalRoot),
|
||||
runStep,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(result.failedStep).toBeNull();
|
||||
expect(result.afterVersion).toBe("2.0.0");
|
||||
expect(result.steps.map((step) => step.name)).toEqual([
|
||||
"global update",
|
||||
"global install swap",
|
||||
]);
|
||||
await expect(fs.access(staleChunk)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Windows pnpm global roots on the pnpm update path", async () => {
|
||||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
try {
|
||||
await withTempDir({ prefix: "openclaw-package-update-win32-pnpm-" }, async (base) => {
|
||||
const globalRoot = path.join(base, "pnpm", "global", "5", "node_modules");
|
||||
const packageRoot = path.join(globalRoot, "openclaw");
|
||||
await writePackageRoot(packageRoot, "1.0.0");
|
||||
|
||||
const runStep = vi.fn(async ({ name, argv, cwd }): Promise<PackageUpdateStepResult> => {
|
||||
if (name !== "global update") {
|
||||
throw new Error(`unexpected step ${name}`);
|
||||
}
|
||||
expect(argv).toEqual(["pnpm", "add", "-g", "openclaw@2.0.0"]);
|
||||
await writePackageRoot(packageRoot, "2.0.0");
|
||||
return {
|
||||
name,
|
||||
command: argv.join(" "),
|
||||
cwd: cwd ?? process.cwd(),
|
||||
durationMs: 1,
|
||||
exitCode: 0,
|
||||
};
|
||||
});
|
||||
|
||||
const result = await runGlobalPackageUpdateSteps({
|
||||
installTarget: createPnpmTarget(globalRoot),
|
||||
installSpec: "openclaw@2.0.0",
|
||||
packageName: "openclaw",
|
||||
packageRoot,
|
||||
runCommand: createRootRunner(globalRoot),
|
||||
runStep,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(result.failedStep).toBeNull();
|
||||
expect(result.afterVersion).toBe("2.0.0");
|
||||
expect(result.steps.map((step) => step.name)).toEqual(["global update"]);
|
||||
});
|
||||
} finally {
|
||||
platformSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a successful staged swap when old package cleanup hits a transient Windows native module error", async () => {
|
||||
await withTempDir({ prefix: "openclaw-package-update-staged-cleanup-" }, async (base) => {
|
||||
const prefix = path.join(base, "prefix");
|
||||
|
||||
@@ -36,6 +36,7 @@ type StagedNpmInstall = {
|
||||
prefix: string;
|
||||
layout: NpmGlobalPrefixLayout;
|
||||
packageRoot: string;
|
||||
installTarget: ResolvedGlobalInstallTarget;
|
||||
};
|
||||
|
||||
type NpmBinShimBackup = {
|
||||
@@ -82,24 +83,60 @@ async function readPackageVersionIfPresent(packageRoot: string | null): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
function isUnambiguousNpmPrefixGlobalRoot(globalRoot: string | null): boolean {
|
||||
const trimmed = globalRoot?.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = path.resolve(trimmed);
|
||||
if (path.basename(normalized) !== "node_modules") {
|
||||
return false;
|
||||
}
|
||||
const parentDir = path.dirname(normalized);
|
||||
if (path.basename(parentDir) === "lib") {
|
||||
return true;
|
||||
}
|
||||
return process.platform === "win32" && path.basename(parentDir).toLowerCase() === "npm";
|
||||
}
|
||||
|
||||
function resolveStagedNpmTargetLayout(
|
||||
installTarget: ResolvedGlobalInstallTarget,
|
||||
): NpmGlobalPrefixLayout | null {
|
||||
const targetLayout = resolveNpmGlobalPrefixLayoutFromGlobalRoot(installTarget.globalRoot);
|
||||
if (!targetLayout) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
installTarget.manager === "npm" ||
|
||||
isUnambiguousNpmPrefixGlobalRoot(installTarget.globalRoot)
|
||||
) {
|
||||
return targetLayout;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function createStagedNpmInstall(
|
||||
installTarget: ResolvedGlobalInstallTarget,
|
||||
packageName: string,
|
||||
): Promise<StagedNpmInstall | null> {
|
||||
if (installTarget.manager !== "npm") {
|
||||
return null;
|
||||
}
|
||||
const targetLayout = resolveNpmGlobalPrefixLayoutFromGlobalRoot(installTarget.globalRoot);
|
||||
const targetLayout = resolveStagedNpmTargetLayout(installTarget);
|
||||
if (!targetLayout) {
|
||||
return null;
|
||||
}
|
||||
await fs.mkdir(targetLayout.globalRoot, { recursive: true });
|
||||
const prefix = await fs.mkdtemp(path.join(targetLayout.globalRoot, ".openclaw-update-stage-"));
|
||||
const layout = resolveNpmGlobalPrefixLayoutFromPrefix(prefix);
|
||||
const command = installTarget.manager === "npm" ? installTarget.command : "npm";
|
||||
return {
|
||||
prefix,
|
||||
layout,
|
||||
packageRoot: path.join(layout.globalRoot, packageName),
|
||||
installTarget: {
|
||||
manager: "npm",
|
||||
command,
|
||||
globalRoot: layout.globalRoot,
|
||||
packageRoot: path.join(layout.globalRoot, packageName),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -329,10 +366,11 @@ export async function runGlobalPackageUpdateSteps(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const installCommandTarget = stagedInstall?.installTarget ?? params.installTarget;
|
||||
const updateStep = await params.runStep({
|
||||
name: "global update",
|
||||
argv: globalInstallArgs(
|
||||
params.installTarget,
|
||||
installCommandTarget,
|
||||
params.installSpec,
|
||||
undefined,
|
||||
stagedInstall?.prefix,
|
||||
@@ -363,7 +401,7 @@ export async function runGlobalPackageUpdateSteps(params: {
|
||||
}
|
||||
|
||||
const fallbackArgv = globalInstallFallbackArgs(
|
||||
params.installTarget,
|
||||
stagedInstall?.installTarget ?? params.installTarget,
|
||||
params.installSpec,
|
||||
undefined,
|
||||
stagedInstall?.prefix,
|
||||
|
||||
@@ -1365,6 +1365,7 @@ describe("runGatewayUpdate", () => {
|
||||
const createGlobalInstallHarness = (params: {
|
||||
pkgRoot: string;
|
||||
npmRootOutput?: string;
|
||||
pnpmRootOutput?: string;
|
||||
installCommand: string;
|
||||
gitRootMode?: "not-git" | "missing";
|
||||
onInstall?: (options?: {
|
||||
@@ -1390,6 +1391,9 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "", stderr: "", code: 1 };
|
||||
}
|
||||
if (key === "pnpm root -g") {
|
||||
if (params.pnpmRootOutput) {
|
||||
return { stdout: params.pnpmRootOutput, stderr: "", code: 0 };
|
||||
}
|
||||
return { stdout: "", stderr: "", code: 1 };
|
||||
}
|
||||
if (key === params.installCommand) {
|
||||
@@ -1747,6 +1751,38 @@ describe("runGatewayUpdate", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses clean staged npm swaps for pnpm installs that resolve to an npm global root", async () => {
|
||||
const prefix = path.join(tempDir, "npm-prefix");
|
||||
const nodeModules = path.join(prefix, "lib", "node_modules");
|
||||
const pkgRoot = path.join(nodeModules, "openclaw");
|
||||
const staleInstallChunk = path.join(pkgRoot, "dist", "install-C_GuuNz6.js");
|
||||
await seedGlobalPackageRoot(pkgRoot);
|
||||
await fs.writeFile(
|
||||
staleInstallChunk,
|
||||
'const pluginRuntime = () => import("./install.runtime-Xom5hOHq.js");\n',
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { calls, runCommand } = createGlobalInstallHarness({
|
||||
pkgRoot,
|
||||
pnpmRootOutput: nodeModules,
|
||||
installCommand: "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error",
|
||||
onInstall: async (options) => {
|
||||
await writeGlobalPackageVersion(options?.packageRoot ?? pkgRoot);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runWithCommand(runCommand, { cwd: pkgRoot });
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.mode).toBe("pnpm");
|
||||
expect(result.after?.version).toBe("2.0.0");
|
||||
expect(calls.some((call) => call.startsWith("npm i -g --prefix "))).toBe(true);
|
||||
expect(calls.some((call) => call.startsWith("pnpm add -g"))).toBe(false);
|
||||
expect(result.steps.map((step) => step.name)).toEqual(["global update", "global install swap"]);
|
||||
await expect(fs.access(staleInstallChunk)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("uses OPENCLAW_UPDATE_PACKAGE_SPEC for global package updates", async () => {
|
||||
const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir);
|
||||
const expectedInstallCommand =
|
||||
|
||||
Reference in New Issue
Block a user