Skip to content

Commit 3b3ce73

Browse files
brokemac79steipete
authored andcommitted
fix(cli): route plugin packaging recovery hints
1 parent 3a4f2b1 commit 3b3ce73

14 files changed

Lines changed: 539 additions & 78 deletions

src/cli/config-cli.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ function setExternalFeishuSchema() {
273273

274274
function makeInvalidSnapshot(params: {
275275
issues: ConfigFileSnapshot["issues"];
276+
warnings?: ConfigFileSnapshot["warnings"];
276277
path?: string;
277278
}): ConfigFileSnapshot {
278279
return {
@@ -286,7 +287,7 @@ function makeInvalidSnapshot(params: {
286287
runtimeConfig: {},
287288
config: {},
288289
issues: params.issues,
289-
warnings: [],
290+
warnings: params.warnings ?? [],
290291
legacyIssues: [],
291292
};
292293
}
@@ -1066,6 +1067,36 @@ describe("config cli", () => {
10661067
expect(mockLog).not.toHaveBeenCalled();
10671068
});
10681069

1070+
it("replaces doctor advice for plugin packaging compiled-output failures", async () => {
1071+
setSnapshotOnce(
1072+
makeInvalidSnapshot({
1073+
issues: [
1074+
{
1075+
path: "plugins.slots.memory",
1076+
message: "plugin not found: source-only-pack",
1077+
},
1078+
],
1079+
warnings: [
1080+
{
1081+
path: "plugins",
1082+
message:
1083+
"plugin source-only-pack: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js. This is a plugin packaging issue, not a local config problem.",
1084+
},
1085+
],
1086+
}),
1087+
);
1088+
1089+
await expect(runConfigCommand(["config", "validate"])).rejects.toThrow("__exit__:1");
1090+
1091+
expectErrorIncludes("plugin not found: source-only-pack");
1092+
expectErrorIncludes("This is a plugin packaging issue, not a local config problem.");
1093+
expectErrorIncludes("disable/uninstall the plugin");
1094+
expect(mockError.mock.calls.map((call) => String(call[0])).join("\n")).not.toContain(
1095+
"openclaw doctor --fix",
1096+
);
1097+
expect(mockLog).not.toHaveBeenCalled();
1098+
});
1099+
10691100
it("returns machine-readable JSON with --json for invalid config", async () => {
10701101
setSnapshotOnce(
10711102
makeInvalidSnapshot({

src/cli/config-cli.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import fs from "node:fs";
22
import type { Command } from "commander";
33
import JSON5 from "json5";
44
import { normalizeConfiguredProviderCatalogModelId } from "../agents/model-ref-shared.js";
5-
import { readConfigFileSnapshot, replaceConfigFile } from "../config/config.js";
5+
import {
6+
type ConfigFileSnapshot,
7+
readConfigFileSnapshot,
8+
replaceConfigFile,
9+
} from "../config/config.js";
610
import { AUTO_MANAGED_CONFIG_META_PATHS } from "../config/io.meta.js";
711
import { formatConfigIssueLines, normalizeConfigIssues } from "../config/issue-format.js";
812
import {
@@ -11,6 +15,7 @@ import {
1115
} from "../config/model-input.js";
1216
import { CONFIG_PATH } from "../config/paths.js";
1317
import { isBlockedObjectKey } from "../config/prototype-keys.js";
18+
import { isPluginPackagingRuntimeOutputInvalidConfigSnapshot } from "../config/recovery-policy.js";
1419
import { redactConfigObject } from "../config/redact-snapshot.js";
1520
import { readBestEffortRuntimeConfigSchema } from "../config/runtime-schema.js";
1621
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -50,6 +55,7 @@ import { formatDocsLink } from "../terminal/links.js";
5055
import { theme } from "../terminal/theme.js";
5156
import { shortenHomePath } from "../utils.js";
5257
import { formatCliCommand } from "./command-format.js";
58+
import { formatPluginPackagingRuntimeOutputRecoveryHint } from "./config-recovery-hints.js";
5359
import type {
5460
ConfigSetDryRunError,
5561
ConfigSetDryRunInputMode,
@@ -412,6 +418,15 @@ function formatDoctorHint(message: string): string {
412418
return `Run \`${formatCliCommand("openclaw doctor --fix")}\` ${message}`;
413419
}
414420

421+
function formatInvalidConfigRepairHint(
422+
snapshot: Pick<ConfigFileSnapshot, "valid" | "issues" | "warnings" | "legacyIssues">,
423+
doctorMessage: string,
424+
): string {
425+
return isPluginPackagingRuntimeOutputInvalidConfigSnapshot(snapshot)
426+
? formatPluginPackagingRuntimeOutputRecoveryHint()
427+
: formatDoctorHint(doctorMessage);
428+
}
429+
415430
function formatUnsupportedSecretRefPolicyFailureMessage(issues: string[]): string {
416431
const lines = [
417432
"Config policy validation failed: unsupported SecretRef usage was detected.",
@@ -868,7 +883,7 @@ async function loadValidConfig(runtime: RuntimeEnv = defaultRuntime) {
868883
for (const line of formatConfigIssueLines(snapshot.issues, "-", { normalizeRoot: true })) {
869884
runtime.error(line);
870885
}
871-
runtime.error(formatDoctorHint("to repair, then retry."));
886+
runtime.error(formatInvalidConfigRepairHint(snapshot, "to repair, then retry."));
872887
runtime.exit(1);
873888
return snapshot;
874889
}
@@ -2343,7 +2358,9 @@ export async function runConfigValidate(opts: { json?: boolean; runtime?: Runtim
23432358
runtime.error(` ${line}`);
23442359
}
23452360
runtime.error("");
2346-
runtime.error(formatDoctorHint("to repair, or fix the keys above manually."));
2361+
runtime.error(
2362+
formatInvalidConfigRepairHint(snapshot, "to repair, or fix the keys above manually."),
2363+
);
23472364
runtime.error(`Inspect with ${formatCliCommand("openclaw config validate")}.`);
23482365
}
23492366
runtime.exit(1);

src/cli/config-recovery-hints.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,10 @@ export function formatInvalidConfigRecoveryHint(): string {
66
"If startup is still blocked, inspect the adjacent .bak backup before restoring it manually.",
77
].join("\n");
88
}
9+
10+
export function formatPluginPackagingRuntimeOutputRecoveryHint(): string {
11+
return [
12+
"This is a plugin packaging issue, not a local config problem.",
13+
"Update or reinstall the plugin after the publisher ships compiled JavaScript, or disable/uninstall the plugin until then.",
14+
].join("\n");
15+
}

src/cli/daemon-cli/lifecycle-core.config-guard.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ const invalidConfigRecoveryHint = [
1919
'Run "openclaw doctor --fix" to repair, then retry.',
2020
"If startup is still blocked, inspect the adjacent .bak backup before restoring it manually.",
2121
].join("\n");
22+
const pluginPackagingRecoveryHints = [
23+
"This is a plugin packaging issue, not a local config problem.",
24+
"Update or reinstall the plugin after the publisher ships compiled JavaScript, or disable/uninstall the plugin until then.",
25+
];
26+
const pluginPackagingHintItems = pluginPackagingRecoveryHints.map((text) => ({
27+
kind: "generic",
28+
text,
29+
}));
2230

2331
function expectLatestRuntimeJson(payload: unknown) {
2432
const calls = defaultRuntime.writeJson.mock.calls;
@@ -47,6 +55,8 @@ function setConfigSnapshot(params: {
4755
exists: boolean;
4856
valid: boolean;
4957
issues?: Array<{ path: string; message: string }>;
58+
warnings?: Array<{ path: string; message: string }>;
59+
legacyIssues?: Array<{ path: string; message: string }>;
5060
lastTouchedVersion?: string;
5161
}) {
5262
const config = params.lastTouchedVersion
@@ -58,6 +68,28 @@ function setConfigSnapshot(params: {
5868
config,
5969
sourceConfig: config,
6070
issues: params.issues ?? [],
71+
warnings: params.warnings ?? [],
72+
legacyIssues: params.legacyIssues ?? [],
73+
});
74+
}
75+
76+
function setPluginPackagingInvalidSnapshot() {
77+
setConfigSnapshot({
78+
exists: true,
79+
valid: false,
80+
issues: [
81+
{
82+
path: "plugins.slots.memory",
83+
message: "plugin not found: source-only-pack",
84+
},
85+
],
86+
warnings: [
87+
{
88+
path: "plugins",
89+
message:
90+
"plugin source-only-pack: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js. This is a plugin packaging issue, not a local config problem.",
91+
},
92+
],
6193
});
6294
}
6395

@@ -107,6 +139,22 @@ describe("runServiceRestart config pre-flight (#35862)", () => {
107139
});
108140
});
109141

142+
it("points restart at plugin packaging recovery for packaging-only invalid config", async () => {
143+
setPluginPackagingInvalidSnapshot();
144+
145+
await expect(runServiceRestart(createServiceRunArgs())).rejects.toThrow("__exit__:1");
146+
147+
expect(service.restart).not.toHaveBeenCalled();
148+
expectLatestRuntimeJson({
149+
action: "restart",
150+
ok: false,
151+
error: "Gateway restart blocked: plugins.slots.memory: plugin not found: source-only-pack",
152+
hints: pluginPackagingRecoveryHints,
153+
hintItems: pluginPackagingHintItems,
154+
warnings: undefined,
155+
});
156+
});
157+
110158
it("blocks restart from an older binary when config was written by a newer one", async () => {
111159
setConfigSnapshot({ exists: true, valid: true, lastTouchedVersion: "9999.1.1" });
112160

@@ -185,6 +233,22 @@ describe("runServiceStart config pre-flight (#35862)", () => {
185233
});
186234
});
187235

236+
it("points start at plugin packaging recovery for packaging-only invalid config", async () => {
237+
setPluginPackagingInvalidSnapshot();
238+
239+
await expect(runServiceStart(createServiceRunArgs())).rejects.toThrow("__exit__:1");
240+
241+
expect(service.restart).not.toHaveBeenCalled();
242+
expectLatestRuntimeJson({
243+
action: "start",
244+
ok: false,
245+
error: "Gateway start blocked: plugins.slots.memory: plugin not found: source-only-pack",
246+
hints: pluginPackagingRecoveryHints,
247+
hintItems: pluginPackagingHintItems,
248+
warnings: undefined,
249+
});
250+
});
251+
188252
it("aborts before not-loaded start recovery when config is invalid", async () => {
189253
const onNotLoaded = vi.fn(async () => ({
190254
result: "started" as const,

src/cli/daemon-cli/lifecycle-core.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { readBestEffortConfig, readConfigFileSnapshot } from "../../config/confi
33
import { resolveFutureConfigActionBlock } from "../../config/future-version-guard.js";
44
import { formatConfigIssueLines } from "../../config/issue-format.js";
55
import { resolveIsNixMode } from "../../config/paths.js";
6+
import { isPluginPackagingRuntimeOutputInvalidConfigSnapshot } from "../../config/recovery-policy.js";
67
import { checkTokenDrift } from "../../daemon/service-audit.js";
78
import type { GatewayServiceRestartResult } from "../../daemon/service-types.js";
89
import type { GatewayServiceStartRepairIssue, GatewayServiceState } from "../../daemon/service.js";
@@ -19,7 +20,10 @@ import {
1920
import { isWSL } from "../../infra/wsl.js";
2021
import { defaultRuntime } from "../../runtime.js";
2122
import { formatCliCommand } from "../command-format.js";
22-
import { formatInvalidConfigRecoveryHint } from "../config-recovery-hints.js";
23+
import {
24+
formatInvalidConfigRecoveryHint,
25+
formatPluginPackagingRuntimeOutputRecoveryHint,
26+
} from "../config-recovery-hints.js";
2327
import { resolveGatewayTokenForDriftCheck } from "./gateway-token-drift.js";
2428
import {
2529
buildDaemonServiceSnapshot,
@@ -139,18 +143,26 @@ type ConfigActionPreflightFailure = {
139143
hints?: string[];
140144
};
141145

146+
function formatPluginPackagingRuntimeOutputRecoveryHints(): string[] {
147+
return formatPluginPackagingRuntimeOutputRecoveryHint().split("\n");
148+
}
149+
142150
async function getConfigActionPreflightFailure(
143151
action: string,
144152
): Promise<ConfigActionPreflightFailure | null> {
145153
let snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
146154
try {
147155
snapshot = await readConfigFileSnapshot();
148156
if (snapshot.exists && !snapshot.valid) {
157+
const message =
158+
snapshot.issues.length > 0
159+
? formatConfigIssueLines(snapshot.issues, "", { normalizeRoot: true }).join("\n")
160+
: "Unknown validation issue.";
149161
return {
150-
message:
151-
snapshot.issues.length > 0
152-
? formatConfigIssueLines(snapshot.issues, "", { normalizeRoot: true }).join("\n")
153-
: "Unknown validation issue.",
162+
message,
163+
...(isPluginPackagingRuntimeOutputInvalidConfigSnapshot(snapshot)
164+
? { hints: formatPluginPackagingRuntimeOutputRecoveryHints() }
165+
: {}),
154166
};
155167
}
156168
} catch {

src/cli/program/config-guard.test.ts

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import { note } from "../../terminal/note.js";
33
import { formatCliCommand } from "../command-format.js";
44
import { ensureConfigReady, testApi } from "./config-guard.js";
55

6+
const pluginPackagingRecoveryHint = [
7+
"This is a plugin packaging issue, not a local config problem.",
8+
"Update or reinstall the plugin after the publisher ships compiled JavaScript, or disable/uninstall the plugin until then.",
9+
].join("\n");
10+
611
const loadAndMaybeMigrateDoctorConfigMock = vi.hoisted(() => vi.fn());
712
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
813
const setRuntimeConfigSnapshotMock = vi.hoisted(() => vi.fn());
@@ -23,6 +28,7 @@ function makeSnapshot() {
2328
exists: false,
2429
valid: true,
2530
issues: [] as ConfigIssue[],
31+
warnings: [] as ConfigIssue[],
2632
legacyIssues: [] as ConfigIssue[],
2733
path: "/tmp/openclaw.json",
2834
};
@@ -42,20 +48,16 @@ function plainErrorCalls(runtime: ReturnType<typeof makeRuntime>): string[] {
4248

4349
async function withCapturedStdout(run: () => Promise<void>): Promise<string> {
4450
const writes: string[] = [];
45-
const writeSpy = vi
46-
.spyOn(process.stdout, "write")
47-
.mockImplementation(
48-
((
49-
chunk: unknown,
50-
encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
51-
callback?: (error?: Error | null) => void,
52-
) => {
53-
writes.push(String(chunk));
54-
const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
55-
done?.();
56-
return true;
57-
}) as typeof process.stdout.write,
58-
);
51+
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((
52+
chunk: unknown,
53+
encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
54+
callback?: (error?: Error | null) => void,
55+
) => {
56+
writes.push(String(chunk));
57+
const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
58+
done?.();
59+
return true;
60+
}) as typeof process.stdout.write);
5961
try {
6062
await run();
6163
return writes.join("");
@@ -185,6 +187,30 @@ describe("ensureConfigReady", () => {
185187
expect(runtime.exit).toHaveBeenCalledWith(1);
186188
});
187189

190+
it("replaces doctor fix advice for plugin packaging-only invalid config", async () => {
191+
setInvalidSnapshot({
192+
issues: [
193+
{
194+
path: "plugins.slots.memory",
195+
message: "plugin not found: source-only-pack",
196+
},
197+
],
198+
warnings: [
199+
{
200+
path: "plugins",
201+
message:
202+
"plugin source-only-pack: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js. This is a plugin packaging issue, not a local config problem.",
203+
},
204+
],
205+
});
206+
const runtime = await runEnsureConfigReady(["message"]);
207+
const calls = plainErrorCalls(runtime);
208+
209+
expect(calls).toContain(`Fix: ${pluginPackagingRecoveryHint}`);
210+
expect(calls).not.toContain(`Fix: ${formatCliCommand("openclaw doctor --fix")}`);
211+
expect(runtime.exit).toHaveBeenCalledWith(1);
212+
});
213+
188214
it("does not exit for invalid config on allowlisted commands", async () => {
189215
setInvalidSnapshot({
190216
issues: [{ path: "agents.defaults", message: 'Unrecognized key: "agentRuntime"' }],

0 commit comments

Comments
 (0)