mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
fix(update): mandatory post-core plugin convergence before gateway restart
Summary:
- validate active plugin payloads, including openclaw.extensions entry files, after core package updates
- treat corrupt active install records without installPath as convergence failures
- prevent managed gateway recovery restart when post-core plugin convergence fails
Verification:
- CI=true pnpm test src/cli/update-cli/plugin-payload-validation.test.ts src/cli/update-cli/post-core-plugin-convergence.test.ts src/cli/update-cli.test.ts src/commands/doctor/shared/missing-configured-plugin-install.test.ts src/commands/doctor/shared/update-phase.test.ts
- CI=true pnpm check:changed
- PR checks green for 2afa84dffe
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { runPluginPayloadSmokeCheck } from "./plugin-payload-validation.js";
|
||||
|
||||
describe("runPluginPayloadSmokeCheck", () => {
|
||||
let tmpRoot: string;
|
||||
beforeEach(async () => {
|
||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-payload-smoke-"));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writePackage(
|
||||
dir: string,
|
||||
manifest: Record<string, unknown>,
|
||||
mainContent?: string,
|
||||
) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, "package.json"), JSON.stringify(manifest), "utf8");
|
||||
const main = typeof manifest.main === "string" ? manifest.main : "index.js";
|
||||
if (mainContent !== undefined) {
|
||||
const target = path.join(dir, main);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(target, mainContent, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
it("reports ok for a record whose package.json + main file exist", async () => {
|
||||
const dir = path.join(tmpRoot, "discord");
|
||||
await writePackage(
|
||||
dir,
|
||||
{ name: "@openclaw/discord", main: "dist/index.js" },
|
||||
"module.exports = {};",
|
||||
);
|
||||
const result = await runPluginPayloadSmokeCheck({
|
||||
records: { discord: { source: "npm", installPath: dir } },
|
||||
env: {},
|
||||
});
|
||||
expect(result.failures).toEqual([]);
|
||||
expect(result.checked).toEqual(["discord"]);
|
||||
});
|
||||
|
||||
it("reports a failure when the package directory is missing", async () => {
|
||||
const dir = path.join(tmpRoot, "brave");
|
||||
const result = await runPluginPayloadSmokeCheck({
|
||||
records: { brave: { source: "npm", installPath: dir } },
|
||||
env: {},
|
||||
});
|
||||
expect(result.failures).toEqual([
|
||||
{
|
||||
pluginId: "brave",
|
||||
installPath: dir,
|
||||
reason: "missing-package-dir",
|
||||
detail: expect.stringContaining(dir),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a failure when the package.json is missing", async () => {
|
||||
const dir = path.join(tmpRoot, "brave");
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const result = await runPluginPayloadSmokeCheck({
|
||||
records: { brave: { source: "npm", installPath: dir } },
|
||||
env: {},
|
||||
});
|
||||
expect(result.failures).toEqual([
|
||||
{
|
||||
pluginId: "brave",
|
||||
installPath: dir,
|
||||
reason: "missing-package-json",
|
||||
detail: expect.stringContaining("package.json"),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a failure when the main entry file is missing on disk", async () => {
|
||||
const dir = path.join(tmpRoot, "brave");
|
||||
await writePackage(dir, { name: "@openclaw/brave", main: "dist/index.js" });
|
||||
const result = await runPluginPayloadSmokeCheck({
|
||||
records: { brave: { source: "npm", installPath: dir } },
|
||||
env: {},
|
||||
});
|
||||
expect(result.failures).toHaveLength(1);
|
||||
expect(result.failures[0]).toMatchObject({
|
||||
pluginId: "brave",
|
||||
reason: "missing-main-entry",
|
||||
});
|
||||
expect(result.failures[0]?.detail).toContain("dist/index.js");
|
||||
});
|
||||
|
||||
it("accepts a manifest with no main field (OpenClaw plugins commonly use `exports` or `openclaw.extensions`)", async () => {
|
||||
const dir = path.join(tmpRoot, "matrix");
|
||||
await writePackage(dir, { name: "@openclaw/plugin-matrix" });
|
||||
const result = await runPluginPayloadSmokeCheck({
|
||||
records: { matrix: { source: "npm", installPath: dir } },
|
||||
env: {},
|
||||
});
|
||||
expect(result.failures).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts a manifest that declares only `exports` and no `main`", async () => {
|
||||
const dir = path.join(tmpRoot, "qa");
|
||||
await writePackage(dir, {
|
||||
name: "@openclaw/qa-channel",
|
||||
exports: { ".": "./index.js", "./api.js": "./api.js" },
|
||||
});
|
||||
const result = await runPluginPayloadSmokeCheck({
|
||||
records: { qa: { source: "npm", installPath: dir } },
|
||||
env: {},
|
||||
});
|
||||
expect(result.failures).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts a manifest that declares an existing `openclaw.extensions` entry and no `main`", async () => {
|
||||
const dir = path.join(tmpRoot, "brave");
|
||||
await writePackage(dir, {
|
||||
name: "@openclaw/brave-plugin",
|
||||
openclaw: { extensions: ["./index.js"] },
|
||||
});
|
||||
await fs.writeFile(path.join(dir, "index.js"), "export default {};\n", "utf8");
|
||||
const result = await runPluginPayloadSmokeCheck({
|
||||
records: { brave: { source: "npm", installPath: dir } },
|
||||
env: {},
|
||||
});
|
||||
expect(result.failures).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a failure when an `openclaw.extensions` entry file is missing", async () => {
|
||||
const dir = path.join(tmpRoot, "brave");
|
||||
await writePackage(dir, {
|
||||
name: "@openclaw/brave-plugin",
|
||||
openclaw: { extensions: ["./dist/index.js"] },
|
||||
});
|
||||
const result = await runPluginPayloadSmokeCheck({
|
||||
records: { brave: { source: "npm", installPath: dir } },
|
||||
env: {},
|
||||
});
|
||||
expect(result.failures).toHaveLength(1);
|
||||
expect(result.failures[0]).toMatchObject({
|
||||
pluginId: "brave",
|
||||
reason: "missing-extension-entry",
|
||||
});
|
||||
expect(result.failures[0]?.detail).toContain("./dist/index.js");
|
||||
});
|
||||
|
||||
it("reports a failure when `main` resolves to a directory rather than a file", async () => {
|
||||
const dir = path.join(tmpRoot, "dir-main");
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(dir, "package.json"),
|
||||
JSON.stringify({ name: "dir-main", main: "lib" }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.mkdir(path.join(dir, "lib"), { recursive: true });
|
||||
const result = await runPluginPayloadSmokeCheck({
|
||||
records: { x: { source: "npm", installPath: dir } },
|
||||
env: {},
|
||||
});
|
||||
expect(result.failures).toHaveLength(1);
|
||||
expect(result.failures[0]).toMatchObject({ pluginId: "x", reason: "missing-main-entry" });
|
||||
});
|
||||
|
||||
it("reports a failure when `main` is a symlink whose target is missing", async () => {
|
||||
const dir = path.join(tmpRoot, "broken-symlink");
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(dir, "package.json"),
|
||||
JSON.stringify({ name: "broken-symlink", main: "dist/entry.js" }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.mkdir(path.join(dir, "dist"), { recursive: true });
|
||||
await fs.symlink(
|
||||
path.join(dir, "dist", "missing-target.js"),
|
||||
path.join(dir, "dist", "entry.js"),
|
||||
);
|
||||
const result = await runPluginPayloadSmokeCheck({
|
||||
records: { x: { source: "npm", installPath: dir } },
|
||||
env: {},
|
||||
});
|
||||
expect(result.failures).toHaveLength(1);
|
||||
expect(result.failures[0]).toMatchObject({
|
||||
pluginId: "x",
|
||||
reason: "missing-main-entry",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a failure when package.json cannot be parsed", async () => {
|
||||
const dir = path.join(tmpRoot, "broken");
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, "package.json"), "not-json", "utf8");
|
||||
const result = await runPluginPayloadSmokeCheck({
|
||||
records: { broken: { source: "npm", installPath: dir } },
|
||||
env: {},
|
||||
});
|
||||
expect(result.failures).toHaveLength(1);
|
||||
expect(result.failures[0]).toMatchObject({
|
||||
pluginId: "broken",
|
||||
reason: "invalid-package-json",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a failure when an install record is missing installPath", async () => {
|
||||
const result = await runPluginPayloadSmokeCheck({
|
||||
records: {
|
||||
discord: { source: "npm" } as unknown as { source: "npm"; installPath?: string },
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
expect(result.checked).toEqual(["discord"]);
|
||||
expect(result.failures).toEqual([
|
||||
{
|
||||
pluginId: "discord",
|
||||
reason: "missing-install-path",
|
||||
detail: "Install path is missing from the plugin install record.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("only checks records whose source is package-tracked (npm/clawhub/git/marketplace)", async () => {
|
||||
const dir = path.join(tmpRoot, "tracked");
|
||||
await writePackage(dir, { name: "tracked" }, "module.exports = {};");
|
||||
const records = {
|
||||
bundled: { source: "bundled", installPath: dir } as never,
|
||||
npm: { source: "npm" as const, installPath: dir },
|
||||
};
|
||||
const result = await runPluginPayloadSmokeCheck({
|
||||
records,
|
||||
env: {},
|
||||
});
|
||||
expect(result.checked).toEqual(["npm"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { PluginInstallRecord } from "../../config/types.plugins.js";
|
||||
import { resolvePackageExtensionEntries, type PackageManifest } from "../../plugins/manifest.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
|
||||
export type PluginPayloadSmokeFailureReason =
|
||||
| "missing-install-path"
|
||||
| "missing-package-dir"
|
||||
| "missing-package-json"
|
||||
| "invalid-package-json"
|
||||
| "missing-main-entry"
|
||||
| "missing-extension-entry";
|
||||
|
||||
export type PluginPayloadSmokeFailure = {
|
||||
pluginId: string;
|
||||
installPath?: string;
|
||||
reason: PluginPayloadSmokeFailureReason;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type PluginPayloadSmokeResult = {
|
||||
checked: string[];
|
||||
failures: PluginPayloadSmokeFailure[];
|
||||
};
|
||||
|
||||
const TRACKED_SOURCES: ReadonlySet<string> = new Set(["npm", "clawhub", "git", "marketplace"]);
|
||||
|
||||
/**
|
||||
* Verify that each tracked plugin install record on disk is structurally
|
||||
* loadable: the install dir exists, contains a parseable `package.json`,
|
||||
* and any declared package entry files exist.
|
||||
*
|
||||
* IMPORTANT: this is intentionally a *static* check. We do NOT execute the
|
||||
* plugin's code, so post-update side effects (network calls, filesystem
|
||||
* writes, registry registration) cannot fire while the gateway is still
|
||||
* stopped. The goal is to catch obvious payload corruption — missing files,
|
||||
* unparseable manifests — before we hand control back to the restart path.
|
||||
*/
|
||||
export async function runPluginPayloadSmokeCheck(params: {
|
||||
records: Record<string, PluginInstallRecord>;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<PluginPayloadSmokeResult> {
|
||||
const checked: string[] = [];
|
||||
const failures: PluginPayloadSmokeFailure[] = [];
|
||||
|
||||
for (const [pluginId, record] of Object.entries(params.records).toSorted(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
)) {
|
||||
if (!record || typeof record !== "object" || !TRACKED_SOURCES.has(record.source)) {
|
||||
continue;
|
||||
}
|
||||
const rawInstallPath = typeof record.installPath === "string" ? record.installPath.trim() : "";
|
||||
if (!rawInstallPath) {
|
||||
checked.push(pluginId);
|
||||
failures.push({
|
||||
pluginId,
|
||||
reason: "missing-install-path",
|
||||
detail: "Install path is missing from the plugin install record.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const installPath = resolveUserPath(rawInstallPath, params.env);
|
||||
checked.push(pluginId);
|
||||
|
||||
const dirStat = await safeStat(installPath);
|
||||
if (!dirStat?.isDirectory()) {
|
||||
failures.push({
|
||||
pluginId,
|
||||
installPath,
|
||||
reason: "missing-package-dir",
|
||||
detail: `Install dir is missing: ${installPath}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const packageJsonPath = path.join(installPath, "package.json");
|
||||
const packageJsonStat = await safeStat(packageJsonPath);
|
||||
if (!packageJsonStat?.isFile()) {
|
||||
failures.push({
|
||||
pluginId,
|
||||
installPath,
|
||||
reason: "missing-package-json",
|
||||
detail: `package.json is missing under ${installPath}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let manifest: PackageManifest & { main?: unknown; exports?: unknown };
|
||||
try {
|
||||
manifest = JSON.parse(await fs.readFile(packageJsonPath, "utf8")) as typeof manifest;
|
||||
} catch (err) {
|
||||
failures.push({
|
||||
pluginId,
|
||||
installPath,
|
||||
reason: "invalid-package-json",
|
||||
detail: `Could not parse package.json: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const extensionResolution = resolvePackageExtensionEntries(manifest);
|
||||
if (extensionResolution.status === "ok") {
|
||||
for (const extensionRel of extensionResolution.entries) {
|
||||
const extensionPath = path.join(installPath, extensionRel);
|
||||
const extensionStat = await safeStat(extensionPath);
|
||||
if (!extensionStat?.isFile()) {
|
||||
failures.push({
|
||||
pluginId,
|
||||
installPath,
|
||||
reason: "missing-extension-entry",
|
||||
detail: `Plugin extension entry "${extensionRel}" not found at ${extensionPath}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only fail on `missing-main-entry` when `main` is *explicitly declared*
|
||||
// and absent on disk. Fully resolving `exports` conditional sub-keys is
|
||||
// out of scope for a static smoke check, so packages with only `exports`
|
||||
// remain intentionally permissive.
|
||||
if (typeof manifest.main !== "string" || !manifest.main.trim()) {
|
||||
continue;
|
||||
}
|
||||
const mainRel = manifest.main.trim();
|
||||
const mainPath = path.join(installPath, mainRel);
|
||||
const mainStat = await safeStat(mainPath);
|
||||
if (!mainStat?.isFile()) {
|
||||
failures.push({
|
||||
pluginId,
|
||||
installPath,
|
||||
reason: "missing-main-entry",
|
||||
detail: `Plugin main entry "${mainRel}" not found at ${mainPath}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { checked, failures };
|
||||
}
|
||||
|
||||
async function safeStat(target: string): Promise<import("node:fs").Stats | null> {
|
||||
try {
|
||||
return await fs.stat(target);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
repairMissingConfiguredPluginInstalls: vi.fn(),
|
||||
runPluginPayloadSmokeCheck: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/doctor/shared/missing-configured-plugin-install.js", () => ({
|
||||
repairMissingConfiguredPluginInstalls: mocks.repairMissingConfiguredPluginInstalls,
|
||||
}));
|
||||
vi.mock("./plugin-payload-validation.js", () => ({
|
||||
runPluginPayloadSmokeCheck: mocks.runPluginPayloadSmokeCheck,
|
||||
}));
|
||||
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import {
|
||||
convergenceWarningsToOutcomes,
|
||||
filterRecordsToActive,
|
||||
runPostCorePluginConvergence,
|
||||
} from "./post-core-plugin-convergence.js";
|
||||
|
||||
describe("runPostCorePluginConvergence", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
records: {},
|
||||
});
|
||||
mocks.runPluginPayloadSmokeCheck.mockResolvedValue({ checked: [], failures: [] });
|
||||
});
|
||||
|
||||
it("calls repair with OPENCLAW_UPDATE_POST_CORE_CONVERGENCE=1 set", async () => {
|
||||
await runPostCorePluginConvergence({
|
||||
cfg: { plugins: { entries: {} } } as unknown as OpenClawConfig,
|
||||
env: { OPENCLAW_UPDATE_IN_PROGRESS: "1" },
|
||||
});
|
||||
expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
OPENCLAW_UPDATE_IN_PROGRESS: "1",
|
||||
OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns ok when no warnings/failures and includes repair changes", async () => {
|
||||
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
||||
changes: ['Repaired missing configured plugin "discord".'],
|
||||
warnings: [],
|
||||
records: { discord: { source: "npm", installPath: "/p/discord" } },
|
||||
});
|
||||
const result = await runPostCorePluginConvergence({
|
||||
cfg: {
|
||||
plugins: { entries: { discord: { enabled: true } } },
|
||||
} as unknown as OpenClawConfig,
|
||||
env: {},
|
||||
});
|
||||
expect(result.errored).toBe(false);
|
||||
expect(result.changes).toEqual(['Repaired missing configured plugin "discord".']);
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns the post-repair install records so callers can re-seed pluginConfig", async () => {
|
||||
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
||||
changes: ["Repaired"],
|
||||
warnings: [],
|
||||
records: { discord: { source: "npm", installPath: "/p/discord" } },
|
||||
});
|
||||
const result = await runPostCorePluginConvergence({
|
||||
cfg: { plugins: { entries: { discord: { enabled: true } } } } as unknown as OpenClawConfig,
|
||||
env: {},
|
||||
});
|
||||
expect(result.installRecords).toEqual({
|
||||
discord: { source: "npm", installPath: "/p/discord" },
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards baselineInstallRecords to repair so sync/npm in-memory mutations are preserved", async () => {
|
||||
const baseline = { matrix: { source: "npm" as const, installPath: "/p/matrix" } };
|
||||
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
records: baseline,
|
||||
});
|
||||
await runPostCorePluginConvergence({
|
||||
cfg: {
|
||||
plugins: { entries: { matrix: { enabled: true } } },
|
||||
} as unknown as OpenClawConfig,
|
||||
env: {},
|
||||
baselineInstallRecords: baseline,
|
||||
});
|
||||
expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baselineRecords: baseline }),
|
||||
);
|
||||
});
|
||||
|
||||
it("flags errored=true and surfaces actionable guidance when repair warns", async () => {
|
||||
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
||||
changes: [],
|
||||
warnings: [
|
||||
'Failed to install missing configured plugin "discord" from @openclaw/discord: ENETUNREACH.',
|
||||
],
|
||||
records: {},
|
||||
});
|
||||
const result = await runPostCorePluginConvergence({
|
||||
cfg: {
|
||||
plugins: { entries: { discord: { enabled: true } } },
|
||||
} as unknown as OpenClawConfig,
|
||||
env: {},
|
||||
});
|
||||
expect(result.errored).toBe(true);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0]).toMatchObject({
|
||||
reason: expect.stringContaining("discord"),
|
||||
guidance: expect.arrayContaining([expect.stringContaining("openclaw doctor --fix")]),
|
||||
});
|
||||
});
|
||||
|
||||
it("flags errored=true when smoke check finds a missing main entry", async () => {
|
||||
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
records: { brave: { source: "npm", installPath: "/p/brave" } },
|
||||
});
|
||||
mocks.runPluginPayloadSmokeCheck.mockResolvedValue({
|
||||
checked: ["brave"],
|
||||
failures: [
|
||||
{
|
||||
pluginId: "brave",
|
||||
installPath: "/p/brave",
|
||||
reason: "missing-main-entry",
|
||||
detail: 'Plugin main entry "dist/index.js" not found at /p/brave/dist/index.js',
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await runPostCorePluginConvergence({
|
||||
cfg: {
|
||||
plugins: { entries: { brave: { enabled: true } } },
|
||||
} as unknown as OpenClawConfig,
|
||||
env: {},
|
||||
});
|
||||
expect(result.errored).toBe(true);
|
||||
expect(result.warnings).toEqual([
|
||||
expect.objectContaining({
|
||||
pluginId: "brave",
|
||||
reason: expect.stringContaining("missing-main-entry"),
|
||||
guidance: expect.arrayContaining([
|
||||
expect.stringContaining("openclaw plugins inspect brave"),
|
||||
]),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags errored=true when smoke check finds a missing install path", async () => {
|
||||
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
records: { brave: { source: "npm" } },
|
||||
});
|
||||
mocks.runPluginPayloadSmokeCheck.mockResolvedValue({
|
||||
checked: ["brave"],
|
||||
failures: [
|
||||
{
|
||||
pluginId: "brave",
|
||||
reason: "missing-install-path",
|
||||
detail: "Install path is missing from the plugin install record.",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await runPostCorePluginConvergence({
|
||||
cfg: {
|
||||
plugins: { entries: { brave: { enabled: true } } },
|
||||
} as unknown as OpenClawConfig,
|
||||
env: {},
|
||||
});
|
||||
expect(result.errored).toBe(true);
|
||||
expect(result.warnings[0]).toMatchObject({
|
||||
pluginId: "brave",
|
||||
reason: expect.stringContaining("missing-install-path"),
|
||||
});
|
||||
});
|
||||
|
||||
it("hands repair's post-mutation records straight to the smoke check (no second disk read)", async () => {
|
||||
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
||||
changes: ["Repaired"],
|
||||
warnings: [],
|
||||
records: { brave: { source: "npm", installPath: "/p/brave" } },
|
||||
});
|
||||
await runPostCorePluginConvergence({
|
||||
cfg: {
|
||||
plugins: { entries: { brave: { enabled: true } } },
|
||||
} as unknown as OpenClawConfig,
|
||||
env: {},
|
||||
});
|
||||
expect(mocks.runPluginPayloadSmokeCheck).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
records: { brave: { source: "npm", installPath: "/p/brave" } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convergenceWarningsToOutcomes", () => {
|
||||
it("emits per-plugin error outcomes for warnings that name a pluginId", () => {
|
||||
const folded = convergenceWarningsToOutcomes({
|
||||
changes: [],
|
||||
warnings: [
|
||||
{
|
||||
pluginId: "brave",
|
||||
reason: "missing-main-entry: …",
|
||||
message: 'Plugin "brave" failed payload smoke check.',
|
||||
guidance: ["Run `openclaw doctor --fix`."],
|
||||
},
|
||||
{
|
||||
reason: "Failed install",
|
||||
message: "Failed install for some plugin.",
|
||||
guidance: ["Run `openclaw doctor --fix`."],
|
||||
},
|
||||
],
|
||||
errored: true,
|
||||
smokeFailures: [],
|
||||
installRecords: {},
|
||||
});
|
||||
expect(folded.errored).toBe(true);
|
||||
expect(folded.outcomes).toEqual([
|
||||
{ pluginId: "brave", status: "error", message: 'Plugin "brave" failed payload smoke check.' },
|
||||
]);
|
||||
expect(folded.warnings).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns errored=false and no outcomes for a clean convergence", () => {
|
||||
const folded = convergenceWarningsToOutcomes({
|
||||
changes: ["Repaired."],
|
||||
warnings: [],
|
||||
errored: false,
|
||||
smokeFailures: [],
|
||||
installRecords: {},
|
||||
});
|
||||
expect(folded).toEqual({ warnings: [], outcomes: [], errored: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterRecordsToActive", () => {
|
||||
it("retains records for plugins whose entry is enabled", () => {
|
||||
const records = {
|
||||
enabled: { source: "npm" as const, installPath: "/p/enabled" },
|
||||
};
|
||||
const filtered = filterRecordsToActive({
|
||||
cfg: {
|
||||
plugins: { enabled: true, entries: { enabled: { enabled: true } } },
|
||||
} as unknown as OpenClawConfig,
|
||||
records,
|
||||
});
|
||||
expect(filtered).toEqual(records);
|
||||
});
|
||||
|
||||
it("drops records for plugins whose entry is explicitly disabled", () => {
|
||||
const records = {
|
||||
"stale-disabled": { source: "npm" as const, installPath: "/p/stale" },
|
||||
"active-plugin": { source: "npm" as const, installPath: "/p/active" },
|
||||
};
|
||||
const filtered = filterRecordsToActive({
|
||||
cfg: {
|
||||
plugins: {
|
||||
enabled: true,
|
||||
entries: {
|
||||
"stale-disabled": { enabled: false },
|
||||
"active-plugin": { enabled: true },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
records,
|
||||
});
|
||||
expect(filtered).toEqual({
|
||||
"active-plugin": { source: "npm", installPath: "/p/active" },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops records for plugins listed in plugins.deny", () => {
|
||||
const records = {
|
||||
denied: { source: "npm" as const, installPath: "/p/denied" },
|
||||
};
|
||||
const filtered = filterRecordsToActive({
|
||||
cfg: {
|
||||
plugins: {
|
||||
enabled: true,
|
||||
deny: ["denied"],
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
records,
|
||||
});
|
||||
expect(filtered).toEqual({});
|
||||
});
|
||||
|
||||
it("retains a disabled trusted-source-linked official npm install (mirroring syncOfficialPluginInstalls policy)", () => {
|
||||
// The Codex install record carries the trusted-source marker. The
|
||||
// existing post-update sync path treats it as authoritative regardless
|
||||
// of the entry's enable flag, so the convergence smoke check must too.
|
||||
const records = {
|
||||
codex: {
|
||||
source: "npm" as const,
|
||||
spec: "@openclaw/codex",
|
||||
installPath: "/p/codex",
|
||||
trustedSourceLinkedOfficial: true,
|
||||
},
|
||||
};
|
||||
const filtered = filterRecordsToActive({
|
||||
cfg: {
|
||||
plugins: {
|
||||
enabled: true,
|
||||
entries: { codex: { enabled: false } },
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
records,
|
||||
});
|
||||
expect(filtered).toEqual(records);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { repairMissingConfiguredPluginInstalls } from "../../commands/doctor/shared/missing-configured-plugin-install.js";
|
||||
import { UPDATE_POST_CORE_CONVERGENCE_ENV } from "../../commands/doctor/shared/update-phase.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../../config/types.plugins.js";
|
||||
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js";
|
||||
import {
|
||||
resolveTrustedSourceLinkedOfficialClawHubSpec,
|
||||
resolveTrustedSourceLinkedOfficialNpmSpec,
|
||||
} from "../../plugins/update.js";
|
||||
import {
|
||||
runPluginPayloadSmokeCheck,
|
||||
type PluginPayloadSmokeFailure,
|
||||
} from "./plugin-payload-validation.js";
|
||||
|
||||
export type PostCoreConvergenceWarning = {
|
||||
pluginId?: string;
|
||||
reason: string;
|
||||
message: string;
|
||||
guidance: string[];
|
||||
};
|
||||
|
||||
export type PostCoreConvergenceResult = {
|
||||
changes: string[];
|
||||
warnings: PostCoreConvergenceWarning[];
|
||||
errored: boolean;
|
||||
smokeFailures: PluginPayloadSmokeFailure[];
|
||||
/**
|
||||
* Final install-record map after convergence: this is the
|
||||
* `baselineInstallRecords` the caller passed in (their in-memory state
|
||||
* including any sync/npm mutations that happened earlier in the
|
||||
* post-core flow) WITH convergence's repair mutations layered on top.
|
||||
* Convergence has already persisted this map to the installed-plugin
|
||||
* index, so the caller's subsequent commit MUST seed its write from
|
||||
* these records — otherwise the stale pre-convergence snapshot will
|
||||
* overwrite both the sync/npm mutations AND the fresh repairs.
|
||||
*/
|
||||
installRecords: Record<string, PluginInstallRecord>;
|
||||
};
|
||||
|
||||
const REPAIR_GUIDANCE = "Run `openclaw doctor --fix` to retry plugin repair.";
|
||||
const inspectGuidance = (pluginId: string) =>
|
||||
`Run \`openclaw plugins inspect ${pluginId} --runtime --json\` for details.`;
|
||||
|
||||
/**
|
||||
* Mandatory post-core convergence pass. Runs AFTER the core package files
|
||||
* are swapped and the in-update doctor pass has already returned, but BEFORE
|
||||
* the gateway is restarted. Failures here must block the restart so we
|
||||
* never restart with a configured plugin whose payload is unloadable.
|
||||
*/
|
||||
export async function runPostCorePluginConvergence(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
/**
|
||||
* Optional in-memory install records from earlier post-core steps (e.g.
|
||||
* `syncPluginsForUpdateChannel`, `updateNpmInstalledPlugins`) whose
|
||||
* mutations have not been persisted to the installed-plugin index yet.
|
||||
* When provided, repair layers its mutations on top of these records
|
||||
* instead of reading the stale pre-update disk snapshot, and the merged
|
||||
* map is what gets persisted and returned via `installRecords`.
|
||||
*/
|
||||
baselineInstallRecords?: Record<string, PluginInstallRecord>;
|
||||
}): Promise<PostCoreConvergenceResult> {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...params.env,
|
||||
[UPDATE_POST_CORE_CONVERGENCE_ENV]: "1",
|
||||
};
|
||||
|
||||
const repair = await repairMissingConfiguredPluginInstalls({
|
||||
cfg: params.cfg,
|
||||
env,
|
||||
...(params.baselineInstallRecords ? { baselineRecords: params.baselineInstallRecords } : {}),
|
||||
});
|
||||
|
||||
const warnings: PostCoreConvergenceWarning[] = repair.warnings.map((message) => ({
|
||||
reason: message,
|
||||
message,
|
||||
guidance: [REPAIR_GUIDANCE],
|
||||
}));
|
||||
|
||||
const records: Record<string, PluginInstallRecord> = repair.records;
|
||||
// Filter the smoke-check input to active records ONLY: configured /
|
||||
// enabled plugins, plus trusted-source-linked official sync targets
|
||||
// (mirroring the existing `collectMissingPluginInstallPayloads` policy
|
||||
// at update-command.ts:~218 with `skipDisabledPlugins: true`). Without
|
||||
// this filter, a stale install record for a disabled or no-longer-
|
||||
// configured plugin whose payload was deleted on disk would block the
|
||||
// entire update — even though the gateway will never load that plugin.
|
||||
const smokeRecords = filterRecordsToActive({ cfg: params.cfg, records });
|
||||
const smoke = await runPluginPayloadSmokeCheck({ records: smokeRecords, env });
|
||||
for (const failure of smoke.failures) {
|
||||
warnings.push({
|
||||
pluginId: failure.pluginId,
|
||||
reason: `${failure.reason}: ${failure.detail}`,
|
||||
message: `Plugin "${failure.pluginId}" failed post-core payload smoke check (${failure.reason}): ${failure.detail}`,
|
||||
guidance: [REPAIR_GUIDANCE, inspectGuidance(failure.pluginId)],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
changes: repair.changes,
|
||||
warnings,
|
||||
errored: warnings.length > 0,
|
||||
smokeFailures: smoke.failures,
|
||||
installRecords: records,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop install records that the gateway would never activate: disabled
|
||||
* plugin entries, plugins listed in `plugins.deny`, etc. Records that
|
||||
* resolve as a trusted-source-linked official install (npm or ClawHub)
|
||||
* are retained even when the entry is disabled, mirroring the existing
|
||||
* `collectMissingPluginInstallPayloads({ skipDisabledPlugins: true,
|
||||
* syncOfficialPluginInstalls: true })` policy at
|
||||
* `update-command.ts:~218`. We do NOT collapse to the configured plugin
|
||||
* id set here — that would over-filter and miss e.g. providers/runtimes
|
||||
* that are enabled implicitly via auth profiles or model refs. Effective
|
||||
* enable state is the right precision boundary.
|
||||
*/
|
||||
export function filterRecordsToActive(params: {
|
||||
cfg: OpenClawConfig;
|
||||
records: Record<string, PluginInstallRecord>;
|
||||
}): Record<string, PluginInstallRecord> {
|
||||
const normalizedPluginConfig = normalizePluginsConfig(params.cfg.plugins);
|
||||
const filtered: Record<string, PluginInstallRecord> = {};
|
||||
for (const [pluginId, record] of Object.entries(params.records)) {
|
||||
if (!record || typeof record !== "object") {
|
||||
continue;
|
||||
}
|
||||
const enableState = resolveEffectiveEnableState({
|
||||
id: pluginId,
|
||||
origin: "global",
|
||||
config: normalizedPluginConfig,
|
||||
rootConfig: params.cfg,
|
||||
});
|
||||
if (enableState.enabled) {
|
||||
filtered[pluginId] = record;
|
||||
continue;
|
||||
}
|
||||
// Even when disabled, retain trusted-source-linked official installs
|
||||
// because the existing post-update sync path treats them as
|
||||
// authoritative regardless of the entry's enable flag.
|
||||
const officialNpm = resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record });
|
||||
const officialClawHub = resolveTrustedSourceLinkedOfficialClawHubSpec({ pluginId, record });
|
||||
if (officialNpm || officialClawHub) {
|
||||
filtered[pluginId] = record;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure helper used by `updatePluginsAfterCoreUpdate` to fold a convergence
|
||||
* result into the existing `PluginUpdateOutcome[]` / warning shape that the
|
||||
* post-core update result carries.
|
||||
*
|
||||
* Returns:
|
||||
* - `outcomes` to append to `pluginUpdateOutcomes`. Only convergence
|
||||
* warnings that name a `pluginId` produce per-plugin error outcomes; the
|
||||
* rest are surfaced via `warnings`.
|
||||
* - `errored` boolean that callers translate into `status: "error"`.
|
||||
*/
|
||||
export function convergenceWarningsToOutcomes(convergence: PostCoreConvergenceResult): {
|
||||
warnings: PostCoreConvergenceWarning[];
|
||||
outcomes: Array<{ pluginId: string; status: "error"; message: string }>;
|
||||
errored: boolean;
|
||||
} {
|
||||
const outcomes = convergence.warnings
|
||||
.filter((w): w is PostCoreConvergenceWarning & { pluginId: string } => Boolean(w.pluginId))
|
||||
.map((w) => ({ pluginId: w.pluginId, status: "error" as const, message: w.message }));
|
||||
return {
|
||||
warnings: convergence.warnings,
|
||||
outcomes,
|
||||
errored: convergence.errored,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
resolveGatewayInstallEntrypoint,
|
||||
} from "../../daemon/gateway-entrypoint.js";
|
||||
import {
|
||||
buildInvalidConfigPostCoreUpdateResult,
|
||||
collectMissingPluginInstallPayloads,
|
||||
recoverInstalledLaunchAgentAfterUpdate,
|
||||
recoverLaunchAgentAndRecheckGatewayHealth,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
shouldPrepareUpdatedInstallRestart,
|
||||
resolveUpdatedGatewayRestartPort,
|
||||
shouldUseLegacyProcessRestartAfterUpdate,
|
||||
updatePluginsAfterCoreUpdate,
|
||||
} from "./update-command.js";
|
||||
|
||||
describe("resolveGatewayInstallEntrypointCandidates", () => {
|
||||
@@ -558,3 +560,62 @@ describe("resolvePostCoreUpdateChildStdio", () => {
|
||||
expect(resolvePostCoreUpdateChildStdio("darwin")).toBe("inherit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updatePluginsAfterCoreUpdate (invalid config end-to-end)", () => {
|
||||
it("returns status:error (not skipped) when configSnapshot is invalid, so the pre-restart gate fires", async () => {
|
||||
// The pre-restart gate in `updateCommand` is literally
|
||||
// if (postCorePluginUpdate?.status === "error") { exit(1) }
|
||||
// so asserting that this function returns status:"error" on invalid
|
||||
// config is sufficient to prove the gate fires end-to-end. We pass
|
||||
// `json: true` to suppress logging side-effects without mocking.
|
||||
const result = await updatePluginsAfterCoreUpdate({
|
||||
root: "/tmp/openclaw-test",
|
||||
channel: "stable",
|
||||
configSnapshot: {
|
||||
valid: false,
|
||||
issues: [],
|
||||
legacyIssues: [],
|
||||
} as unknown as Awaited<
|
||||
ReturnType<typeof import("../../config/io.js").readConfigFileSnapshot>
|
||||
>,
|
||||
opts: { json: true } as never,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.reason).toBe("invalid-config");
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.warnings).toEqual([
|
||||
expect.objectContaining({
|
||||
reason: "invalid-config",
|
||||
guidance: expect.arrayContaining([expect.stringContaining("openclaw doctor")]),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildInvalidConfigPostCoreUpdateResult", () => {
|
||||
it("returns status:error so the existing pre-restart gate exits 1 instead of restarting on invalid config", () => {
|
||||
const built = buildInvalidConfigPostCoreUpdateResult();
|
||||
expect(built.result.status).toBe("error");
|
||||
expect(built.result.reason).toBe("invalid-config");
|
||||
expect(built.result.changed).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces actionable repair guidance in both the structural warnings and the message string", () => {
|
||||
const built = buildInvalidConfigPostCoreUpdateResult();
|
||||
expect(built.guidance).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("openclaw doctor"),
|
||||
expect.stringContaining("openclaw update"),
|
||||
]),
|
||||
);
|
||||
expect(built.result.warnings).toEqual([
|
||||
{
|
||||
reason: "invalid-config",
|
||||
message: built.message,
|
||||
guidance: built.guidance,
|
||||
},
|
||||
]);
|
||||
expect(built.message).toMatch(/refusing to restart/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,6 +89,10 @@ import {
|
||||
import { commitPluginInstallRecordsWithConfig } from "../plugins-install-record-commit.js";
|
||||
import { listPersistedBundledPluginLocationBridges } from "../plugins-location-bridges.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "../plugins-registry-refresh.js";
|
||||
import {
|
||||
convergenceWarningsToOutcomes,
|
||||
runPostCorePluginConvergence,
|
||||
} from "./post-core-plugin-convergence.js";
|
||||
import { createUpdateProgress, printResult } from "./progress.js";
|
||||
import { prepareRestartScript, runRestartScript } from "./restart-helper.js";
|
||||
import {
|
||||
@@ -316,6 +320,51 @@ function isDisabledAfterFailureOutcome(outcome: PluginUpdateOutcome): boolean {
|
||||
return outcome.status === "skipped" && outcome.message.includes("after plugin update failure");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the post-core-update result we return when the active config cannot
|
||||
* even be parsed. Mandatory post-core convergence requires a parseable
|
||||
* config to know which plugins are configured; if one isn't available, we
|
||||
* refuse to restart the gateway and surface this as a hard error so the
|
||||
* existing `status === "error"` ⇒ `exit 1` pre-restart gate fires.
|
||||
*
|
||||
* Exported for unit testing without having to drive the entire
|
||||
* `updatePluginsAfterCoreUpdate` orchestrator.
|
||||
*/
|
||||
export function buildInvalidConfigPostCoreUpdateResult(): {
|
||||
message: string;
|
||||
guidance: string[];
|
||||
result: PostCorePluginUpdateResult;
|
||||
} {
|
||||
const guidance = [
|
||||
"Run `openclaw doctor` to inspect the config validation errors.",
|
||||
"Once the config parses, rerun `openclaw update`.",
|
||||
];
|
||||
const message =
|
||||
"Plugin post-update convergence skipped because the config is invalid; refusing to restart the gateway with an unverified plugin set.";
|
||||
return {
|
||||
message,
|
||||
guidance,
|
||||
result: {
|
||||
status: "error",
|
||||
reason: "invalid-config",
|
||||
changed: false,
|
||||
sync: {
|
||||
changed: false,
|
||||
switchedToBundled: [],
|
||||
switchedToNpm: [],
|
||||
warnings: [],
|
||||
errors: [],
|
||||
},
|
||||
npm: {
|
||||
changed: false,
|
||||
outcomes: [],
|
||||
},
|
||||
integrityDrifts: [],
|
||||
warnings: [{ reason: "invalid-config", message, guidance }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldPrepareUpdatedInstallRestart(params: {
|
||||
updateMode: UpdateRunResult["mode"];
|
||||
serviceInstalled: boolean;
|
||||
@@ -1103,7 +1152,7 @@ async function runGitUpdate(params: {
|
||||
};
|
||||
}
|
||||
|
||||
async function updatePluginsAfterCoreUpdate(params: {
|
||||
export async function updatePluginsAfterCoreUpdate(params: {
|
||||
root: string;
|
||||
channel: "stable" | "beta" | "dev";
|
||||
configSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
|
||||
@@ -1112,27 +1161,14 @@ async function updatePluginsAfterCoreUpdate(params: {
|
||||
pluginInstallRecords?: Record<string, PluginInstallRecord>;
|
||||
}): Promise<PostCorePluginUpdateResult> {
|
||||
if (!params.configSnapshot.valid) {
|
||||
const invalid = buildInvalidConfigPostCoreUpdateResult();
|
||||
if (!params.opts.json) {
|
||||
defaultRuntime.log(theme.warn("Skipping plugin updates: config is invalid."));
|
||||
defaultRuntime.log(theme.error(invalid.message));
|
||||
for (const line of invalid.guidance) {
|
||||
defaultRuntime.log(theme.muted(` ${line}`));
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "invalid-config",
|
||||
changed: false,
|
||||
sync: {
|
||||
changed: false,
|
||||
switchedToBundled: [],
|
||||
switchedToNpm: [],
|
||||
warnings: [],
|
||||
errors: [],
|
||||
},
|
||||
npm: {
|
||||
changed: false,
|
||||
outcomes: [],
|
||||
},
|
||||
integrityDrifts: [],
|
||||
warnings: [],
|
||||
};
|
||||
return invalid.result;
|
||||
}
|
||||
|
||||
const pluginLogger = params.opts.json
|
||||
@@ -1290,6 +1326,51 @@ async function updatePluginsAfterCoreUpdate(params: {
|
||||
}),
|
||||
);
|
||||
|
||||
// Mandatory post-core convergence: repair any configured plugin install
|
||||
// records that are still missing payloads on disk and run a static smoke
|
||||
// check that the repaired payloads are at least loadable. Failures here
|
||||
// escalate `status` to `"error"`, which the caller maps to exit 1 BEFORE
|
||||
// restarting the gateway. See `post-core-plugin-convergence.ts`.
|
||||
//
|
||||
// We pass `baselineInstallRecords: pluginConfig.plugins?.installs ?? {}`
|
||||
// so that convergence layers its mutations on top of the latest
|
||||
// *in-memory* sync/npm record state — not on the stale pre-update disk
|
||||
// snapshot. The merged map convergence returns is the single source of
|
||||
// truth for the subsequent commit block.
|
||||
const convergenceBaselineRecords = pluginConfig.plugins?.installs ?? {};
|
||||
const convergence = await runPostCorePluginConvergence({
|
||||
cfg: pluginConfig,
|
||||
env: process.env,
|
||||
baselineInstallRecords: convergenceBaselineRecords,
|
||||
});
|
||||
for (const change of convergence.changes) {
|
||||
if (!params.opts.json) {
|
||||
defaultRuntime.log(theme.muted(change));
|
||||
}
|
||||
}
|
||||
const convergenceFolded = convergenceWarningsToOutcomes(convergence);
|
||||
for (const warning of convergenceFolded.warnings) {
|
||||
warnings.push(warning);
|
||||
if (!params.opts.json) {
|
||||
defaultRuntime.log(theme.warn(warning.message));
|
||||
for (const guidance of warning.guidance) {
|
||||
defaultRuntime.log(theme.muted(` ${guidance}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
pluginUpdateOutcomes.push(...convergenceFolded.outcomes);
|
||||
const convergenceErrored = convergenceFolded.errored;
|
||||
// Reseed `pluginConfig` from convergence's authoritative post-merge
|
||||
// record map. This is unconditional because convergence is what
|
||||
// reconciled the baseline (sync/npm in-memory state) with disk and any
|
||||
// new repairs, and convergence already persisted that exact map. If
|
||||
// we did not adopt it here, the commit block below would overwrite the
|
||||
// disk with `convergenceBaselineRecords` (no repairs included).
|
||||
pluginConfig = withPluginInstallRecords(pluginConfig, convergence.installRecords);
|
||||
if (convergence.changes.length > 0) {
|
||||
pluginsChanged = true;
|
||||
}
|
||||
|
||||
if (pluginsChanged) {
|
||||
const nextInstallRecords = pluginConfig.plugins?.installs ?? {};
|
||||
const nextConfig = withoutPluginInstallRecords(pluginConfig);
|
||||
@@ -1310,7 +1391,7 @@ async function updatePluginsAfterCoreUpdate(params: {
|
||||
|
||||
if (params.opts.json) {
|
||||
return {
|
||||
status: warnings.length > 0 ? "warning" : "ok",
|
||||
status: convergenceErrored ? "error" : warnings.length > 0 ? "warning" : "ok",
|
||||
changed: pluginsChanged,
|
||||
warnings,
|
||||
sync: {
|
||||
@@ -1380,7 +1461,7 @@ async function updatePluginsAfterCoreUpdate(params: {
|
||||
}
|
||||
|
||||
return {
|
||||
status: warnings.length > 0 ? "warning" : "ok",
|
||||
status: convergenceErrored ? "error" : warnings.length > 0 ? "warning" : "ok",
|
||||
changed: pluginsChanged,
|
||||
warnings,
|
||||
sync: {
|
||||
@@ -2476,10 +2557,6 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
} else {
|
||||
defaultRuntime.error(theme.error("Update failed during plugin post-update sync."));
|
||||
}
|
||||
await maybeRestartServiceAfterFailedPackageUpdate({
|
||||
prePackageServiceStop,
|
||||
jsonMode: Boolean(opts.json),
|
||||
});
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user