Skip to content

Commit d7dbf11

Browse files
committed
fix(plugins): preserve npm plugin installs across repairs
1 parent e8df05e commit d7dbf11

3 files changed

Lines changed: 176 additions & 1 deletion

File tree

src/infra/npm-managed-root.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
import {
6+
resolveManagedNpmRootDependencySpec,
7+
upsertManagedNpmRootDependency,
8+
} from "./npm-managed-root.js";
9+
10+
const tempDirs: string[] = [];
11+
12+
async function makeTempRoot(): Promise<string> {
13+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-npm-managed-root-"));
14+
tempDirs.push(dir);
15+
return dir;
16+
}
17+
18+
afterEach(async () => {
19+
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
20+
});
21+
22+
describe("managed npm root", () => {
23+
it("keeps existing plugin dependencies when adding another managed plugin", async () => {
24+
const npmRoot = await makeTempRoot();
25+
await fs.writeFile(
26+
path.join(npmRoot, "package.json"),
27+
`${JSON.stringify(
28+
{
29+
private: true,
30+
dependencies: {
31+
"@openclaw/discord": "2026.5.2",
32+
},
33+
devDependencies: {
34+
fixture: "1.0.0",
35+
},
36+
},
37+
null,
38+
2,
39+
)}\n`,
40+
);
41+
42+
await upsertManagedNpmRootDependency({
43+
npmRoot,
44+
packageName: "@openclaw/feishu",
45+
dependencySpec: "2026.5.2",
46+
});
47+
48+
await expect(
49+
fs.readFile(path.join(npmRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)),
50+
).resolves.toEqual({
51+
private: true,
52+
dependencies: {
53+
"@openclaw/discord": "2026.5.2",
54+
"@openclaw/feishu": "2026.5.2",
55+
},
56+
devDependencies: {
57+
fixture: "1.0.0",
58+
},
59+
});
60+
});
61+
62+
it("uses the requested selector before falling back to resolved version", () => {
63+
expect(
64+
resolveManagedNpmRootDependencySpec({
65+
parsedSpec: {
66+
name: "@openclaw/discord",
67+
raw: "@openclaw/discord@stable",
68+
selector: "stable",
69+
selectorKind: "tag",
70+
selectorIsPrerelease: false,
71+
},
72+
resolution: {
73+
name: "@openclaw/discord",
74+
version: "2026.5.2",
75+
resolvedSpec: "@openclaw/[email protected]",
76+
resolvedAt: "2026-05-03T00:00:00.000Z",
77+
},
78+
}),
79+
).toBe("stable");
80+
81+
expect(
82+
resolveManagedNpmRootDependencySpec({
83+
parsedSpec: {
84+
name: "@openclaw/discord",
85+
raw: "@openclaw/discord",
86+
selectorKind: "none",
87+
selectorIsPrerelease: false,
88+
},
89+
resolution: {
90+
name: "@openclaw/discord",
91+
version: "2026.5.2",
92+
resolvedSpec: "@openclaw/[email protected]",
93+
resolvedAt: "2026-05-03T00:00:00.000Z",
94+
},
95+
}),
96+
).toBe("2026.5.2");
97+
});
98+
});

src/infra/npm-managed-root.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import fs from "node:fs/promises";
2+
import path from "node:path";
3+
import type { NpmSpecResolution } from "./install-source-utils.js";
4+
import type { ParsedRegistryNpmSpec } from "./npm-registry-spec.js";
5+
6+
type ManagedNpmRootManifest = {
7+
private?: boolean;
8+
dependencies?: Record<string, string>;
9+
[key: string]: unknown;
10+
};
11+
12+
function isRecord(value: unknown): value is Record<string, unknown> {
13+
return typeof value === "object" && value !== null && !Array.isArray(value);
14+
}
15+
16+
function readDependencyRecord(value: unknown): Record<string, string> {
17+
if (!isRecord(value)) {
18+
return {};
19+
}
20+
const dependencies: Record<string, string> = {};
21+
for (const [key, raw] of Object.entries(value)) {
22+
if (typeof raw === "string") {
23+
dependencies[key] = raw;
24+
}
25+
}
26+
return dependencies;
27+
}
28+
29+
async function readManagedNpmRootManifest(filePath: string): Promise<ManagedNpmRootManifest> {
30+
try {
31+
const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
32+
return isRecord(parsed) ? { ...parsed } : {};
33+
} catch (err) {
34+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
35+
return {};
36+
}
37+
throw err;
38+
}
39+
}
40+
41+
export function resolveManagedNpmRootDependencySpec(params: {
42+
parsedSpec: ParsedRegistryNpmSpec;
43+
resolution: NpmSpecResolution;
44+
}): string {
45+
return params.parsedSpec.selector ?? params.resolution.version ?? "latest";
46+
}
47+
48+
export async function upsertManagedNpmRootDependency(params: {
49+
npmRoot: string;
50+
packageName: string;
51+
dependencySpec: string;
52+
}): Promise<void> {
53+
await fs.mkdir(params.npmRoot, { recursive: true });
54+
const manifestPath = path.join(params.npmRoot, "package.json");
55+
const manifest = await readManagedNpmRootManifest(manifestPath);
56+
const dependencies = readDependencyRecord(manifest.dependencies);
57+
const next: ManagedNpmRootManifest = {
58+
...manifest,
59+
private: true,
60+
dependencies: {
61+
...dependencies,
62+
[params.packageName]: params.dependencySpec,
63+
},
64+
};
65+
await fs.writeFile(manifestPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
66+
}

src/plugins/install.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import {
77
type NpmSpecResolution,
88
} from "../infra/install-source-utils.js";
99
import { resolveNpmIntegrityDriftWithDefaultMessage } from "../infra/npm-integrity.js";
10+
import {
11+
resolveManagedNpmRootDependencySpec,
12+
upsertManagedNpmRootDependency,
13+
} from "../infra/npm-managed-root.js";
1014
import {
1115
formatPrereleaseResolutionError,
1216
isPrereleaseResolutionAllowed,
@@ -1177,7 +1181,14 @@ export async function installPluginFromNpmSpec(
11771181
}
11781182

11791183
logger.info?.(`Installing ${spec} into ${npmRoot}…`);
1180-
await fs.mkdir(npmRoot, { recursive: true });
1184+
await upsertManagedNpmRootDependency({
1185+
npmRoot,
1186+
packageName: parsedSpec.name,
1187+
dependencySpec: resolveManagedNpmRootDependencySpec({
1188+
parsedSpec,
1189+
resolution: npmResolution,
1190+
}),
1191+
});
11811192
const install = await runCommandWithTimeout(
11821193
[
11831194
"npm",

0 commit comments

Comments
 (0)