diff --git a/src/infra/npm-managed-root.test.ts b/src/infra/npm-managed-root.test.ts new file mode 100644 index 00000000000..43d76d88166 --- /dev/null +++ b/src/infra/npm-managed-root.test.ts @@ -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 { + 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"); + }); +}); diff --git a/src/infra/npm-managed-root.ts b/src/infra/npm-managed-root.ts new file mode 100644 index 00000000000..08ba1a3ea90 --- /dev/null +++ b/src/infra/npm-managed-root.ts @@ -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; + [key: string]: unknown; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readDependencyRecord(value: unknown): Record { + if (!isRecord(value)) { + return {}; + } + const dependencies: Record = {}; + for (const [key, raw] of Object.entries(value)) { + if (typeof raw === "string") { + dependencies[key] = raw; + } + } + return dependencies; +} + +async function readManagedNpmRootManifest(filePath: string): Promise { + 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 { + 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"); +} diff --git a/src/plugins/install.ts b/src/plugins/install.ts index a0e41be21b6..f32441ca7d0 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -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",