diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index af00ab2996f..2b1d54df8bb 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -843,6 +843,61 @@ describe("update-cli", () => { expect(logs.join("\n")).toContain("expected installed version 2026.3.23-2, found 2026.3.23"); }); + it("marks package post-update doctor as update-in-progress", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-package-")); + const nodeModules = path.join(tempDir, "node_modules"); + const pkgRoot = path.join(nodeModules, "openclaw"); + const entryPath = path.join(pkgRoot, "dist", "index.js"); + mockPackageInstallStatus(pkgRoot); + await fs.mkdir(path.dirname(entryPath), { recursive: true }); + await fs.writeFile( + path.join(pkgRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.21" }), + "utf-8", + ); + await fs.writeFile(entryPath, "export {};\n", "utf-8"); + await writePackageDistInventory(pkgRoot); + pathExists.mockImplementation(async (candidate: string) => { + try { + await fs.access(candidate); + return true; + } catch { + return false; + } + }); + vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { + return { + stdout: `${nodeModules}\n`, + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + } + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + }); + + await updateCommand({ yes: true }); + + expect(runCommandWithTimeout).toHaveBeenCalledWith( + [expect.stringMatching(/node/), entryPath, "doctor", "--non-interactive"], + expect.objectContaining({ + env: expect.objectContaining({ + OPENCLAW_UPDATE_IN_PROGRESS: "1", + }), + }), + ); + }); + it("uses the owning npm binary for package updates when PATH npm points elsewhere", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); const brewPrefix = createCaseDir("brew-prefix"); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 3a4c0ddc035..86698318e33 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -410,6 +410,10 @@ async function runPackageInstallUpdate(params: { const doctorStep = await runUpdateStep({ name: `${CLI_NAME} doctor`, argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive"], + env: { + ...process.env, + OPENCLAW_UPDATE_IN_PROGRESS: "1", + }, timeoutMs: params.timeoutMs, progress: params.progress, }); diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts index ce295453fe5..7b729e5b6d6 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts @@ -3,6 +3,8 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { scanBundledPluginRuntimeDeps } from "../plugins/bundled-runtime-deps.js"; +import { maybeRepairBundledPluginRuntimeDeps } from "./doctor-bundled-plugin-runtime-deps.js"; +import type { DoctorPrompter } from "./doctor-prompter.js"; function writeJson(filePath: string, value: unknown) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); @@ -117,6 +119,28 @@ describe("doctor bundled plugin runtime deps", () => { expect(result.conflicts).toEqual([]); }); + it("can include disabled but configured bundled channel deps for doctor recovery", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); + writeJson(path.join(root, "package.json"), { name: "openclaw" }); + writeBundledChannelPlugin(root, "telegram", { "telegram-only": "1.0.0" }); + + const result = scanBundledPluginRuntimeDeps({ + packageRoot: root, + includeConfiguredChannels: true, + config: { + plugins: { enabled: true }, + channels: { + telegram: { enabled: false, botToken: "123:abc" }, + }, + }, + }); + + expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ + "telegram-only@1.0.0", + ]); + expect(result.conflicts).toEqual([]); + }); + it("reports default-enabled bundled plugin deps", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); writeJson(path.join(root, "package.json"), { name: "openclaw" }); @@ -143,4 +167,47 @@ describe("doctor bundled plugin runtime deps", () => { ]); expect(result.conflicts).toEqual([]); }); + + it("repairs missing deps during non-interactive doctor", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); + writeJson(path.join(root, "package.json"), { name: "openclaw" }); + writeBundledChannelPlugin(root, "telegram", { grammy: "1.37.0" }); + const installed: Array<{ installRoot: string; missingSpecs: string[] }> = []; + const prompter = { + shouldRepair: false, + shouldForce: false, + repairMode: { + shouldRepair: false, + shouldForce: false, + nonInteractive: true, + canPrompt: false, + updateInProgress: false, + }, + confirm: async () => false, + confirmAutoFix: async () => false, + confirmAggressiveAutoFix: async () => false, + confirmRuntimeRepair: async () => false, + select: async (_params: unknown, fallback: unknown) => fallback, + } as DoctorPrompter; + + await maybeRepairBundledPluginRuntimeDeps({ + runtime: { error: () => {} } as never, + prompter, + packageRoot: root, + config: { + plugins: { enabled: true }, + channels: { telegram: { enabled: true } }, + }, + installDeps: (params) => { + installed.push(params); + }, + }); + + expect(installed).toEqual([ + { + installRoot: root, + missingSpecs: ["grammy@1.37.0"], + }, + ]); + }); }); diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.ts b/src/commands/doctor-bundled-plugin-runtime-deps.ts index cbb369ae7aa..ea72d7ed8d1 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.ts @@ -15,6 +15,7 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: { config?: OpenClawConfig; env?: NodeJS.ProcessEnv; packageRoot?: string | null; + includeConfiguredChannels?: boolean; installDeps?: (params: { installRoot: string; missingSpecs: string[] }) => void; }): Promise { const packageRoot = @@ -31,6 +32,7 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: { const { missing, conflicts } = scanBundledPluginRuntimeDeps({ packageRoot, config: params.config, + includeConfiguredChannels: params.includeConfiguredChannels, }); if (conflicts.length > 0) { const conflictLines = conflicts.flatMap((conflict) => @@ -67,6 +69,7 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: { const shouldRepair = params.prompter.shouldRepair || + params.prompter.repairMode.nonInteractive || (await params.prompter.confirmAutoFix({ message: "Install missing bundled plugin runtime deps now?", initialValue: true, diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index cf988110433..fb570891d93 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -2,11 +2,12 @@ import { formatCliCommand } from "../cli/command-format.js"; import { findLegacyConfigIssues } from "../config/legacy.js"; import { CONFIG_PATH } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { RuntimeEnv } from "../runtime.js"; import { note } from "../terminal/note.js"; import { noteOpencodeProviderOverrides } from "./doctor-config-analysis.js"; import { runDoctorConfigPreflight } from "./doctor-config-preflight.js"; import { normalizeCompatibilityConfigValues } from "./doctor-legacy-config.js"; -import type { DoctorOptions } from "./doctor-prompter.js"; +import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js"; import { emitDoctorNotes } from "./doctor/emit-notes.js"; import { finalizeDoctorConfigFlow } from "./doctor/finalize-config-flow.js"; import { @@ -39,6 +40,8 @@ function collectConfiguredChannelIds(cfg: OpenClawConfig): string[] { export async function loadAndMaybeMigrateDoctorConfig(params: { options: DoctorOptions; confirm: (p: { message: string; initialValue: boolean }) => Promise; + runtime?: RuntimeEnv; + prompter?: DoctorPrompter; }) { const shouldRepair = params.options.repair === true || params.options.yes === true; const preflight = await runDoctorConfigPreflight(); @@ -132,6 +135,17 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { })); } + if (params.runtime && params.prompter) { + const { maybeRepairBundledPluginRuntimeDeps } = + await import("./doctor-bundled-plugin-runtime-deps.js"); + await maybeRepairBundledPluginRuntimeDeps({ + runtime: params.runtime, + prompter: params.prompter, + config: candidate, + includeConfiguredChannels: true, + }); + } + const hasConfiguredChannels = collectConfiguredChannelIds(candidate).length > 0; let collectMutableAllowlistWarnings: | typeof import("./doctor/shared/channel-doctor.js").collectChannelDoctorMutableAllowlistWarnings diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts new file mode 100644 index 00000000000..2a0ec743a4a --- /dev/null +++ b/src/flows/doctor-health-contributions.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { resolveDoctorHealthContributions } from "./doctor-health-contributions.js"; + +describe("doctor health contributions", () => { + it("repairs bundled runtime deps before channel-owned doctor paths can import runtimes", () => { + const ids = resolveDoctorHealthContributions().map((entry) => entry.id); + + expect(ids.indexOf("doctor:bundled-plugin-runtime-deps")).toBeGreaterThan(-1); + expect(ids.indexOf("doctor:bundled-plugin-runtime-deps")).toBeLessThan( + ids.indexOf("doctor:auth-profiles"), + ); + expect(ids.indexOf("doctor:bundled-plugin-runtime-deps")).toBeLessThan( + ids.indexOf("doctor:startup-channel-maintenance"), + ); + }); +}); diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index a8a0ddc86fa..f741bd2e9da 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -222,6 +222,7 @@ async function runBundledPluginRuntimeDepsHealth(ctx: DoctorHealthFlowContext): runtime: ctx.runtime, prompter: ctx.prompter, config: ctx.cfg, + includeConfiguredChannels: true, }); } @@ -509,6 +510,11 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] { label: "Gateway config", run: runGatewayConfigHealth, }), + createDoctorHealthContribution({ + id: "doctor:bundled-plugin-runtime-deps", + label: "Bundled plugin runtime deps", + run: runBundledPluginRuntimeDepsHealth, + }), createDoctorHealthContribution({ id: "doctor:auth-profiles", label: "Auth profiles", @@ -534,11 +540,6 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] { label: "Legacy plugin manifests", run: runLegacyPluginManifestHealth, }), - createDoctorHealthContribution({ - id: "doctor:bundled-plugin-runtime-deps", - label: "Bundled plugin runtime deps", - run: runBundledPluginRuntimeDepsHealth, - }), createDoctorHealthContribution({ id: "doctor:state-integrity", label: "State integrity", diff --git a/src/flows/doctor-health.ts b/src/flows/doctor-health.ts index 9a8553f4c82..a10ac64a0d5 100644 --- a/src/flows/doctor-health.ts +++ b/src/flows/doctor-health.ts @@ -44,6 +44,8 @@ export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions const configResult = await loadAndMaybeMigrateDoctorConfig({ options, confirm: (p) => prompter.confirm(p), + runtime: effectiveRuntime, + prompter, }); const { CONFIG_PATH } = await import("../config/config.js"); const ctx = { diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index e7043fc4e6e..c4c800b13de 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -328,6 +328,7 @@ function isBundledPluginConfiguredForRuntimeDeps(params: { config: OpenClawConfig; pluginId: string; pluginDir: string; + includeConfiguredChannels?: boolean; }): boolean { const plugins = normalizePluginsConfig(params.config.plugins); if (!plugins.enabled) { @@ -355,7 +356,8 @@ function isBundledPluginConfiguredForRuntimeDeps(params: { channelConfig && typeof channelConfig === "object" && !Array.isArray(channelConfig) && - (channelConfig as { enabled?: unknown }).enabled === true + (params.includeConfiguredChannels || + (channelConfig as { enabled?: unknown }).enabled === true) ) { return true; } @@ -368,6 +370,7 @@ function shouldIncludeBundledPluginRuntimeDeps(params: { pluginIds?: ReadonlySet; pluginId: string; pluginDir: string; + includeConfiguredChannels?: boolean; }): boolean { if (params.pluginIds && !params.pluginIds.has(params.pluginId)) { return false; @@ -379,6 +382,7 @@ function shouldIncludeBundledPluginRuntimeDeps(params: { config: params.config, pluginId: params.pluginId, pluginDir: params.pluginDir, + includeConfiguredChannels: params.includeConfiguredChannels, }); } @@ -386,6 +390,7 @@ function collectBundledPluginRuntimeDeps(params: { extensionsDir: string; config?: OpenClawConfig; pluginIds?: ReadonlySet; + includeConfiguredChannels?: boolean; }): { deps: RuntimeDepEntry[]; conflicts: RuntimeDepConflict[]; @@ -404,6 +409,7 @@ function collectBundledPluginRuntimeDeps(params: { pluginIds: params.pluginIds, pluginId, pluginDir, + includeConfiguredChannels: params.includeConfiguredChannels, }) ) { continue; @@ -476,6 +482,7 @@ export function scanBundledPluginRuntimeDeps(params: { packageRoot: string; config?: OpenClawConfig; pluginIds?: readonly string[]; + includeConfiguredChannels?: boolean; }): { missing: RuntimeDepEntry[]; conflicts: RuntimeDepConflict[]; @@ -491,6 +498,7 @@ export function scanBundledPluginRuntimeDeps(params: { extensionsDir, config: params.config, pluginIds: normalizePluginIdSet(params.pluginIds), + includeConfiguredChannels: params.includeConfiguredChannels, }); const missing = deps.filter( (dep) =>