refactor(cli): share npm install metadata helpers

This commit is contained in:
Peter Steinberger
2026-02-21 21:56:05 +00:00
parent d6ad647f56
commit 2d4e4e2288
6 changed files with 316 additions and 79 deletions

View File

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

View File

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

86
src/cli/npm-resolution.ts Normal file
View File

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

View File

@@ -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("<id>", "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<string, { enabled?: boolean }> | 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;

View File

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

21
src/cli/plugins-config.ts Normal file
View File

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