diff --git a/src/infra/package-update-utils.ts b/src/infra/package-update-utils.ts index 68805c7554c..9582d9f6688 100644 --- a/src/infra/package-update-utils.ts +++ b/src/infra/package-update-utils.ts @@ -24,7 +24,11 @@ export function expectedIntegrityForUpdate( return integrity; } -export async function readInstalledPackageVersion(dir: string): Promise { +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readInstalledPackageManifest(dir: string): Record | undefined { const manifestPath = path.join(dir, "package.json"); const opened = openBoundaryFileSync({ absolutePath: manifestPath, @@ -35,12 +39,32 @@ export async function readInstalledPackageVersion(dir: string): Promise { + const manifest = readInstalledPackageManifest(dir); + return typeof manifest?.version === "string" ? manifest.version : undefined; +} + +export function installedPackageNeedsOpenClawPeerLinkRepair(dir: string): boolean { + const manifest = readInstalledPackageManifest(dir); + const peerDependencies = isRecord(manifest?.peerDependencies) ? manifest.peerDependencies : {}; + if (!Object.hasOwn(peerDependencies, "openclaw")) { + return false; + } + + try { + fsSync.statSync(path.join(dir, "node_modules", "openclaw")); + return false; + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === "ENOENT" || code === "ENOTDIR"; + } +} diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 655d387e32b..2f1dc104abb 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -250,12 +250,24 @@ function createCodexAppServerInstallConfig(params: { }; } -function createInstalledPackageDir(params: { name?: string; version: string }): string { +function createInstalledPackageDir(params: { + name?: string; + version: string; + peerDependencies?: Record; +}): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-test-")); tempDirs.push(dir); fs.writeFileSync( path.join(dir, "package.json"), - JSON.stringify({ name: params.name ?? "test-plugin", version: params.version }, null, 2), + JSON.stringify( + { + name: params.name ?? "test-plugin", + version: params.version, + ...(params.peerDependencies ? { peerDependencies: params.peerDependencies } : {}), + }, + null, + 2, + ), ); return dir; } @@ -708,6 +720,119 @@ describe("updateNpmInstalledPlugins", () => { ]); }); + it("repairs missing openclaw peer links before skipping unchanged npm plugins", async () => { + const installPath = createInstalledPackageDir({ + name: "@openclaw/codex", + version: "2026.5.3", + peerDependencies: { openclaw: ">=2026.5.3" }, + }); + mockNpmViewMetadata({ + name: "@openclaw/codex", + version: "2026.5.3", + integrity: "sha512-same", + shasum: "same", + }); + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "codex", + targetDir: installPath, + version: "2026.5.3", + npmResolution: { + name: "@openclaw/codex", + version: "2026.5.3", + resolvedSpec: "@openclaw/codex@2026.5.3", + }, + }), + ); + const config: OpenClawConfig = { + plugins: { + installs: { + codex: { + source: "npm", + spec: "@openclaw/codex", + installPath, + resolvedName: "@openclaw/codex", + resolvedVersion: "2026.5.3", + resolvedSpec: "@openclaw/codex@2026.5.3", + integrity: "sha512-same", + shasum: "same", + }, + }, + }, + }; + + const result = await updateNpmInstalledPlugins({ + config, + pluginIds: ["codex"], + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/codex", + mode: "update", + expectedPluginId: "codex", + }), + ); + expect(result.changed).toBe(true); + expect(result.outcomes).toEqual([ + { + pluginId: "codex", + status: "unchanged", + currentVersion: "2026.5.3", + nextVersion: "2026.5.3", + message: "codex already at 2026.5.3.", + }, + ]); + }); + + it("skips unchanged npm plugins when the openclaw peer link already resolves", async () => { + const installPath = createInstalledPackageDir({ + name: "@openclaw/codex", + version: "2026.5.3", + peerDependencies: { openclaw: ">=2026.5.3" }, + }); + fs.mkdirSync(path.join(installPath, "node_modules", "openclaw"), { recursive: true }); + mockNpmViewMetadata({ + name: "@openclaw/codex", + version: "2026.5.3", + integrity: "sha512-same", + shasum: "same", + }); + installPluginFromNpmSpecMock.mockRejectedValue(new Error("installer should not run")); + + const result = await updateNpmInstalledPlugins({ + config: { + plugins: { + installs: { + codex: { + source: "npm", + spec: "@openclaw/codex", + installPath, + resolvedName: "@openclaw/codex", + resolvedVersion: "2026.5.3", + resolvedSpec: "@openclaw/codex@2026.5.3", + integrity: "sha512-same", + shasum: "same", + }, + }, + }, + }, + pluginIds: ["codex"], + }); + + expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); + expect(result.changed).toBe(false); + expect(result.outcomes).toEqual([ + { + pluginId: "codex", + status: "unchanged", + currentVersion: "2026.5.3", + nextVersion: "2026.5.3", + message: "codex is up to date (2026.5.3).", + }, + ]); + }); + it("refreshes legacy npm install records before skipping unchanged artifacts", async () => { const installPath = createInstalledPackageDir({ name: "@martian-engineering/lossless-claw", diff --git a/src/plugins/update.ts b/src/plugins/update.ts index c3b97338c4f..2a1175954a5 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -11,6 +11,7 @@ import { } from "../infra/npm-registry-spec.js"; import { expectedIntegrityForUpdate, + installedPackageNeedsOpenClawPeerLinkRepair, readInstalledPackageVersion, } from "../infra/package-update-utils.js"; import { compareComparableSemver, parseComparableSemver } from "../infra/semver-compare.js"; @@ -989,6 +990,7 @@ export async function updateNpmInstalledPlugins(params: { spec: effectiveSpec!, trustedSourceLinkedOfficialInstall, }) && + !installedPackageNeedsOpenClawPeerLinkRepair(installPath) && shouldSkipUnchangedNpmInstall({ currentVersion, record,