refactor(cli): extract hook pack update flow

This commit is contained in:
Peter Steinberger
2026-03-22 11:46:22 -07:00
parent 5696e24c3f
commit e3151af6bc
6 changed files with 300 additions and 255 deletions

View File

@@ -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"]);

View File

@@ -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<string | undefined> {
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;

View File

@@ -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<string, PluginInstallRecord>;
rawId?: string;
@@ -105,161 +87,15 @@ function resolveHookPackUpdateSelection(params: {
};
}
async function updateTrackedHookPacks(params: {
config: OpenClawConfig;
hookIds?: string[];
dryRun?: boolean;
specOverrides?: Record<string, string>;
}): Promise<HookPackUpdateSummary> {
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) {

198
src/hooks/update.ts Normal file
View File

@@ -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<boolean>;
}) {
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<string, string>;
onIntegrityDrift?: (params: HookPackUpdateIntegrityDriftParams) => boolean | Promise<boolean>;
}): Promise<HookPackUpdateSummary> {
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 };
}

View File

@@ -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<string | undefined> {
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);
}
}

View File

@@ -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<string | undefined> {
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,