diff --git a/src/cli/plugins-cli.test.ts b/src/cli/plugins-cli.test.ts index ccfa630e8ae..7c15fcb6b85 100644 --- a/src/cli/plugins-cli.test.ts +++ b/src/cli/plugins-cli.test.ts @@ -16,6 +16,7 @@ const buildPluginStatusReport = vi.fn(); const applyExclusiveSlotSelection = vi.fn(); const uninstallPlugin = vi.fn(); const updateNpmInstalledPlugins = vi.fn(); +const updateNpmInstalledHookPacks = vi.fn(); const promptYesNo = vi.fn(); const installPluginFromNpmSpec = vi.fn(); const installPluginFromPath = vi.fn(); @@ -81,6 +82,10 @@ vi.mock("../plugins/update.js", () => ({ updateNpmInstalledPlugins: (...args: unknown[]) => updateNpmInstalledPlugins(...args), })); +vi.mock("../hooks/update.js", () => ({ + updateNpmInstalledHookPacks: (...args: unknown[]) => updateNpmInstalledHookPacks(...args), +})); + vi.mock("./prompt.js", () => ({ promptYesNo: (...args: unknown[]) => promptYesNo(...args), })); @@ -145,6 +150,7 @@ describe("plugins cli", () => { uninstallPlugin.mockReset(); updateNpmInstalledPlugins.mockReset(); promptYesNo.mockReset(); + updateNpmInstalledHookPacks.mockReset(); installPluginFromNpmSpec.mockReset(); installPluginFromPath.mockReset(); installPluginFromClawHub.mockReset(); @@ -189,6 +195,11 @@ describe("plugins cli", () => { changed: false, config: {} as OpenClawConfig, }); + updateNpmInstalledHookPacks.mockResolvedValue({ + outcomes: [], + changed: false, + config: {} as OpenClawConfig, + }); promptYesNo.mockResolvedValue(true); installPluginFromPath.mockResolvedValue({ ok: false, error: "path install disabled in test" }); installPluginFromNpmSpec.mockResolvedValue({ @@ -596,34 +607,24 @@ describe("plugins cli", () => { changed: false, outcomes: [], }); - installHooksFromNpmSpec.mockResolvedValue({ - ok: true, - hookPackId: "demo-hooks", - hooks: ["command-audit"], - targetDir: "/tmp/hooks/demo-hooks", - version: "1.1.0", - npmResolution: { - name: "@acme/demo-hooks", - spec: "@acme/demo-hooks@1.1.0", - integrity: "sha256-demo-2", - }, + updateNpmInstalledHookPacks.mockResolvedValue({ + config: nextConfig, + changed: true, + outcomes: [ + { + hookId: "demo-hooks", + status: "updated", + message: 'Updated hook pack "demo-hooks": 1.0.0 -> 1.1.0.', + }, + ], }); - recordHookInstall.mockReturnValue(nextConfig); await runCommand(["plugins", "update", "demo-hooks"]); - expect(installHooksFromNpmSpec).toHaveBeenCalledWith( + expect(updateNpmInstalledHookPacks).toHaveBeenCalledWith( expect.objectContaining({ - spec: "@acme/demo-hooks@1.0.0", - mode: "update", - expectedHookPackId: "demo-hooks", - }), - ); - expect(recordHookInstall).toHaveBeenCalledWith( - cfg, - expect.objectContaining({ - hookId: "demo-hooks", - hooks: ["command-audit"], + config: cfg, + hookIds: ["demo-hooks"], }), ); expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); @@ -756,6 +757,7 @@ describe("plugins cli", () => { await runCommand(["plugins", "update", "--all"]); expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled(); expect(runtimeLogs.at(-1)).toBe("No tracked plugins or hook packs to update."); }); @@ -920,6 +922,11 @@ describe("plugins cli", () => { changed: true, config: nextConfig, }); + updateNpmInstalledHookPacks.mockResolvedValue({ + outcomes: [], + changed: false, + config: nextConfig, + }); await runCommand(["plugins", "update", "alpha"]); diff --git a/src/cli/plugins-command-helpers.ts b/src/cli/plugins-command-helpers.ts index 17d00c7f955..931432799ca 100644 --- a/src/cli/plugins-command-helpers.ts +++ b/src/cli/plugins-command-helpers.ts @@ -1,5 +1,3 @@ -import fs from "node:fs"; -import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; import type { HookInstallRecord } from "../config/types.hooks.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; @@ -138,16 +136,6 @@ export function logHookPackRestartHint() { defaultRuntime.log("Restart the gateway to load hooks."); } -export async function readInstalledPackageVersion(dir: string): Promise { - try { - const raw = fs.readFileSync(path.join(dir, "package.json"), "utf-8"); - const parsed = JSON.parse(raw) as { version?: unknown }; - return typeof parsed.version === "string" ? parsed.version : undefined; - } catch { - return undefined; - } -} - export function logSlotWarnings(warnings: string[]) { if (warnings.length === 0) { return; diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts index 68808c26615..668dd2da212 100644 --- a/src/cli/plugins-update-command.ts +++ b/src/cli/plugins-update-command.ts @@ -1,35 +1,17 @@ -import type { OpenClawConfig } from "../config/config.js"; import { loadConfig, writeConfigFile } from "../config/config.js"; import type { HookInstallRecord } from "../config/types.hooks.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; -import { installHooksFromNpmSpec, resolveHookInstallDir } from "../hooks/install.js"; -import { recordHookInstall } from "../hooks/installs.js"; +import { updateNpmInstalledHookPacks } from "../hooks/update.js"; import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { updateNpmInstalledPlugins } from "../plugins/update.js"; import { defaultRuntime } from "../runtime.js"; import { theme } from "../terminal/theme.js"; import { - createHookPackInstallLogger, extractInstalledNpmHookPackageName, extractInstalledNpmPackageName, - readInstalledPackageVersion, } from "./plugins-command-helpers.js"; import { promptYesNo } from "./prompt.js"; -type HookPackUpdateOutcome = { - hookId: string; - status: "updated" | "unchanged" | "skipped" | "error"; - message: string; - currentVersion?: string; - nextVersion?: string; -}; - -type HookPackUpdateSummary = { - config: OpenClawConfig; - changed: boolean; - outcomes: HookPackUpdateOutcome[]; -}; - function resolvePluginUpdateSelection(params: { installs: Record; rawId?: string; @@ -105,161 +87,15 @@ function resolveHookPackUpdateSelection(params: { }; } -async function updateTrackedHookPacks(params: { - config: OpenClawConfig; - hookIds?: string[]; - dryRun?: boolean; - specOverrides?: Record; -}): Promise { - const installs = params.config.hooks?.internal?.installs ?? {}; - const targets = params.hookIds?.length ? params.hookIds : Object.keys(installs); - const outcomes: HookPackUpdateOutcome[] = []; - let next = params.config; - let changed = false; - - for (const hookId of targets) { - const record = installs[hookId]; - if (!record) { - outcomes.push({ - hookId, - status: "skipped", - message: `No install record for hook pack "${hookId}".`, - }); - continue; - } - if (record.source !== "npm") { - outcomes.push({ - hookId, - status: "skipped", - message: `Skipping hook pack "${hookId}" (source: ${record.source}).`, - }); - continue; - } - - const effectiveSpec = params.specOverrides?.[hookId] ?? record.spec; - if (!effectiveSpec) { - outcomes.push({ - hookId, - status: "skipped", - message: `Skipping hook pack "${hookId}" (missing npm spec).`, - }); - continue; - } - - let installPath: string; - try { - installPath = record.installPath ?? resolveHookInstallDir(hookId); - } catch (err) { - outcomes.push({ - hookId, - status: "error", - message: `Invalid install path for hook pack "${hookId}": ${String(err)}`, - }); - continue; - } - const currentVersion = await readInstalledPackageVersion(installPath); - - const onIntegrityDrift = async (drift: { - spec: string; - expectedIntegrity: string; - actualIntegrity: string; - resolution: { resolvedSpec?: string }; - }) => { - const specLabel = drift.resolution.resolvedSpec ?? drift.spec; - defaultRuntime.log( - theme.warn( - `Integrity drift detected for hook pack "${hookId}" (${specLabel})` + - `\nExpected: ${drift.expectedIntegrity}` + - `\nActual: ${drift.actualIntegrity}`, - ), - ); - if (params.dryRun) { - return true; - } - return await promptYesNo(`Continue updating hook pack "${hookId}" with this artifact?`); - }; - - const result = params.dryRun - ? await installHooksFromNpmSpec({ - spec: effectiveSpec, - mode: "update", - dryRun: true, - expectedHookPackId: hookId, - expectedIntegrity: record.integrity, - onIntegrityDrift, - logger: createHookPackInstallLogger(), - }) - : await installHooksFromNpmSpec({ - spec: effectiveSpec, - mode: "update", - expectedHookPackId: hookId, - expectedIntegrity: record.integrity, - onIntegrityDrift, - logger: createHookPackInstallLogger(), - }); - - if (!result.ok) { - outcomes.push({ - hookId, - status: "error", - message: `Failed to ${params.dryRun ? "check" : "update"} hook pack "${hookId}": ${result.error}`, - }); - continue; - } - - const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir)); - const currentLabel = currentVersion ?? "unknown"; - const nextLabel = nextVersion ?? "unknown"; - - if (params.dryRun) { - outcomes.push({ - hookId, - status: - currentVersion && nextVersion && currentVersion === nextVersion ? "unchanged" : "updated", - currentVersion: currentVersion ?? undefined, - nextVersion: nextVersion ?? undefined, - message: - currentVersion && nextVersion && currentVersion === nextVersion - ? `Hook pack "${hookId}" is up to date (${currentLabel}).` - : `Would update hook pack "${hookId}": ${currentLabel} -> ${nextLabel}.`, - }); - continue; - } - - next = recordHookInstall(next, { - hookId, - source: "npm", - spec: effectiveSpec, - installPath: result.targetDir, - version: nextVersion, - resolvedName: result.npmResolution?.name, - resolvedSpec: result.npmResolution?.resolvedSpec, - integrity: result.npmResolution?.integrity, - hooks: result.hooks, - }); - changed = true; - - outcomes.push({ - hookId, - status: - currentVersion && nextVersion && currentVersion === nextVersion ? "unchanged" : "updated", - currentVersion: currentVersion ?? undefined, - nextVersion: nextVersion ?? undefined, - message: - currentVersion && nextVersion && currentVersion === nextVersion - ? `Hook pack "${hookId}" already at ${currentLabel}.` - : `Updated hook pack "${hookId}": ${currentLabel} -> ${nextLabel}.`, - }); - } - - return { config: next, changed, outcomes }; -} - export async function runPluginUpdateCommand(params: { id?: string; opts: { all?: boolean; dryRun?: boolean }; }) { const cfg = loadConfig(); + const logger = { + info: (msg: string) => defaultRuntime.log(msg), + warn: (msg: string) => defaultRuntime.log(theme.warn(msg)), + }; const pluginSelection = resolvePluginUpdateSelection({ installs: cfg.plugins?.installs ?? {}, rawId: params.id, @@ -285,10 +121,7 @@ export async function runPluginUpdateCommand(params: { pluginIds: pluginSelection.pluginIds, specOverrides: pluginSelection.specOverrides, dryRun: params.opts.dryRun, - logger: { - info: (msg) => defaultRuntime.log(msg), - warn: (msg) => defaultRuntime.log(theme.warn(msg)), - }, + logger, onIntegrityDrift: async (drift) => { const specLabel = drift.resolvedSpec ?? drift.spec; defaultRuntime.log( @@ -304,11 +137,26 @@ export async function runPluginUpdateCommand(params: { return await promptYesNo(`Continue updating "${drift.pluginId}" with this artifact?`); }, }); - const hookResult = await updateTrackedHookPacks({ + const hookResult = await updateNpmInstalledHookPacks({ config: pluginResult.config, hookIds: hookSelection.hookIds, specOverrides: hookSelection.specOverrides, dryRun: params.opts.dryRun, + logger, + onIntegrityDrift: async (drift) => { + const specLabel = drift.resolvedSpec ?? drift.spec; + defaultRuntime.log( + theme.warn( + `Integrity drift detected for hook pack "${drift.hookId}" (${specLabel})` + + `\nExpected: ${drift.expectedIntegrity}` + + `\nActual: ${drift.actualIntegrity}`, + ), + ); + if (drift.dryRun) { + return true; + } + return await promptYesNo(`Continue updating hook pack "${drift.hookId}" with this artifact?`); + }, }); for (const outcome of pluginResult.outcomes) { diff --git a/src/hooks/update.ts b/src/hooks/update.ts new file mode 100644 index 00000000000..ca0f66e5d59 --- /dev/null +++ b/src/hooks/update.ts @@ -0,0 +1,198 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { + expectedIntegrityForUpdate, + readInstalledPackageVersion, +} from "../infra/package-update-utils.js"; +import { + installHooksFromNpmSpec, + type HookNpmIntegrityDriftParams, + resolveHookInstallDir, +} from "./install.js"; +import { recordHookInstall } from "./installs.js"; + +export type HookPackUpdateLogger = { + info?: (message: string) => void; + warn?: (message: string) => void; +}; + +export type HookPackUpdateStatus = "updated" | "unchanged" | "skipped" | "error"; + +export type HookPackUpdateOutcome = { + hookId: string; + status: HookPackUpdateStatus; + message: string; + currentVersion?: string; + nextVersion?: string; +}; + +export type HookPackUpdateSummary = { + config: OpenClawConfig; + changed: boolean; + outcomes: HookPackUpdateOutcome[]; +}; + +export type HookPackUpdateIntegrityDriftParams = HookNpmIntegrityDriftParams & { + hookId: string; + resolvedSpec?: string; + resolvedVersion?: string; + dryRun: boolean; +}; + +function createHookPackUpdateIntegrityDriftHandler(params: { + hookId: string; + dryRun: boolean; + logger: HookPackUpdateLogger; + onIntegrityDrift?: (params: HookPackUpdateIntegrityDriftParams) => boolean | Promise; +}) { + return async (drift: HookNpmIntegrityDriftParams) => { + const payload: HookPackUpdateIntegrityDriftParams = { + hookId: params.hookId, + spec: drift.spec, + expectedIntegrity: drift.expectedIntegrity, + actualIntegrity: drift.actualIntegrity, + resolution: drift.resolution, + resolvedSpec: drift.resolution.resolvedSpec, + resolvedVersion: drift.resolution.version, + dryRun: params.dryRun, + }; + if (params.onIntegrityDrift) { + return await params.onIntegrityDrift(payload); + } + params.logger.warn?.( + `Integrity drift for hook pack "${params.hookId}" (${payload.resolvedSpec ?? payload.spec}): expected ${payload.expectedIntegrity}, got ${payload.actualIntegrity}`, + ); + return true; + }; +} + +export async function updateNpmInstalledHookPacks(params: { + config: OpenClawConfig; + logger?: HookPackUpdateLogger; + hookIds?: string[]; + dryRun?: boolean; + specOverrides?: Record; + onIntegrityDrift?: (params: HookPackUpdateIntegrityDriftParams) => boolean | Promise; +}): Promise { + const logger = params.logger ?? {}; + const installs = params.config.hooks?.internal?.installs ?? {}; + const targets = params.hookIds?.length ? params.hookIds : Object.keys(installs); + const outcomes: HookPackUpdateOutcome[] = []; + let next = params.config; + let changed = false; + + for (const hookId of targets) { + const record = installs[hookId]; + if (!record) { + outcomes.push({ + hookId, + status: "skipped", + message: `No install record for hook pack "${hookId}".`, + }); + continue; + } + if (record.source !== "npm") { + outcomes.push({ + hookId, + status: "skipped", + message: `Skipping hook pack "${hookId}" (source: ${record.source}).`, + }); + continue; + } + + const effectiveSpec = params.specOverrides?.[hookId] ?? record.spec; + const expectedIntegrity = + effectiveSpec === record.spec + ? expectedIntegrityForUpdate(record.spec, record.integrity) + : undefined; + if (!effectiveSpec) { + outcomes.push({ + hookId, + status: "skipped", + message: `Skipping hook pack "${hookId}" (missing npm spec).`, + }); + continue; + } + + let installPath: string; + try { + installPath = record.installPath ?? resolveHookInstallDir(hookId); + } catch (err) { + outcomes.push({ + hookId, + status: "error", + message: `Invalid install path for hook pack "${hookId}": ${String(err)}`, + }); + continue; + } + const currentVersion = await readInstalledPackageVersion(installPath); + const result = await installHooksFromNpmSpec({ + spec: effectiveSpec, + mode: "update", + dryRun: params.dryRun, + expectedHookPackId: hookId, + expectedIntegrity, + onIntegrityDrift: createHookPackUpdateIntegrityDriftHandler({ + hookId, + dryRun: Boolean(params.dryRun), + logger, + onIntegrityDrift: params.onIntegrityDrift, + }), + logger, + }); + + if (!result.ok) { + outcomes.push({ + hookId, + status: "error", + message: `Failed to ${params.dryRun ? "check" : "update"} hook pack "${hookId}": ${result.error}`, + }); + continue; + } + + const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir)); + const currentLabel = currentVersion ?? "unknown"; + const nextLabel = nextVersion ?? "unknown"; + const status = + currentVersion && nextVersion && currentVersion === nextVersion ? "unchanged" : "updated"; + + if (params.dryRun) { + outcomes.push({ + hookId, + status, + currentVersion: currentVersion ?? undefined, + nextVersion: nextVersion ?? undefined, + message: + status === "unchanged" + ? `Hook pack "${hookId}" is up to date (${currentLabel}).` + : `Would update hook pack "${hookId}": ${currentLabel} -> ${nextLabel}.`, + }); + continue; + } + + next = recordHookInstall(next, { + hookId, + source: "npm", + spec: effectiveSpec, + installPath: result.targetDir, + version: nextVersion, + resolvedName: result.npmResolution?.name, + resolvedSpec: result.npmResolution?.resolvedSpec, + integrity: result.npmResolution?.integrity, + hooks: result.hooks, + }); + changed = true; + + outcomes.push({ + hookId, + status, + currentVersion: currentVersion ?? undefined, + nextVersion: nextVersion ?? undefined, + message: + status === "unchanged" + ? `Hook pack "${hookId}" already at ${currentLabel}.` + : `Updated hook pack "${hookId}": ${currentLabel} -> ${nextLabel}.`, + }); + } + + return { config: next, changed, outcomes }; +} diff --git a/src/infra/package-update-utils.ts b/src/infra/package-update-utils.ts new file mode 100644 index 00000000000..68805c7554c --- /dev/null +++ b/src/infra/package-update-utils.ts @@ -0,0 +1,46 @@ +import fsSync from "node:fs"; +import path from "node:path"; +import { openBoundaryFileSync } from "./boundary-file-read.js"; + +export function expectedIntegrityForUpdate( + spec: string | undefined, + integrity: string | undefined, +): string | undefined { + if (!integrity || !spec) { + return undefined; + } + const value = spec.trim(); + if (!value) { + return undefined; + } + const at = value.lastIndexOf("@"); + if (at <= 0 || at >= value.length - 1) { + return undefined; + } + const version = value.slice(at + 1).trim(); + if (!/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) { + return undefined; + } + return integrity; +} + +export async function readInstalledPackageVersion(dir: string): Promise { + const manifestPath = path.join(dir, "package.json"); + const opened = openBoundaryFileSync({ + absolutePath: manifestPath, + rootPath: dir, + boundaryLabel: "installed package directory", + }); + if (!opened.ok) { + 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; + } catch { + return undefined; + } finally { + fsSync.closeSync(opened.fd); + } +} diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 026fce749d8..d201780ad2a 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -1,7 +1,8 @@ -import fsSync from "node:fs"; -import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; -import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; +import { + expectedIntegrityForUpdate, + readInstalledPackageVersion, +} from "../infra/package-update-utils.js"; import type { UpdateChannel } from "../infra/update-channels.js"; import { resolveUserPath } from "../utils.js"; import { resolveBundledPluginSources } from "./bundled-sources.js"; @@ -103,49 +104,6 @@ type InstallIntegrityDrift = { }; }; -function expectedIntegrityForUpdate( - spec: string | undefined, - integrity: string | undefined, -): string | undefined { - if (!integrity || !spec) { - return undefined; - } - const value = spec.trim(); - if (!value) { - return undefined; - } - const at = value.lastIndexOf("@"); - if (at <= 0 || at >= value.length - 1) { - return undefined; - } - const version = value.slice(at + 1).trim(); - if (!/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) { - return undefined; - } - return integrity; -} - -async function readInstalledPackageVersion(dir: string): Promise { - const manifestPath = path.join(dir, "package.json"); - const opened = openBoundaryFileSync({ - absolutePath: manifestPath, - rootPath: dir, - boundaryLabel: "installed plugin directory", - }); - if (!opened.ok) { - 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; - } catch { - return undefined; - } finally { - fsSync.closeSync(opened.fd); - } -} - function pathsEqual( left: string | undefined, right: string | undefined,