CLI: support versioned plugin updates (#49998)

Merged via squash.

Prepared head SHA: 545ea60fa2
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
This commit is contained in:
Harold Hunt
2026-03-19 12:51:10 -04:00
committed by GitHub
parent 7fb142d115
commit 401ffb59f5
7 changed files with 345 additions and 12 deletions

View File

@@ -161,6 +161,129 @@ describe("updateNpmInstalledPlugins", () => {
]);
});
it("reuses a recorded npm dist-tag spec for id-based updates", async () => {
installPluginFromNpmSpecMock.mockResolvedValue({
ok: true,
pluginId: "openclaw-codex-app-server",
targetDir: "/tmp/openclaw-codex-app-server",
version: "0.2.0-beta.4",
extensions: ["index.ts"],
});
const result = await updateNpmInstalledPlugins({
config: {
plugins: {
installs: {
"openclaw-codex-app-server": {
source: "npm",
spec: "openclaw-codex-app-server@beta",
installPath: "/tmp/openclaw-codex-app-server",
resolvedName: "openclaw-codex-app-server",
resolvedSpec: "openclaw-codex-app-server@0.2.0-beta.3",
},
},
},
},
pluginIds: ["openclaw-codex-app-server"],
});
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
spec: "openclaw-codex-app-server@beta",
expectedPluginId: "openclaw-codex-app-server",
}),
);
expect(result.config.plugins?.installs?.["openclaw-codex-app-server"]).toMatchObject({
source: "npm",
spec: "openclaw-codex-app-server@beta",
installPath: "/tmp/openclaw-codex-app-server",
version: "0.2.0-beta.4",
});
});
it("uses and persists an explicit npm spec override during updates", async () => {
installPluginFromNpmSpecMock.mockResolvedValue({
ok: true,
pluginId: "openclaw-codex-app-server",
targetDir: "/tmp/openclaw-codex-app-server",
version: "0.2.0-beta.4",
extensions: ["index.ts"],
npmResolution: {
name: "openclaw-codex-app-server",
version: "0.2.0-beta.4",
resolvedSpec: "openclaw-codex-app-server@0.2.0-beta.4",
},
});
const result = await updateNpmInstalledPlugins({
config: {
plugins: {
installs: {
"openclaw-codex-app-server": {
source: "npm",
spec: "openclaw-codex-app-server",
installPath: "/tmp/openclaw-codex-app-server",
},
},
},
},
pluginIds: ["openclaw-codex-app-server"],
specOverrides: {
"openclaw-codex-app-server": "openclaw-codex-app-server@beta",
},
});
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
spec: "openclaw-codex-app-server@beta",
expectedPluginId: "openclaw-codex-app-server",
}),
);
expect(result.config.plugins?.installs?.["openclaw-codex-app-server"]).toMatchObject({
source: "npm",
spec: "openclaw-codex-app-server@beta",
installPath: "/tmp/openclaw-codex-app-server",
version: "0.2.0-beta.4",
resolvedSpec: "openclaw-codex-app-server@0.2.0-beta.4",
});
});
it("skips recorded integrity checks when an explicit npm version override changes the spec", async () => {
installPluginFromNpmSpecMock.mockResolvedValue({
ok: true,
pluginId: "openclaw-codex-app-server",
targetDir: "/tmp/openclaw-codex-app-server",
version: "0.2.0-beta.4",
extensions: ["index.ts"],
});
await updateNpmInstalledPlugins({
config: {
plugins: {
installs: {
"openclaw-codex-app-server": {
source: "npm",
spec: "openclaw-codex-app-server@0.2.0-beta.3",
integrity: "sha512-old",
installPath: "/tmp/openclaw-codex-app-server",
},
},
},
},
pluginIds: ["openclaw-codex-app-server"],
specOverrides: {
"openclaw-codex-app-server": "openclaw-codex-app-server@0.2.0-beta.4",
},
});
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
spec: "openclaw-codex-app-server@0.2.0-beta.4",
expectedIntegrity: undefined,
}),
);
});
it("migrates legacy unscoped install keys when a scoped npm package updates", async () => {
installPluginFromNpmSpecMock.mockResolvedValue({
ok: true,

View File

@@ -291,6 +291,7 @@ export async function updateNpmInstalledPlugins(params: {
pluginIds?: string[];
skipIds?: Set<string>;
dryRun?: boolean;
specOverrides?: Record<string, string>;
onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise<boolean>;
}): Promise<PluginUpdateSummary> {
const logger = params.logger ?? {};
@@ -329,7 +330,14 @@ export async function updateNpmInstalledPlugins(params: {
continue;
}
if (record.source === "npm" && !record.spec) {
const effectiveSpec =
record.source === "npm" ? (params.specOverrides?.[pluginId] ?? record.spec) : undefined;
const expectedIntegrity =
record.source === "npm" && effectiveSpec === record.spec
? expectedIntegrityForUpdate(record.spec, record.integrity)
: undefined;
if (record.source === "npm" && !effectiveSpec) {
outcomes.push({
pluginId,
status: "skipped",
@@ -371,11 +379,11 @@ export async function updateNpmInstalledPlugins(params: {
probe =
record.source === "npm"
? await installPluginFromNpmSpec({
spec: record.spec!,
spec: effectiveSpec!,
mode: "update",
dryRun: true,
expectedPluginId: pluginId,
expectedIntegrity: expectedIntegrityForUpdate(record.spec, record.integrity),
expectedIntegrity,
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
pluginId,
dryRun: true,
@@ -408,7 +416,7 @@ export async function updateNpmInstalledPlugins(params: {
record.source === "npm"
? formatNpmInstallFailure({
pluginId,
spec: record.spec!,
spec: effectiveSpec!,
phase: "check",
result: probe,
})
@@ -452,10 +460,10 @@ export async function updateNpmInstalledPlugins(params: {
result =
record.source === "npm"
? await installPluginFromNpmSpec({
spec: record.spec!,
spec: effectiveSpec!,
mode: "update",
expectedPluginId: pluginId,
expectedIntegrity: expectedIntegrityForUpdate(record.spec, record.integrity),
expectedIntegrity,
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
pluginId,
dryRun: false,
@@ -487,7 +495,7 @@ export async function updateNpmInstalledPlugins(params: {
record.source === "npm"
? formatNpmInstallFailure({
pluginId,
spec: record.spec!,
spec: effectiveSpec!,
phase: "update",
result: result,
})
@@ -512,7 +520,7 @@ export async function updateNpmInstalledPlugins(params: {
next = recordPluginInstall(next, {
pluginId: resolvedPluginId,
source: "npm",
spec: record.spec,
spec: effectiveSpec,
installPath: result.targetDir,
version: nextVersion,
...buildNpmResolutionInstallFields(result.npmResolution),