mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-23 09:58:12 +00:00
fix(cli): harden official plugin recovery (#93325)
* fix(cli): harden official plugin recovery * fix(config): preserve include write context * fix(config): reject external include mutations * fix(config): bind snapshots to config paths * fix(config): preserve write ownership * fix(cli): preflight plugin config mutations * chore(plugin-sdk): refresh api baseline * test(config): prove install env policy mutations * fix(cli): preflight plugin updates * fix(cli): preflight non-npm id migrations * chore(plugin-sdk): refresh api baseline * fix(cli): satisfy plugin recovery checks
This commit is contained in:
@@ -1,11 +1,17 @@
|
||||
// Plugins CLI update tests cover plugin update command behavior and output.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { hashConfigIncludeRaw } from "../config/includes.js";
|
||||
import {
|
||||
loadConfig,
|
||||
readConfigFileSnapshotForWrite,
|
||||
refreshPluginRegistry,
|
||||
registerPluginsCli,
|
||||
replaceConfigFile,
|
||||
resetPluginsCliTestState,
|
||||
runPluginsCommand,
|
||||
runtimeErrors,
|
||||
@@ -55,6 +61,63 @@ function expectSingleCallParams(mockFn: ReturnType<typeof vi.fn>) {
|
||||
return params;
|
||||
}
|
||||
|
||||
function primeUpdateConfigSnapshot(params: {
|
||||
config: OpenClawConfig;
|
||||
configPath?: string;
|
||||
loadedConfig?: OpenClawConfig;
|
||||
parsed?: Record<string, unknown>;
|
||||
runtimeConfig?: OpenClawConfig;
|
||||
sourceConfig?: OpenClawConfig;
|
||||
valid?: boolean;
|
||||
includeFileHashesForWrite?: Record<string, string>;
|
||||
includeFileTargetsForWrite?: Record<string, string>;
|
||||
}): void {
|
||||
const configPath = params.configPath ?? path.join(process.cwd(), "openclaw.json5");
|
||||
const parsed = params.parsed ?? (params.config as Record<string, unknown>);
|
||||
const sourceConfig = params.sourceConfig ?? params.config;
|
||||
const runtimeConfig = params.runtimeConfig ?? params.config;
|
||||
loadConfig.mockReturnValue(params.loadedConfig ?? params.config);
|
||||
readConfigFileSnapshotForWrite.mockResolvedValue({
|
||||
snapshot: {
|
||||
path: configPath,
|
||||
exists: true,
|
||||
raw: JSON.stringify(parsed),
|
||||
parsed,
|
||||
resolved: sourceConfig,
|
||||
sourceConfig,
|
||||
runtimeConfig,
|
||||
valid: params.valid ?? true,
|
||||
config: runtimeConfig,
|
||||
hash: "update-config",
|
||||
issues: [],
|
||||
warnings: [],
|
||||
legacyIssues: [],
|
||||
},
|
||||
writeOptions: {
|
||||
assertConfigPathForWrite: () => {},
|
||||
expectedConfigPath: configPath,
|
||||
ownedConfigPathForWrite: configPath,
|
||||
includeFileHashesForWrite: params.includeFileHashesForWrite,
|
||||
includeFileTargetsForWrite: params.includeFileTargetsForWrite,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function primeBlockedUpdateConfig(section: "hooks" | "plugins", config: OpenClawConfig): void {
|
||||
const externalPath = path.join(
|
||||
path.parse(process.cwd()).root,
|
||||
"external-openclaw",
|
||||
`${section}.json5`,
|
||||
);
|
||||
primeUpdateConfigSnapshot({
|
||||
config,
|
||||
parsed: { [section]: { $include: externalPath } },
|
||||
includeFileTargetsForWrite: {
|
||||
[externalPath]: externalPath,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("plugins cli update", () => {
|
||||
beforeEach(() => {
|
||||
resetPluginsCliTestState();
|
||||
@@ -131,7 +194,12 @@ describe("plugins cli update", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
primeUpdateConfigSnapshot({
|
||||
config: cfg,
|
||||
includeFileHashesForWrite: {
|
||||
"/tmp/hooks.json5": "hooks-start-hash",
|
||||
},
|
||||
});
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
config: cfg,
|
||||
changed: false,
|
||||
@@ -155,10 +223,682 @@ describe("plugins cli update", () => {
|
||||
expect(hookUpdateParams.config).toBe(cfg);
|
||||
expect(hookUpdateParams.hookIds).toEqual(["demo-hooks"]);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(nextConfig);
|
||||
expect(replaceConfigFile).toHaveBeenCalledWith({
|
||||
nextConfig,
|
||||
baseHash: "update-config",
|
||||
writeOptions: expect.objectContaining({
|
||||
includeFileHashesForWrite: {
|
||||
"/tmp/hooks.json5": "hooks-start-hash",
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(refreshPluginRegistry).not.toHaveBeenCalled();
|
||||
expectRestartNoticeLogged();
|
||||
});
|
||||
|
||||
it("uses the mutation-start snapshot for updater input and hook selection", async () => {
|
||||
const loadedConfig = {
|
||||
hooks: {
|
||||
internal: {
|
||||
installs: {
|
||||
"old-hooks": {
|
||||
source: "npm",
|
||||
spec: "@acme/old-hooks@1.0.0",
|
||||
installPath: "/tmp/hooks/old-hooks",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
alpha: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const snapshotConfig = {
|
||||
hooks: {
|
||||
internal: {
|
||||
installs: {
|
||||
"new-hooks": {
|
||||
source: "npm",
|
||||
spec: "@acme/new-hooks@1.0.0",
|
||||
installPath: "~/.openclaw/hooks/new-hooks",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
alpha: { enabled: false },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const installRecords = {
|
||||
alpha: {
|
||||
source: "npm",
|
||||
spec: "@openclaw/alpha@1.0.0",
|
||||
installPath: "/tmp/alpha",
|
||||
},
|
||||
} as const;
|
||||
primeUpdateConfigSnapshot({
|
||||
config: snapshotConfig,
|
||||
loadedConfig,
|
||||
runtimeConfig: {
|
||||
...snapshotConfig,
|
||||
hooks: {
|
||||
internal: {
|
||||
installs: {
|
||||
"new-hooks": {
|
||||
source: "npm",
|
||||
spec: "@acme/new-hooks@1.0.0",
|
||||
installPath: "/home/test/.openclaw/hooks/new-hooks",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
ackReactionScope: "group-mentions",
|
||||
},
|
||||
},
|
||||
});
|
||||
setInstalledPluginIndexInstallRecords(installRecords);
|
||||
updateNpmInstalledPlugins.mockImplementation(async (params: { config: OpenClawConfig }) => ({
|
||||
config: params.config,
|
||||
changed: false,
|
||||
outcomes: [],
|
||||
}));
|
||||
updateNpmInstalledHookPacks.mockImplementation(async (params: { config: OpenClawConfig }) => ({
|
||||
config: params.config,
|
||||
changed: false,
|
||||
outcomes: [],
|
||||
}));
|
||||
|
||||
await runPluginsCommand(["plugins", "update", "--all"]);
|
||||
|
||||
const pluginUpdateParams = expectSingleCallParams(updateNpmInstalledPlugins);
|
||||
const hookUpdateParams = expectSingleCallParams(updateNpmInstalledHookPacks);
|
||||
expect(pluginUpdateParams.config).toEqual({
|
||||
...snapshotConfig,
|
||||
hooks: {
|
||||
internal: {
|
||||
installs: {
|
||||
"new-hooks": {
|
||||
source: "npm",
|
||||
spec: "@acme/new-hooks@1.0.0",
|
||||
installPath: "/home/test/.openclaw/hooks/new-hooks",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
ackReactionScope: "group-mentions",
|
||||
},
|
||||
plugins: {
|
||||
...snapshotConfig.plugins,
|
||||
installs: installRecords,
|
||||
},
|
||||
});
|
||||
expect(hookUpdateParams.hookIds).toEqual(["new-hooks"]);
|
||||
});
|
||||
|
||||
it("uses resolved shipped install records instead of raw env placeholders", async () => {
|
||||
const cfg = createTrackedPluginConfig({
|
||||
pluginId: "alpha",
|
||||
spec: "@openclaw/alpha@1.0.0",
|
||||
});
|
||||
primeUpdateConfigSnapshot({
|
||||
config: cfg,
|
||||
parsed: {
|
||||
plugins: {
|
||||
installs: {
|
||||
alpha: {
|
||||
source: "npm",
|
||||
spec: "${PLUGIN_SPEC}",
|
||||
installPath: "${PLUGIN_PATH}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
config: cfg,
|
||||
changed: false,
|
||||
outcomes: [],
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "update", "alpha"]);
|
||||
|
||||
const updateParams = expectSingleCallParams(updateNpmInstalledPlugins);
|
||||
expect(updateParams.config).toEqual(cfg);
|
||||
});
|
||||
|
||||
it("rejects invalid config snapshots before updater side effects", async () => {
|
||||
const cfg = createTrackedPluginConfig({
|
||||
pluginId: "alpha",
|
||||
spec: "@openclaw/alpha@1.0.0",
|
||||
});
|
||||
primeUpdateConfigSnapshot({
|
||||
config: cfg,
|
||||
valid: false,
|
||||
});
|
||||
setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "update", "alpha"])).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(runtimeErrors.at(-1)).toBe(
|
||||
"Cannot update plugins or hooks while the config is invalid.",
|
||||
);
|
||||
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
|
||||
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks hook pack updates before updater side effects when hooks config is include-owned", async () => {
|
||||
const cfg = {
|
||||
hooks: {
|
||||
internal: {
|
||||
installs: {
|
||||
"demo-hooks": {
|
||||
source: "npm",
|
||||
spec: "@acme/demo-hooks@1.0.0",
|
||||
installPath: "/tmp/hooks/demo-hooks",
|
||||
resolvedName: "@acme/demo-hooks",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
primeBlockedUpdateConfig("hooks", cfg);
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "update", "--all"])).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"Config hooks are stored in an external or unresolved top-level $include",
|
||||
);
|
||||
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
|
||||
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows index-only legacy id migration when an included plugins section has no references", async () => {
|
||||
const cfg = { plugins: {} } as OpenClawConfig;
|
||||
const pluginRecords = createTrackedPluginConfig({
|
||||
pluginId: "voice-call",
|
||||
spec: "@openclaw/voice-call@1.0.0",
|
||||
}).plugins?.installs;
|
||||
const nextConfig = {
|
||||
...cfg,
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
installs: {
|
||||
"@openclaw/voice-call": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/voice-call@1.1.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
primeBlockedUpdateConfig("plugins", cfg);
|
||||
setInstalledPluginIndexInstallRecords(pluginRecords ?? {});
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
config: nextConfig,
|
||||
changed: true,
|
||||
outcomes: [
|
||||
{
|
||||
pluginId: "@openclaw/voice-call",
|
||||
status: "updated",
|
||||
message: "Updated @openclaw/voice-call.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "update", "--all"]);
|
||||
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
expect(updateNpmInstalledPlugins).toHaveBeenCalledOnce();
|
||||
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
|
||||
nextConfig.plugins?.installs,
|
||||
);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(cfg);
|
||||
});
|
||||
|
||||
it("allows scoped non-npm updates beside include-owned plugin config", async () => {
|
||||
const pluginId = "@acme/demo";
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {
|
||||
[pluginId]: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const pluginRecords = {
|
||||
[pluginId]: {
|
||||
source: "git",
|
||||
spec: "https://github.com/acme/demo.git#v1.0.0",
|
||||
installPath: "/tmp/demo",
|
||||
},
|
||||
} as const;
|
||||
const nextConfig = {
|
||||
...cfg,
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
installs: pluginRecords,
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
primeBlockedUpdateConfig("plugins", cfg);
|
||||
setInstalledPluginIndexInstallRecords(pluginRecords);
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
config: nextConfig,
|
||||
changed: true,
|
||||
outcomes: [{ pluginId, status: "updated", message: `Updated ${pluginId}.` }],
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "update", pluginId]);
|
||||
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
expect(updateNpmInstalledPlugins).toHaveBeenCalledOnce();
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(pluginRecords);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(cfg);
|
||||
});
|
||||
|
||||
it("blocks legacy plugin id migration before updater side effects", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"voice-call": { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
primeBlockedUpdateConfig("plugins", cfg);
|
||||
setInstalledPluginIndexInstallRecords({
|
||||
"voice-call": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/voice-call",
|
||||
installPath: "/tmp/voice-call",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "update", "voice-call"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"Config plugins are stored in an external or unresolved top-level $include",
|
||||
);
|
||||
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
|
||||
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "ClawHub",
|
||||
record: {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:@openclaw/voice-call",
|
||||
clawhubPackage: "@openclaw/voice-call",
|
||||
installPath: "/tmp/voice-call",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "git",
|
||||
record: {
|
||||
source: "git",
|
||||
spec: "https://github.com/openclaw/voice-call.git",
|
||||
installPath: "/tmp/voice-call",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "marketplace",
|
||||
record: {
|
||||
source: "marketplace",
|
||||
marketplaceSource: "acme",
|
||||
marketplacePlugin: "voice-call",
|
||||
installPath: "/tmp/voice-call",
|
||||
},
|
||||
},
|
||||
] as const)(
|
||||
"blocks possible $label id migration before updater side effects",
|
||||
async ({ record }) => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"voice-call": { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
primeBlockedUpdateConfig("plugins", cfg);
|
||||
setInstalledPluginIndexInstallRecords({
|
||||
"voice-call": record,
|
||||
});
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "update", "voice-call"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"Config plugins are stored in an external or unresolved top-level $include",
|
||||
);
|
||||
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("blocks possible legacy id migration when an included plugins section is unresolved", async () => {
|
||||
const externalPath = path.join(
|
||||
path.parse(process.cwd()).root,
|
||||
"external-openclaw",
|
||||
"plugins.json5",
|
||||
);
|
||||
const cfg = { plugins: {} } as OpenClawConfig;
|
||||
primeUpdateConfigSnapshot({
|
||||
config: cfg,
|
||||
parsed: { plugins: { $include: externalPath } },
|
||||
sourceConfig: { plugins: { $include: externalPath } } as unknown as OpenClawConfig,
|
||||
includeFileTargetsForWrite: {
|
||||
[externalPath]: externalPath,
|
||||
},
|
||||
});
|
||||
setInstalledPluginIndexInstallRecords({
|
||||
"voice-call": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/voice-call",
|
||||
installPath: "/tmp/voice-call",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "update", "voice-call"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"Config plugins are stored in an external or unresolved top-level $include",
|
||||
);
|
||||
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preflights legacy plugin-record cleanup before hook-only updater side effects", async () => {
|
||||
const cfg = {
|
||||
hooks: {
|
||||
internal: {
|
||||
installs: {
|
||||
"demo-hooks": {
|
||||
source: "npm",
|
||||
spec: "@acme/demo-hooks@1.0.0",
|
||||
installPath: "/tmp/hooks/demo-hooks",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
installs: {
|
||||
legacy: {
|
||||
source: "npm",
|
||||
spec: "@openclaw/legacy@1.0.0",
|
||||
installPath: "/tmp/legacy",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
primeBlockedUpdateConfig("plugins", cfg);
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "update", "demo-hooks"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"Config plugins are stored in an external or unresolved top-level $include",
|
||||
);
|
||||
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
|
||||
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves skip behavior for plugin records whose source cannot be updated", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
installs: {
|
||||
linked: {
|
||||
source: "path",
|
||||
sourcePath: "/tmp/linked",
|
||||
installPath: "/tmp/linked",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
primeBlockedUpdateConfig("plugins", cfg);
|
||||
setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
config: cfg,
|
||||
changed: false,
|
||||
outcomes: [{ pluginId: "linked", status: "skipped", message: "Skipping linked." }],
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "update", "--all"]);
|
||||
|
||||
expect(updateNpmInstalledPlugins).toHaveBeenCalledOnce();
|
||||
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves skip behavior for ClawHub records missing package metadata", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
primeBlockedUpdateConfig("plugins", cfg);
|
||||
setInstalledPluginIndexInstallRecords({
|
||||
demo: {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:demo",
|
||||
installPath: "/tmp/demo",
|
||||
},
|
||||
});
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
config: cfg,
|
||||
changed: false,
|
||||
outcomes: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
status: "skipped",
|
||||
message: 'Skipping "demo" (missing ClawHub package metadata).',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "update", "demo"]);
|
||||
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
expect(updateNpmInstalledPlugins).toHaveBeenCalledOnce();
|
||||
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves an include-owned plugins section during legacy-record cleanup", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-"));
|
||||
const configPath = path.join(tempRoot, "openclaw.json5");
|
||||
const pluginsPath = path.join(tempRoot, "plugins.json5");
|
||||
const cfg = createTrackedPluginConfig({
|
||||
pluginId: "alpha",
|
||||
spec: "@openclaw/alpha@1.0.0",
|
||||
});
|
||||
const pluginsRaw = `${JSON.stringify(cfg.plugins, null, 2)}\n`;
|
||||
const nextConfig = createTrackedPluginConfig({
|
||||
pluginId: "alpha",
|
||||
spec: "@openclaw/alpha@1.1.0",
|
||||
});
|
||||
fs.writeFileSync(pluginsPath, pluginsRaw);
|
||||
primeUpdateConfigSnapshot({
|
||||
config: cfg,
|
||||
configPath,
|
||||
parsed: { plugins: { $include: "./plugins.json5" } },
|
||||
includeFileHashesForWrite: {
|
||||
[pluginsPath]: hashConfigIncludeRaw(pluginsRaw),
|
||||
},
|
||||
includeFileTargetsForWrite: {
|
||||
[pluginsPath]: fs.realpathSync(pluginsPath),
|
||||
},
|
||||
});
|
||||
setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
config: nextConfig,
|
||||
changed: true,
|
||||
outcomes: [{ pluginId: "alpha", status: "updated", message: "Updated alpha." }],
|
||||
});
|
||||
|
||||
try {
|
||||
await runPluginsCommand(["plugins", "update", "alpha"]);
|
||||
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
expect(updateNpmInstalledPlugins).toHaveBeenCalledOnce();
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
|
||||
nextConfig.plugins?.installs,
|
||||
);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith({ plugins: {} });
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("migrates included legacy install records while updating another indexed plugin", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-"));
|
||||
const configPath = path.join(tempRoot, "openclaw.json5");
|
||||
const pluginsPath = path.join(tempRoot, "plugins.json5");
|
||||
const legacyRecord = {
|
||||
source: "npm",
|
||||
spec: "@openclaw/legacy@1.0.0",
|
||||
installPath: "/tmp/legacy",
|
||||
} as const;
|
||||
const indexedRecord = {
|
||||
source: "npm",
|
||||
spec: "@openclaw/alpha@1.0.0",
|
||||
installPath: "/tmp/alpha",
|
||||
} as const;
|
||||
const updatedIndexedRecord = {
|
||||
...indexedRecord,
|
||||
spec: "@openclaw/alpha@1.1.0",
|
||||
} as const;
|
||||
const cfg = {
|
||||
plugins: {
|
||||
installs: {
|
||||
legacy: legacyRecord,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const pluginsRaw = `${JSON.stringify(cfg.plugins, null, 2)}\n`;
|
||||
const nextInstallRecords = {
|
||||
alpha: updatedIndexedRecord,
|
||||
legacy: legacyRecord,
|
||||
};
|
||||
fs.writeFileSync(pluginsPath, pluginsRaw);
|
||||
primeUpdateConfigSnapshot({
|
||||
config: cfg,
|
||||
configPath,
|
||||
parsed: { plugins: { $include: "./plugins.json5" } },
|
||||
includeFileHashesForWrite: {
|
||||
[pluginsPath]: hashConfigIncludeRaw(pluginsRaw),
|
||||
},
|
||||
includeFileTargetsForWrite: {
|
||||
[pluginsPath]: fs.realpathSync(pluginsPath),
|
||||
},
|
||||
});
|
||||
setInstalledPluginIndexInstallRecords({
|
||||
alpha: indexedRecord,
|
||||
});
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
config: {
|
||||
plugins: {
|
||||
installs: nextInstallRecords,
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
changed: true,
|
||||
outcomes: [{ pluginId: "alpha", status: "updated", message: "Updated alpha." }],
|
||||
});
|
||||
|
||||
try {
|
||||
await runPluginsCommand(["plugins", "update", "alpha"]);
|
||||
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
const updateParams = expectSingleCallParams(updateNpmInstalledPlugins);
|
||||
expect(updateParams.config).toEqual({
|
||||
plugins: {
|
||||
installs: {
|
||||
alpha: indexedRecord,
|
||||
legacy: legacyRecord,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
|
||||
nextInstallRecords,
|
||||
);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith({ plugins: {} });
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks combined plugin and hook updates when either config section uses an include", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-"));
|
||||
const configPath = path.join(tempRoot, "openclaw.json5");
|
||||
const pluginsPath = path.join(tempRoot, "plugins.json5");
|
||||
const pluginsRaw = "{}\n";
|
||||
fs.writeFileSync(pluginsPath, pluginsRaw);
|
||||
const cfg = {
|
||||
hooks: {
|
||||
internal: {
|
||||
installs: {
|
||||
"demo-hooks": {
|
||||
source: "npm",
|
||||
spec: "@acme/demo-hooks@1.0.0",
|
||||
installPath: "/tmp/hooks/demo-hooks",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
installs: {
|
||||
alpha: {
|
||||
source: "npm",
|
||||
spec: "@openclaw/alpha@1.0.0",
|
||||
installPath: "/tmp/alpha",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
primeUpdateConfigSnapshot({
|
||||
config: cfg,
|
||||
configPath,
|
||||
parsed: {
|
||||
hooks: {},
|
||||
plugins: { $include: "./plugins.json5" },
|
||||
},
|
||||
includeFileHashesForWrite: {
|
||||
[pluginsPath]: hashConfigIncludeRaw(pluginsRaw),
|
||||
},
|
||||
includeFileTargetsForWrite: {
|
||||
[pluginsPath]: fs.realpathSync(pluginsPath),
|
||||
},
|
||||
});
|
||||
setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
|
||||
|
||||
try {
|
||||
await expect(runPluginsCommand(["plugins", "update", "--all"])).rejects.toThrow("__exit__:1");
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"Config plugins and hooks cannot be updated together while either section uses a top-level $include",
|
||||
);
|
||||
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
|
||||
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("exits when update is called without id and without --all", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
plugins: {
|
||||
@@ -261,29 +1001,55 @@ describe("plugins cli update", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
const runtimeConfig = {
|
||||
...cfg,
|
||||
messages: {
|
||||
ackReactionScope: "group-mentions",
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const nextRuntimeConfig = {
|
||||
...nextConfig,
|
||||
messages: runtimeConfig.messages,
|
||||
} as OpenClawConfig;
|
||||
primeUpdateConfigSnapshot({
|
||||
config: cfg,
|
||||
runtimeConfig,
|
||||
includeFileHashesForWrite: {
|
||||
"/tmp/plugins.json5": "plugins-start-hash",
|
||||
},
|
||||
});
|
||||
setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
outcomes: [{ status: "ok", message: "Updated alpha -> 1.1.0" }],
|
||||
outcomes: [{ pluginId: "alpha", status: "updated", message: "Updated alpha -> 1.1.0" }],
|
||||
changed: true,
|
||||
config: nextConfig,
|
||||
config: nextRuntimeConfig,
|
||||
});
|
||||
updateNpmInstalledHookPacks.mockResolvedValue({
|
||||
outcomes: [],
|
||||
changed: false,
|
||||
config: nextConfig,
|
||||
config: nextRuntimeConfig,
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "update", "alpha"]);
|
||||
|
||||
const updateParams = expectSingleCallParams(updateNpmInstalledPlugins);
|
||||
expect(updateParams.config).toEqual(cfg);
|
||||
expect(updateParams.config).toEqual(runtimeConfig);
|
||||
expect(updateParams.pluginIds).toEqual(["alpha"]);
|
||||
expect(updateParams.dryRun).toBe(false);
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
|
||||
nextConfig.plugins?.installs,
|
||||
);
|
||||
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).toHaveBeenCalledWith({});
|
||||
expect(replaceConfigFile).toHaveBeenCalledWith({
|
||||
nextConfig: {},
|
||||
baseHash: "update-config",
|
||||
writeOptions: expect.objectContaining({
|
||||
includeFileHashesForWrite: {
|
||||
"/tmp/plugins.json5": "plugins-start-hash",
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(refreshPluginRegistry).toHaveBeenCalledWith({
|
||||
config: {},
|
||||
installRecords: nextConfig.plugins?.installs,
|
||||
|
||||
Reference in New Issue
Block a user