Skip to content

Commit 2e8761c

Browse files
ProspectOresteipete
authored andcommitted
fix(plugins): repair missing openclaw peer links on update
1 parent 0eb06ca commit 2e8761c

3 files changed

Lines changed: 157 additions & 6 deletions

File tree

src/infra/package-update-utils.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ export function expectedIntegrityForUpdate(
2424
return integrity;
2525
}
2626

27-
export async function readInstalledPackageVersion(dir: string): Promise<string | undefined> {
27+
function isRecord(value: unknown): value is Record<string, unknown> {
28+
return typeof value === "object" && value !== null && !Array.isArray(value);
29+
}
30+
31+
function readInstalledPackageManifest(dir: string): Record<string, unknown> | undefined {
2832
const manifestPath = path.join(dir, "package.json");
2933
const opened = openBoundaryFileSync({
3034
absolutePath: manifestPath,
@@ -35,12 +39,32 @@ export async function readInstalledPackageVersion(dir: string): Promise<string |
3539
return undefined;
3640
}
3741
try {
38-
const raw = fsSync.readFileSync(opened.fd, "utf-8");
39-
const parsed = JSON.parse(raw) as { version?: unknown };
40-
return typeof parsed.version === "string" ? parsed.version : undefined;
42+
const parsed = JSON.parse(fsSync.readFileSync(opened.fd, "utf-8")) as unknown;
43+
return isRecord(parsed) ? parsed : undefined;
4144
} catch {
4245
return undefined;
4346
} finally {
4447
fsSync.closeSync(opened.fd);
4548
}
4649
}
50+
51+
export async function readInstalledPackageVersion(dir: string): Promise<string | undefined> {
52+
const manifest = readInstalledPackageManifest(dir);
53+
return typeof manifest?.version === "string" ? manifest.version : undefined;
54+
}
55+
56+
export function installedPackageNeedsOpenClawPeerLinkRepair(dir: string): boolean {
57+
const manifest = readInstalledPackageManifest(dir);
58+
const peerDependencies = isRecord(manifest?.peerDependencies) ? manifest.peerDependencies : {};
59+
if (!Object.hasOwn(peerDependencies, "openclaw")) {
60+
return false;
61+
}
62+
63+
try {
64+
fsSync.statSync(path.join(dir, "node_modules", "openclaw"));
65+
return false;
66+
} catch (error) {
67+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
68+
return code === "ENOENT" || code === "ENOTDIR";
69+
}
70+
}

src/plugins/update.test.ts

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,12 +250,24 @@ function createCodexAppServerInstallConfig(params: {
250250
};
251251
}
252252

253-
function createInstalledPackageDir(params: { name?: string; version: string }): string {
253+
function createInstalledPackageDir(params: {
254+
name?: string;
255+
version: string;
256+
peerDependencies?: Record<string, string>;
257+
}): string {
254258
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-test-"));
255259
tempDirs.push(dir);
256260
fs.writeFileSync(
257261
path.join(dir, "package.json"),
258-
JSON.stringify({ name: params.name ?? "test-plugin", version: params.version }, null, 2),
262+
JSON.stringify(
263+
{
264+
name: params.name ?? "test-plugin",
265+
version: params.version,
266+
...(params.peerDependencies ? { peerDependencies: params.peerDependencies } : {}),
267+
},
268+
null,
269+
2,
270+
),
259271
);
260272
return dir;
261273
}
@@ -708,6 +720,119 @@ describe("updateNpmInstalledPlugins", () => {
708720
]);
709721
});
710722

723+
it("repairs missing openclaw peer links before skipping unchanged npm plugins", async () => {
724+
const installPath = createInstalledPackageDir({
725+
name: "@openclaw/codex",
726+
version: "2026.5.3",
727+
peerDependencies: { openclaw: ">=2026.5.3" },
728+
});
729+
mockNpmViewMetadata({
730+
name: "@openclaw/codex",
731+
version: "2026.5.3",
732+
integrity: "sha512-same",
733+
shasum: "same",
734+
});
735+
installPluginFromNpmSpecMock.mockResolvedValue(
736+
createSuccessfulNpmUpdateResult({
737+
pluginId: "codex",
738+
targetDir: installPath,
739+
version: "2026.5.3",
740+
npmResolution: {
741+
name: "@openclaw/codex",
742+
version: "2026.5.3",
743+
resolvedSpec: "@openclaw/codex@2026.5.3",
744+
},
745+
}),
746+
);
747+
const config: OpenClawConfig = {
748+
plugins: {
749+
installs: {
750+
codex: {
751+
source: "npm",
752+
spec: "@openclaw/codex",
753+
installPath,
754+
resolvedName: "@openclaw/codex",
755+
resolvedVersion: "2026.5.3",
756+
resolvedSpec: "@openclaw/codex@2026.5.3",
757+
integrity: "sha512-same",
758+
shasum: "same",
759+
},
760+
},
761+
},
762+
};
763+
764+
const result = await updateNpmInstalledPlugins({
765+
config,
766+
pluginIds: ["codex"],
767+
});
768+
769+
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
770+
expect.objectContaining({
771+
spec: "@openclaw/codex",
772+
mode: "update",
773+
expectedPluginId: "codex",
774+
}),
775+
);
776+
expect(result.changed).toBe(true);
777+
expect(result.outcomes).toEqual([
778+
{
779+
pluginId: "codex",
780+
status: "unchanged",
781+
currentVersion: "2026.5.3",
782+
nextVersion: "2026.5.3",
783+
message: "codex already at 2026.5.3.",
784+
},
785+
]);
786+
});
787+
788+
it("skips unchanged npm plugins when the openclaw peer link already resolves", async () => {
789+
const installPath = createInstalledPackageDir({
790+
name: "@openclaw/codex",
791+
version: "2026.5.3",
792+
peerDependencies: { openclaw: ">=2026.5.3" },
793+
});
794+
fs.mkdirSync(path.join(installPath, "node_modules", "openclaw"), { recursive: true });
795+
mockNpmViewMetadata({
796+
name: "@openclaw/codex",
797+
version: "2026.5.3",
798+
integrity: "sha512-same",
799+
shasum: "same",
800+
});
801+
installPluginFromNpmSpecMock.mockRejectedValue(new Error("installer should not run"));
802+
803+
const result = await updateNpmInstalledPlugins({
804+
config: {
805+
plugins: {
806+
installs: {
807+
codex: {
808+
source: "npm",
809+
spec: "@openclaw/codex",
810+
installPath,
811+
resolvedName: "@openclaw/codex",
812+
resolvedVersion: "2026.5.3",
813+
resolvedSpec: "@openclaw/codex@2026.5.3",
814+
integrity: "sha512-same",
815+
shasum: "same",
816+
},
817+
},
818+
},
819+
},
820+
pluginIds: ["codex"],
821+
});
822+
823+
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
824+
expect(result.changed).toBe(false);
825+
expect(result.outcomes).toEqual([
826+
{
827+
pluginId: "codex",
828+
status: "unchanged",
829+
currentVersion: "2026.5.3",
830+
nextVersion: "2026.5.3",
831+
message: "codex is up to date (2026.5.3).",
832+
},
833+
]);
834+
});
835+
711836
it("refreshes legacy npm install records before skipping unchanged artifacts", async () => {
712837
const installPath = createInstalledPackageDir({
713838
name: "@martian-engineering/lossless-claw",

src/plugins/update.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from "../infra/npm-registry-spec.js";
1212
import {
1313
expectedIntegrityForUpdate,
14+
installedPackageNeedsOpenClawPeerLinkRepair,
1415
readInstalledPackageVersion,
1516
} from "../infra/package-update-utils.js";
1617
import { compareComparableSemver, parseComparableSemver } from "../infra/semver-compare.js";
@@ -989,6 +990,7 @@ export async function updateNpmInstalledPlugins(params: {
989990
spec: effectiveSpec!,
990991
trustedSourceLinkedOfficialInstall,
991992
}) &&
993+
!installedPackageNeedsOpenClawPeerLinkRepair(installPath) &&
992994
shouldSkipUnchangedNpmInstall({
993995
currentVersion,
994996
record,

0 commit comments

Comments
 (0)