fix(plugins): preserve npm plugin installs across repairs

This commit is contained in:
Vincent Koc
2026-05-02 18:31:59 -07:00
parent e8df05ed4f
commit d7dbf11504
3 changed files with 176 additions and 1 deletions

View File

@@ -0,0 +1,98 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
resolveManagedNpmRootDependencySpec,
upsertManagedNpmRootDependency,
} from "./npm-managed-root.js";
const tempDirs: string[] = [];
async function makeTempRoot(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-npm-managed-root-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("managed npm root", () => {
it("keeps existing plugin dependencies when adding another managed plugin", async () => {
const npmRoot = await makeTempRoot();
await fs.writeFile(
path.join(npmRoot, "package.json"),
`${JSON.stringify(
{
private: true,
dependencies: {
"@openclaw/discord": "2026.5.2",
},
devDependencies: {
fixture: "1.0.0",
},
},
null,
2,
)}\n`,
);
await upsertManagedNpmRootDependency({
npmRoot,
packageName: "@openclaw/feishu",
dependencySpec: "2026.5.2",
});
await expect(
fs.readFile(path.join(npmRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)),
).resolves.toEqual({
private: true,
dependencies: {
"@openclaw/discord": "2026.5.2",
"@openclaw/feishu": "2026.5.2",
},
devDependencies: {
fixture: "1.0.0",
},
});
});
it("uses the requested selector before falling back to resolved version", () => {
expect(
resolveManagedNpmRootDependencySpec({
parsedSpec: {
name: "@openclaw/discord",
raw: "@openclaw/discord@stable",
selector: "stable",
selectorKind: "tag",
selectorIsPrerelease: false,
},
resolution: {
name: "@openclaw/discord",
version: "2026.5.2",
resolvedSpec: "@openclaw/discord@2026.5.2",
resolvedAt: "2026-05-03T00:00:00.000Z",
},
}),
).toBe("stable");
expect(
resolveManagedNpmRootDependencySpec({
parsedSpec: {
name: "@openclaw/discord",
raw: "@openclaw/discord",
selectorKind: "none",
selectorIsPrerelease: false,
},
resolution: {
name: "@openclaw/discord",
version: "2026.5.2",
resolvedSpec: "@openclaw/discord@2026.5.2",
resolvedAt: "2026-05-03T00:00:00.000Z",
},
}),
).toBe("2026.5.2");
});
});

View File

@@ -0,0 +1,66 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { NpmSpecResolution } from "./install-source-utils.js";
import type { ParsedRegistryNpmSpec } from "./npm-registry-spec.js";
type ManagedNpmRootManifest = {
private?: boolean;
dependencies?: Record<string, string>;
[key: string]: unknown;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readDependencyRecord(value: unknown): Record<string, string> {
if (!isRecord(value)) {
return {};
}
const dependencies: Record<string, string> = {};
for (const [key, raw] of Object.entries(value)) {
if (typeof raw === "string") {
dependencies[key] = raw;
}
}
return dependencies;
}
async function readManagedNpmRootManifest(filePath: string): Promise<ManagedNpmRootManifest> {
try {
const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
return isRecord(parsed) ? { ...parsed } : {};
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return {};
}
throw err;
}
}
export function resolveManagedNpmRootDependencySpec(params: {
parsedSpec: ParsedRegistryNpmSpec;
resolution: NpmSpecResolution;
}): string {
return params.parsedSpec.selector ?? params.resolution.version ?? "latest";
}
export async function upsertManagedNpmRootDependency(params: {
npmRoot: string;
packageName: string;
dependencySpec: string;
}): Promise<void> {
await fs.mkdir(params.npmRoot, { recursive: true });
const manifestPath = path.join(params.npmRoot, "package.json");
const manifest = await readManagedNpmRootManifest(manifestPath);
const dependencies = readDependencyRecord(manifest.dependencies);
const next: ManagedNpmRootManifest = {
...manifest,
private: true,
dependencies: {
...dependencies,
[params.packageName]: params.dependencySpec,
},
};
await fs.writeFile(manifestPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
}

View File

@@ -7,6 +7,10 @@ import {
type NpmSpecResolution,
} from "../infra/install-source-utils.js";
import { resolveNpmIntegrityDriftWithDefaultMessage } from "../infra/npm-integrity.js";
import {
resolveManagedNpmRootDependencySpec,
upsertManagedNpmRootDependency,
} from "../infra/npm-managed-root.js";
import {
formatPrereleaseResolutionError,
isPrereleaseResolutionAllowed,
@@ -1177,7 +1181,14 @@ export async function installPluginFromNpmSpec(
}
logger.info?.(`Installing ${spec} into ${npmRoot}`);
await fs.mkdir(npmRoot, { recursive: true });
await upsertManagedNpmRootDependency({
npmRoot,
packageName: parsedSpec.name,
dependencySpec: resolveManagedNpmRootDependencySpec({
parsedSpec,
resolution: npmResolution,
}),
});
const install = await runCommandWithTimeout(
[
"npm",