mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
fix(plugins): preserve npm plugin installs across repairs
This commit is contained in:
98
src/infra/npm-managed-root.test.ts
Normal file
98
src/infra/npm-managed-root.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
66
src/infra/npm-managed-root.ts
Normal file
66
src/infra/npm-managed-root.ts
Normal 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");
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user