diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index 5187938e7df..a704e474280 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -26,6 +26,11 @@ import { renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { resolveUserPath, shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; +import { + buildNpmInstallRecordFields, + logPinnedNpmSpecMessages, + resolvePinnedNpmSpec, +} from "./npm-resolution.js"; import { promptYesNo } from "./prompt.js"; export type HooksListOptions = { @@ -179,6 +184,25 @@ function logGatewayRestartHint() { defaultRuntime.log("Restart the gateway to load hooks."); } +function logIntegrityDriftWarning( + hookId: string, + drift: { + resolution: { resolvedSpec?: string }; + spec: string; + expectedIntegrity: string; + actualIntegrity: string; + }, +) { + const specLabel = drift.resolution.resolvedSpec ?? drift.spec; + defaultRuntime.log( + theme.warn( + `Integrity drift detected for "${hookId}" (${specLabel})` + + `\nExpected: ${drift.expectedIntegrity}` + + `\nActual: ${drift.actualIntegrity}`, + ), + ); +} + async function readInstalledPackageVersion(dir: string): Promise { try { const raw = await fsp.readFile(path.join(dir, "package.json"), "utf-8"); @@ -660,29 +684,25 @@ export function registerHooksCli(program: Command): void { } let next = enableInternalHookEntries(cfg, result.hooks); - const resolvedSpec = result.npmResolution?.resolvedSpec; - const recordSpec = opts.pin && resolvedSpec ? resolvedSpec : raw; - if (opts.pin && !resolvedSpec) { - defaultRuntime.log( - theme.warn("Could not resolve exact npm version for --pin; storing original npm spec."), - ); - } - if (opts.pin && resolvedSpec) { - defaultRuntime.log(`Pinned npm install record to ${resolvedSpec}.`); - } + const pinInfo = resolvePinnedNpmSpec({ + rawSpec: raw, + pin: Boolean(opts.pin), + resolvedSpec: result.npmResolution?.resolvedSpec, + }); + logPinnedNpmSpecMessages( + pinInfo, + (message) => defaultRuntime.log(message), + (message) => defaultRuntime.log(theme.warn(message)), + ); next = recordHookInstall(next, { hookId: result.hookPackId, - source: "npm", - spec: recordSpec, - installPath: result.targetDir, - version: result.version, - resolvedName: result.npmResolution?.name, - resolvedVersion: result.npmResolution?.version, - resolvedSpec: result.npmResolution?.resolvedSpec, - integrity: result.npmResolution?.integrity, - shasum: result.npmResolution?.shasum, - resolvedAt: result.npmResolution?.resolvedAt, + ...buildNpmInstallRecordFields({ + spec: pinInfo.recordSpec, + installPath: result.targetDir, + version: result.version, + resolution: result.npmResolution, + }), hooks: result.hooks, }); await writeConfigFile(next); @@ -741,14 +761,7 @@ export function registerHooksCli(program: Command): void { expectedHookPackId: hookId, expectedIntegrity: record.integrity, onIntegrityDrift: async (drift) => { - const specLabel = drift.resolution.resolvedSpec ?? drift.spec; - defaultRuntime.log( - theme.warn( - `Integrity drift detected for "${hookId}" (${specLabel})` + - `\nExpected: ${drift.expectedIntegrity}` + - `\nActual: ${drift.actualIntegrity}`, - ), - ); + logIntegrityDriftWarning(hookId, drift); return true; }, logger: createInstallLogger(), @@ -774,14 +787,7 @@ export function registerHooksCli(program: Command): void { expectedHookPackId: hookId, expectedIntegrity: record.integrity, onIntegrityDrift: async (drift) => { - const specLabel = drift.resolution.resolvedSpec ?? drift.spec; - defaultRuntime.log( - theme.warn( - `Integrity drift detected for "${hookId}" (${specLabel})` + - `\nExpected: ${drift.expectedIntegrity}` + - `\nActual: ${drift.actualIntegrity}`, - ), - ); + logIntegrityDriftWarning(hookId, drift); return await promptYesNo(`Continue updating "${hookId}" with this artifact?`); }, logger: createInstallLogger(), @@ -794,16 +800,12 @@ export function registerHooksCli(program: Command): void { const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir)); nextCfg = recordHookInstall(nextCfg, { hookId, - source: "npm", - spec: record.spec, - installPath: result.targetDir, - version: nextVersion, - resolvedName: result.npmResolution?.name, - resolvedVersion: result.npmResolution?.version, - resolvedSpec: result.npmResolution?.resolvedSpec, - integrity: result.npmResolution?.integrity, - shasum: result.npmResolution?.shasum, - resolvedAt: result.npmResolution?.resolvedAt, + ...buildNpmInstallRecordFields({ + spec: record.spec, + installPath: result.targetDir, + version: nextVersion, + resolution: result.npmResolution, + }), hooks: result.hooks, }); updatedCount += 1; diff --git a/src/cli/npm-resolution.test.ts b/src/cli/npm-resolution.test.ts new file mode 100644 index 00000000000..0895d2dac25 --- /dev/null +++ b/src/cli/npm-resolution.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { + buildNpmInstallRecordFields, + logPinnedNpmSpecMessages, + mapNpmResolutionMetadata, + resolvePinnedNpmSpec, +} from "./npm-resolution.js"; + +describe("npm-resolution helpers", () => { + it("keeps original spec when pin is disabled", () => { + const result = resolvePinnedNpmSpec({ + rawSpec: "@openclaw/plugin-alpha@latest", + pin: false, + resolvedSpec: "@openclaw/plugin-alpha@1.2.3", + }); + expect(result).toEqual({ + recordSpec: "@openclaw/plugin-alpha@latest", + }); + }); + + it("warns when pin is enabled but resolved spec is missing", () => { + const result = resolvePinnedNpmSpec({ + rawSpec: "@openclaw/plugin-alpha@latest", + pin: true, + }); + expect(result).toEqual({ + recordSpec: "@openclaw/plugin-alpha@latest", + pinWarning: "Could not resolve exact npm version for --pin; storing original npm spec.", + }); + }); + + it("returns pinned spec notice when resolved spec is available", () => { + const result = resolvePinnedNpmSpec({ + rawSpec: "@openclaw/plugin-alpha@latest", + pin: true, + resolvedSpec: "@openclaw/plugin-alpha@1.2.3", + }); + expect(result).toEqual({ + recordSpec: "@openclaw/plugin-alpha@1.2.3", + pinNotice: "Pinned npm install record to @openclaw/plugin-alpha@1.2.3.", + }); + }); + + it("maps npm resolution metadata to install fields", () => { + expect( + mapNpmResolutionMetadata({ + name: "@openclaw/plugin-alpha", + version: "1.2.3", + resolvedSpec: "@openclaw/plugin-alpha@1.2.3", + integrity: "sha512-abc", + shasum: "deadbeef", + resolvedAt: "2026-02-21T00:00:00.000Z", + }), + ).toEqual({ + resolvedName: "@openclaw/plugin-alpha", + resolvedVersion: "1.2.3", + resolvedSpec: "@openclaw/plugin-alpha@1.2.3", + integrity: "sha512-abc", + shasum: "deadbeef", + resolvedAt: "2026-02-21T00:00:00.000Z", + }); + }); + + it("builds common npm install record fields", () => { + expect( + buildNpmInstallRecordFields({ + spec: "@openclaw/plugin-alpha@1.2.3", + installPath: "/tmp/openclaw/extensions/alpha", + version: "1.2.3", + resolution: { + name: "@openclaw/plugin-alpha", + version: "1.2.3", + resolvedSpec: "@openclaw/plugin-alpha@1.2.3", + integrity: "sha512-abc", + }, + }), + ).toEqual({ + source: "npm", + spec: "@openclaw/plugin-alpha@1.2.3", + installPath: "/tmp/openclaw/extensions/alpha", + version: "1.2.3", + resolvedName: "@openclaw/plugin-alpha", + resolvedVersion: "1.2.3", + resolvedSpec: "@openclaw/plugin-alpha@1.2.3", + integrity: "sha512-abc", + shasum: undefined, + resolvedAt: undefined, + }); + }); + + it("logs pin warning/notice messages through provided writers", () => { + const logs: string[] = []; + const warns: string[] = []; + logPinnedNpmSpecMessages( + { + pinWarning: "warn-1", + pinNotice: "notice-1", + }, + (message) => logs.push(message), + (message) => warns.push(message), + ); + + expect(logs).toEqual(["notice-1"]); + expect(warns).toEqual(["warn-1"]); + }); +}); diff --git a/src/cli/npm-resolution.ts b/src/cli/npm-resolution.ts new file mode 100644 index 00000000000..044beb96875 --- /dev/null +++ b/src/cli/npm-resolution.ts @@ -0,0 +1,86 @@ +export type NpmResolutionMetadata = { + name?: string; + version?: string; + resolvedSpec?: string; + integrity?: string; + shasum?: string; + resolvedAt?: string; +}; + +export function resolvePinnedNpmSpec(params: { + rawSpec: string; + pin: boolean; + resolvedSpec?: string; +}): { recordSpec: string; pinWarning?: string; pinNotice?: string } { + const recordSpec = params.pin && params.resolvedSpec ? params.resolvedSpec : params.rawSpec; + if (!params.pin) { + return { recordSpec }; + } + if (!params.resolvedSpec) { + return { + recordSpec, + pinWarning: "Could not resolve exact npm version for --pin; storing original npm spec.", + }; + } + return { + recordSpec, + pinNotice: `Pinned npm install record to ${params.resolvedSpec}.`, + }; +} + +export function mapNpmResolutionMetadata(resolution?: NpmResolutionMetadata): { + resolvedName?: string; + resolvedVersion?: string; + resolvedSpec?: string; + integrity?: string; + shasum?: string; + resolvedAt?: string; +} { + return { + resolvedName: resolution?.name, + resolvedVersion: resolution?.version, + resolvedSpec: resolution?.resolvedSpec, + integrity: resolution?.integrity, + shasum: resolution?.shasum, + resolvedAt: resolution?.resolvedAt, + }; +} + +export function buildNpmInstallRecordFields(params: { + spec: string; + installPath: string; + version?: string; + resolution?: NpmResolutionMetadata; +}): { + source: "npm"; + spec: string; + installPath: string; + version?: string; + resolvedName?: string; + resolvedVersion?: string; + resolvedSpec?: string; + integrity?: string; + shasum?: string; + resolvedAt?: string; +} { + return { + source: "npm", + spec: params.spec, + installPath: params.installPath, + version: params.version, + ...mapNpmResolutionMetadata(params.resolution), + }; +} + +export function logPinnedNpmSpecMessages( + pinInfo: { pinWarning?: string; pinNotice?: string }, + log: (message: string) => void, + logWarn: (message: string) => void, +): void { + if (pinInfo.pinWarning) { + logWarn(pinInfo.pinWarning); + } + if (pinInfo.pinNotice) { + log(pinInfo.pinNotice); + } +} diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 9ae4c060299..4a20a9d8c8b 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -21,6 +21,12 @@ import { formatDocsLink } from "../terminal/links.js"; import { renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { resolveUserPath, shortenHomeInString, shortenHomePath } from "../utils.js"; +import { + buildNpmInstallRecordFields, + logPinnedNpmSpecMessages, + resolvePinnedNpmSpec, +} from "./npm-resolution.js"; +import { setPluginEnabledInConfig } from "./plugins-config.js"; import { promptYesNo } from "./prompt.js"; export type PluginsListOptions = { @@ -360,19 +366,7 @@ export function registerPluginsCli(program: Command) { .argument("", "Plugin id") .action(async (id: string) => { const cfg = loadConfig(); - const next = { - ...cfg, - plugins: { - ...cfg.plugins, - entries: { - ...cfg.plugins?.entries, - [id]: { - ...(cfg.plugins?.entries as Record | undefined)?.[id], - enabled: false, - }, - }, - }, - }; + const next = setPluginEnabledInConfig(cfg, id, false); await writeConfigFile(next); defaultRuntime.log(`Disabled plugin "${id}". Restart the gateway to apply.`); }); @@ -631,28 +625,24 @@ export function registerPluginsCli(program: Command) { clearPluginManifestRegistryCache(); let next = enablePluginInConfig(cfg, result.pluginId).config; - const resolvedSpec = result.npmResolution?.resolvedSpec; - const recordSpec = opts.pin && resolvedSpec ? resolvedSpec : raw; - if (opts.pin && !resolvedSpec) { - defaultRuntime.log( - theme.warn("Could not resolve exact npm version for --pin; storing original npm spec."), - ); - } - if (opts.pin && resolvedSpec) { - defaultRuntime.log(`Pinned npm install record to ${resolvedSpec}.`); - } + const pinInfo = resolvePinnedNpmSpec({ + rawSpec: raw, + pin: Boolean(opts.pin), + resolvedSpec: result.npmResolution?.resolvedSpec, + }); + logPinnedNpmSpecMessages( + pinInfo, + (message) => defaultRuntime.log(message), + (message) => defaultRuntime.log(theme.warn(message)), + ); next = recordPluginInstall(next, { pluginId: result.pluginId, - source: "npm", - spec: recordSpec, - installPath: result.targetDir, - version: result.version, - resolvedName: result.npmResolution?.name, - resolvedVersion: result.npmResolution?.version, - resolvedSpec: result.npmResolution?.resolvedSpec, - integrity: result.npmResolution?.integrity, - shasum: result.npmResolution?.shasum, - resolvedAt: result.npmResolution?.resolvedAt, + ...buildNpmInstallRecordFields({ + spec: pinInfo.recordSpec, + installPath: result.targetDir, + version: result.version, + resolution: result.npmResolution, + }), }); const slotResult = applySlotSelectionForPlugin(next, result.pluginId); next = slotResult.config; diff --git a/src/cli/plugins-config.test.ts b/src/cli/plugins-config.test.ts new file mode 100644 index 00000000000..5ba4c9415b8 --- /dev/null +++ b/src/cli/plugins-config.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { setPluginEnabledInConfig } from "./plugins-config.js"; + +describe("setPluginEnabledInConfig", () => { + it("sets enabled flag for an existing plugin entry", () => { + const config = { + plugins: { + entries: { + alpha: { enabled: false, custom: "x" }, + }, + }, + } as OpenClawConfig; + + const next = setPluginEnabledInConfig(config, "alpha", true); + + expect(next.plugins?.entries?.alpha).toEqual({ + enabled: true, + custom: "x", + }); + }); + + it("creates a plugin entry when it does not exist", () => { + const config = {} as OpenClawConfig; + + const next = setPluginEnabledInConfig(config, "beta", false); + + expect(next.plugins?.entries?.beta).toEqual({ + enabled: false, + }); + }); +}); diff --git a/src/cli/plugins-config.ts b/src/cli/plugins-config.ts new file mode 100644 index 00000000000..f8634388bfc --- /dev/null +++ b/src/cli/plugins-config.ts @@ -0,0 +1,21 @@ +import type { OpenClawConfig } from "../config/config.js"; + +export function setPluginEnabledInConfig( + config: OpenClawConfig, + pluginId: string, + enabled: boolean, +): OpenClawConfig { + return { + ...config, + plugins: { + ...config.plugins, + entries: { + ...config.plugins?.entries, + [pluginId]: { + ...(config.plugins?.entries?.[pluginId] as object | undefined), + enabled, + }, + }, + }, + }; +}