Skip to content

Commit f9b20c7

Browse files
committed
fix(plugins): repair bundled runtime deps during doctor
1 parent 6608a50 commit f9b20c7

9 files changed

Lines changed: 177 additions & 7 deletions

src/cli/update-cli.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,61 @@ describe("update-cli", () => {
843843
expect(logs.join("\n")).toContain("expected installed version 2026.3.23-2, found 2026.3.23");
844844
});
845845

846+
it("marks package post-update doctor as update-in-progress", async () => {
847+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-package-"));
848+
const nodeModules = path.join(tempDir, "node_modules");
849+
const pkgRoot = path.join(nodeModules, "openclaw");
850+
const entryPath = path.join(pkgRoot, "dist", "index.js");
851+
mockPackageInstallStatus(pkgRoot);
852+
await fs.mkdir(path.dirname(entryPath), { recursive: true });
853+
await fs.writeFile(
854+
path.join(pkgRoot, "package.json"),
855+
JSON.stringify({ name: "openclaw", version: "2026.4.21" }),
856+
"utf-8",
857+
);
858+
await fs.writeFile(entryPath, "export {};\n", "utf-8");
859+
await writePackageDistInventory(pkgRoot);
860+
pathExists.mockImplementation(async (candidate: string) => {
861+
try {
862+
await fs.access(candidate);
863+
return true;
864+
} catch {
865+
return false;
866+
}
867+
});
868+
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
869+
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
870+
return {
871+
stdout: `${nodeModules}\n`,
872+
stderr: "",
873+
code: 0,
874+
signal: null,
875+
killed: false,
876+
termination: "exit",
877+
};
878+
}
879+
return {
880+
stdout: "",
881+
stderr: "",
882+
code: 0,
883+
signal: null,
884+
killed: false,
885+
termination: "exit",
886+
};
887+
});
888+
889+
await updateCommand({ yes: true });
890+
891+
expect(runCommandWithTimeout).toHaveBeenCalledWith(
892+
[expect.stringMatching(/node/), entryPath, "doctor", "--non-interactive"],
893+
expect.objectContaining({
894+
env: expect.objectContaining({
895+
OPENCLAW_UPDATE_IN_PROGRESS: "1",
896+
}),
897+
}),
898+
);
899+
});
900+
846901
it("uses the owning npm binary for package updates when PATH npm points elsewhere", async () => {
847902
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
848903
const brewPrefix = createCaseDir("brew-prefix");

src/cli/update-cli/update-command.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,10 @@ async function runPackageInstallUpdate(params: {
410410
const doctorStep = await runUpdateStep({
411411
name: `${CLI_NAME} doctor`,
412412
argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive"],
413+
env: {
414+
...process.env,
415+
OPENCLAW_UPDATE_IN_PROGRESS: "1",
416+
},
413417
timeoutMs: params.timeoutMs,
414418
progress: params.progress,
415419
});

src/commands/doctor-bundled-plugin-runtime-deps.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import os from "node:os";
33
import path from "node:path";
44
import { describe, expect, it } from "vitest";
55
import { scanBundledPluginRuntimeDeps } from "../plugins/bundled-runtime-deps.js";
6+
import { maybeRepairBundledPluginRuntimeDeps } from "./doctor-bundled-plugin-runtime-deps.js";
7+
import type { DoctorPrompter } from "./doctor-prompter.js";
68

79
function writeJson(filePath: string, value: unknown) {
810
fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -117,6 +119,28 @@ describe("doctor bundled plugin runtime deps", () => {
117119
expect(result.conflicts).toEqual([]);
118120
});
119121

122+
it("can include disabled but configured bundled channel deps for doctor recovery", () => {
123+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-"));
124+
writeJson(path.join(root, "package.json"), { name: "openclaw" });
125+
writeBundledChannelPlugin(root, "telegram", { "telegram-only": "1.0.0" });
126+
127+
const result = scanBundledPluginRuntimeDeps({
128+
packageRoot: root,
129+
includeConfiguredChannels: true,
130+
config: {
131+
plugins: { enabled: true },
132+
channels: {
133+
telegram: { enabled: false, botToken: "123:abc" },
134+
},
135+
},
136+
});
137+
138+
expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([
139+
140+
]);
141+
expect(result.conflicts).toEqual([]);
142+
});
143+
120144
it("reports default-enabled bundled plugin deps", () => {
121145
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-"));
122146
writeJson(path.join(root, "package.json"), { name: "openclaw" });
@@ -143,4 +167,47 @@ describe("doctor bundled plugin runtime deps", () => {
143167
]);
144168
expect(result.conflicts).toEqual([]);
145169
});
170+
171+
it("repairs missing deps during non-interactive doctor", async () => {
172+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-"));
173+
writeJson(path.join(root, "package.json"), { name: "openclaw" });
174+
writeBundledChannelPlugin(root, "telegram", { grammy: "1.37.0" });
175+
const installed: Array<{ installRoot: string; missingSpecs: string[] }> = [];
176+
const prompter = {
177+
shouldRepair: false,
178+
shouldForce: false,
179+
repairMode: {
180+
shouldRepair: false,
181+
shouldForce: false,
182+
nonInteractive: true,
183+
canPrompt: false,
184+
updateInProgress: false,
185+
},
186+
confirm: async () => false,
187+
confirmAutoFix: async () => false,
188+
confirmAggressiveAutoFix: async () => false,
189+
confirmRuntimeRepair: async () => false,
190+
select: async (_params: unknown, fallback: unknown) => fallback,
191+
} as DoctorPrompter;
192+
193+
await maybeRepairBundledPluginRuntimeDeps({
194+
runtime: { error: () => {} } as never,
195+
prompter,
196+
packageRoot: root,
197+
config: {
198+
plugins: { enabled: true },
199+
channels: { telegram: { enabled: true } },
200+
},
201+
installDeps: (params) => {
202+
installed.push(params);
203+
},
204+
});
205+
206+
expect(installed).toEqual([
207+
{
208+
installRoot: root,
209+
missingSpecs: ["[email protected]"],
210+
},
211+
]);
212+
});
146213
});

src/commands/doctor-bundled-plugin-runtime-deps.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: {
1515
config?: OpenClawConfig;
1616
env?: NodeJS.ProcessEnv;
1717
packageRoot?: string | null;
18+
includeConfiguredChannels?: boolean;
1819
installDeps?: (params: { installRoot: string; missingSpecs: string[] }) => void;
1920
}): Promise<void> {
2021
const packageRoot =
@@ -31,6 +32,7 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: {
3132
const { missing, conflicts } = scanBundledPluginRuntimeDeps({
3233
packageRoot,
3334
config: params.config,
35+
includeConfiguredChannels: params.includeConfiguredChannels,
3436
});
3537
if (conflicts.length > 0) {
3638
const conflictLines = conflicts.flatMap((conflict) =>
@@ -67,6 +69,7 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: {
6769

6870
const shouldRepair =
6971
params.prompter.shouldRepair ||
72+
params.prompter.repairMode.nonInteractive ||
7073
(await params.prompter.confirmAutoFix({
7174
message: "Install missing bundled plugin runtime deps now?",
7275
initialValue: true,

src/commands/doctor-config-flow.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import { formatCliCommand } from "../cli/command-format.js";
22
import { findLegacyConfigIssues } from "../config/legacy.js";
33
import { CONFIG_PATH } from "../config/paths.js";
44
import type { OpenClawConfig } from "../config/types.openclaw.js";
5+
import type { RuntimeEnv } from "../runtime.js";
56
import { note } from "../terminal/note.js";
67
import { noteOpencodeProviderOverrides } from "./doctor-config-analysis.js";
78
import { runDoctorConfigPreflight } from "./doctor-config-preflight.js";
89
import { normalizeCompatibilityConfigValues } from "./doctor-legacy-config.js";
9-
import type { DoctorOptions } from "./doctor-prompter.js";
10+
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
1011
import { emitDoctorNotes } from "./doctor/emit-notes.js";
1112
import { finalizeDoctorConfigFlow } from "./doctor/finalize-config-flow.js";
1213
import {
@@ -39,6 +40,8 @@ function collectConfiguredChannelIds(cfg: OpenClawConfig): string[] {
3940
export async function loadAndMaybeMigrateDoctorConfig(params: {
4041
options: DoctorOptions;
4142
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
43+
runtime?: RuntimeEnv;
44+
prompter?: DoctorPrompter;
4245
}) {
4346
const shouldRepair = params.options.repair === true || params.options.yes === true;
4447
const preflight = await runDoctorConfigPreflight();
@@ -132,6 +135,17 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
132135
}));
133136
}
134137

138+
if (params.runtime && params.prompter) {
139+
const { maybeRepairBundledPluginRuntimeDeps } =
140+
await import("./doctor-bundled-plugin-runtime-deps.js");
141+
await maybeRepairBundledPluginRuntimeDeps({
142+
runtime: params.runtime,
143+
prompter: params.prompter,
144+
config: candidate,
145+
includeConfiguredChannels: true,
146+
});
147+
}
148+
135149
const hasConfiguredChannels = collectConfiguredChannelIds(candidate).length > 0;
136150
let collectMutableAllowlistWarnings:
137151
| typeof import("./doctor/shared/channel-doctor.js").collectChannelDoctorMutableAllowlistWarnings
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveDoctorHealthContributions } from "./doctor-health-contributions.js";
3+
4+
describe("doctor health contributions", () => {
5+
it("repairs bundled runtime deps before channel-owned doctor paths can import runtimes", () => {
6+
const ids = resolveDoctorHealthContributions().map((entry) => entry.id);
7+
8+
expect(ids.indexOf("doctor:bundled-plugin-runtime-deps")).toBeGreaterThan(-1);
9+
expect(ids.indexOf("doctor:bundled-plugin-runtime-deps")).toBeLessThan(
10+
ids.indexOf("doctor:auth-profiles"),
11+
);
12+
expect(ids.indexOf("doctor:bundled-plugin-runtime-deps")).toBeLessThan(
13+
ids.indexOf("doctor:startup-channel-maintenance"),
14+
);
15+
});
16+
});

src/flows/doctor-health-contributions.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ async function runBundledPluginRuntimeDepsHealth(ctx: DoctorHealthFlowContext):
222222
runtime: ctx.runtime,
223223
prompter: ctx.prompter,
224224
config: ctx.cfg,
225+
includeConfiguredChannels: true,
225226
});
226227
}
227228

@@ -509,6 +510,11 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
509510
label: "Gateway config",
510511
run: runGatewayConfigHealth,
511512
}),
513+
createDoctorHealthContribution({
514+
id: "doctor:bundled-plugin-runtime-deps",
515+
label: "Bundled plugin runtime deps",
516+
run: runBundledPluginRuntimeDepsHealth,
517+
}),
512518
createDoctorHealthContribution({
513519
id: "doctor:auth-profiles",
514520
label: "Auth profiles",
@@ -534,11 +540,6 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
534540
label: "Legacy plugin manifests",
535541
run: runLegacyPluginManifestHealth,
536542
}),
537-
createDoctorHealthContribution({
538-
id: "doctor:bundled-plugin-runtime-deps",
539-
label: "Bundled plugin runtime deps",
540-
run: runBundledPluginRuntimeDepsHealth,
541-
}),
542543
createDoctorHealthContribution({
543544
id: "doctor:state-integrity",
544545
label: "State integrity",

src/flows/doctor-health.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions
4444
const configResult = await loadAndMaybeMigrateDoctorConfig({
4545
options,
4646
confirm: (p) => prompter.confirm(p),
47+
runtime: effectiveRuntime,
48+
prompter,
4749
});
4850
const { CONFIG_PATH } = await import("../config/config.js");
4951
const ctx = {

src/plugins/bundled-runtime-deps.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ function isBundledPluginConfiguredForRuntimeDeps(params: {
328328
config: OpenClawConfig;
329329
pluginId: string;
330330
pluginDir: string;
331+
includeConfiguredChannels?: boolean;
331332
}): boolean {
332333
const plugins = normalizePluginsConfig(params.config.plugins);
333334
if (!plugins.enabled) {
@@ -355,7 +356,8 @@ function isBundledPluginConfiguredForRuntimeDeps(params: {
355356
channelConfig &&
356357
typeof channelConfig === "object" &&
357358
!Array.isArray(channelConfig) &&
358-
(channelConfig as { enabled?: unknown }).enabled === true
359+
(params.includeConfiguredChannels ||
360+
(channelConfig as { enabled?: unknown }).enabled === true)
359361
) {
360362
return true;
361363
}
@@ -368,6 +370,7 @@ function shouldIncludeBundledPluginRuntimeDeps(params: {
368370
pluginIds?: ReadonlySet<string>;
369371
pluginId: string;
370372
pluginDir: string;
373+
includeConfiguredChannels?: boolean;
371374
}): boolean {
372375
if (params.pluginIds && !params.pluginIds.has(params.pluginId)) {
373376
return false;
@@ -379,13 +382,15 @@ function shouldIncludeBundledPluginRuntimeDeps(params: {
379382
config: params.config,
380383
pluginId: params.pluginId,
381384
pluginDir: params.pluginDir,
385+
includeConfiguredChannels: params.includeConfiguredChannels,
382386
});
383387
}
384388

385389
function collectBundledPluginRuntimeDeps(params: {
386390
extensionsDir: string;
387391
config?: OpenClawConfig;
388392
pluginIds?: ReadonlySet<string>;
393+
includeConfiguredChannels?: boolean;
389394
}): {
390395
deps: RuntimeDepEntry[];
391396
conflicts: RuntimeDepConflict[];
@@ -404,6 +409,7 @@ function collectBundledPluginRuntimeDeps(params: {
404409
pluginIds: params.pluginIds,
405410
pluginId,
406411
pluginDir,
412+
includeConfiguredChannels: params.includeConfiguredChannels,
407413
})
408414
) {
409415
continue;
@@ -476,6 +482,7 @@ export function scanBundledPluginRuntimeDeps(params: {
476482
packageRoot: string;
477483
config?: OpenClawConfig;
478484
pluginIds?: readonly string[];
485+
includeConfiguredChannels?: boolean;
479486
}): {
480487
missing: RuntimeDepEntry[];
481488
conflicts: RuntimeDepConflict[];
@@ -491,6 +498,7 @@ export function scanBundledPluginRuntimeDeps(params: {
491498
extensionsDir,
492499
config: params.config,
493500
pluginIds: normalizePluginIdSet(params.pluginIds),
501+
includeConfiguredChannels: params.includeConfiguredChannels,
494502
});
495503
const missing = deps.filter(
496504
(dep) =>

0 commit comments

Comments
 (0)