fix(plugins): repair missing openclaw peer links on update

This commit is contained in:
pickaxe
2026-05-04 14:36:01 -07:00
committed by Peter Steinberger
parent 0eb06caae3
commit 2e8761c5c1
3 changed files with 157 additions and 6 deletions

View File

@@ -24,7 +24,11 @@ export function expectedIntegrityForUpdate(
return integrity;
}
export async function readInstalledPackageVersion(dir: string): Promise<string | undefined> {
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readInstalledPackageManifest(dir: string): Record<string, unknown> | undefined {
const manifestPath = path.join(dir, "package.json");
const opened = openBoundaryFileSync({
absolutePath: manifestPath,
@@ -35,12 +39,32 @@ export async function readInstalledPackageVersion(dir: string): Promise<string |
return undefined;
}
try {
const raw = fsSync.readFileSync(opened.fd, "utf-8");
const parsed = JSON.parse(raw) as { version?: unknown };
return typeof parsed.version === "string" ? parsed.version : undefined;
const parsed = JSON.parse(fsSync.readFileSync(opened.fd, "utf-8")) as unknown;
return isRecord(parsed) ? parsed : undefined;
} catch {
return undefined;
} finally {
fsSync.closeSync(opened.fd);
}
}
export async function readInstalledPackageVersion(dir: string): Promise<string | undefined> {
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";
}
}

View File

@@ -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, string>;
}): 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",

View File

@@ -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,