From 48529f1a965c1cb894e79f9e2d544226c3cab0ee Mon Sep 17 00:00:00 2001 From: Sarah Fortune Date: Tue, 12 May 2026 16:51:27 -0700 Subject: [PATCH] feat(onboard): offer codex migration after harness install (#81192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a post-install seam so the wizard can prompt the user to import their existing Codex CLI state (skills, archived config/hooks, advisory cached plugins) through the existing `openclaw migrate codex` flow once the harness plugin is in place. Fires on both fresh installs and repair runs; the user can decline at any time. Trigger sites, both routing through one helper: - src/plugins/provider-auth-choice.ts: after `ensureCodexRuntimePluginForModelSelection` reports `installed: true`, dynamically import `offerPostInstallMigrations` and call it before the wizard moves on. - src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts: same call shape with `nonInteractive: true`, so the helper emits a hint line only and never mutates state. Helper (src/wizard/setup.post-install-migration.ts) is generic, not Codex-hardcoded — it resolves migration providers via the manifest `migrationProviders` contract, filters to providers owned by plugins the caller flags as installed in this onboarding step, runs `provider.detect`, and on TTY hands accepted runs to `migrateDefaultCommand`. All detect, prompt, and migrate failures are swallowed so onboarding never aborts on this optional offer. Also harden the Codex app-server subprocess lifecycle now that `detect()` runs from a hotter onboarding path: isolate the plugin-install `plugin/read` call (extensions/codex/src/migration/apply.ts) and have the isolated request wait for child exit with a SIGKILL fallback (extensions/codex/src/app-server/request.ts) so parents are not held open by an orphaned codex binary. Tests: - src/wizard/setup.post-install-migration.test.ts (new, 10 cases) - src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts extended with hint-call assertions and a not-required-no-offer case. --- extensions/codex/src/app-server/request.ts | 7 +- extensions/codex/src/migration/apply.ts | 1 + src/commands/codex-runtime-plugin-install.ts | 8 +- .../auth-choice.plugin-providers.test.ts | 41 +++ .../local/auth-choice.plugin-providers.ts | 35 ++- src/plugins/provider-auth-choice.ts | 34 ++- .../setup.post-install-migration.test.ts | 242 ++++++++++++++++++ src/wizard/setup.post-install-migration.ts | 159 ++++++++++++ 8 files changed, 505 insertions(+), 22 deletions(-) create mode 100644 src/wizard/setup.post-install-migration.test.ts create mode 100644 src/wizard/setup.post-install-migration.ts diff --git a/extensions/codex/src/app-server/request.ts b/extensions/codex/src/app-server/request.ts index 9e3bfb49e54a..981398982c3f 100644 --- a/extensions/codex/src/app-server/request.ts +++ b/extensions/codex/src/app-server/request.ts @@ -58,7 +58,12 @@ export async function requestCodexAppServerJson(param return await client.request(params.method, params.requestParams, { timeoutMs }); } finally { if (params.isolated) { - client.close(); + // Wait for the child to actually exit (with a SIGKILL fallback) so + // the parent process doesn't hang on an orphaned codex app-server. + // The stdio bin shim does not always propagate stdin EOF to the + // underlying codex binary, so the unref'd close() path can leave + // the child running and keep the parent's event loop alive. + await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 }); } } })(), diff --git a/extensions/codex/src/migration/apply.ts b/extensions/codex/src/migration/apply.ts index 2167b9183da5..8f0e7abf60a3 100644 --- a/extensions/codex/src/migration/apply.ts +++ b/extensions/codex/src/migration/apply.ts @@ -126,6 +126,7 @@ async function applyCodexPluginInstallItem( startOptions: appServer.start, agentDir: resolveCodexMigrationTargets(ctx).agentDir, config: ctx.config, + isolated: true, }), appCache: defaultCodexAppInventoryCache, appCacheKey, diff --git a/src/commands/codex-runtime-plugin-install.ts b/src/commands/codex-runtime-plugin-install.ts index 4341a97f6a88..073a77db6e99 100644 --- a/src/commands/codex-runtime-plugin-install.ts +++ b/src/commands/codex-runtime-plugin-install.ts @@ -9,7 +9,7 @@ import type { RuntimeEnv } from "../runtime.js"; import { resolveUserPath } from "../utils.js"; import type { WizardPrompter } from "../wizard/prompts.js"; -const CODEX_RUNTIME_PLUGIN_ID = "codex"; +export const CODEX_RUNTIME_PLUGIN_ID = "codex"; const CODEX_RUNTIME_PLUGIN_LABEL = "Codex"; const CODEX_RUNTIME_PLUGIN_NPM_SPEC = "@openclaw/codex"; @@ -49,7 +49,11 @@ export async function ensureCodexRuntimePluginForModelSelection(params: { workspaceDir?: string; }): Promise { if (!selectedModelShouldEnsureCodexRuntimePlugin({ cfg: params.cfg, model: params.model })) { - return { cfg: params.cfg, required: false, installed: false }; + return { + cfg: params.cfg, + required: false, + installed: false, + }; } const existingRecords = await loadInstalledPluginIndexInstallRecords({ env: process.env }); if (isInstalledRecordPresentOnDisk(existingRecords[CODEX_RUNTIME_PLUGIN_ID], process.env)) { diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts index e9d774092153..20eaa4fe6851 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts @@ -13,8 +13,13 @@ const ensureCodexRuntimePluginForModelSelection = vi.hoisted(() => ), ); vi.mock("../../codex-runtime-plugin-install.js", () => ({ + CODEX_RUNTIME_PLUGIN_ID: "codex", ensureCodexRuntimePluginForModelSelection, })); +const offerPostInstallMigrations = vi.hoisted(() => vi.fn(async () => {})); +vi.mock("../../../wizard/setup.post-install-migration.js", () => ({ + offerPostInstallMigrations, +})); const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => undefined)); vi.mock("../../../plugins/provider-auth-choice-preference.js", () => ({ resolvePreferredProviderForAuthChoice, @@ -47,6 +52,7 @@ beforeEach(() => { required: false, installed: false, })); + offerPostInstallMigrations.mockClear(); }); function createRuntime() { @@ -270,5 +276,40 @@ describe("applyNonInteractivePluginProviderChoice", () => { expect(ensureInput.runtime).toBe(runtime); expectWorkspaceDir(ensureInput.workspaceDir); expect(result).toBe(installedConfig); + expect(offerPostInstallMigrations).toHaveBeenCalledOnce(); + const migrationInput = mockArg(offerPostInstallMigrations); + expect(migrationInput.config).toBe(installedConfig); + expect(migrationInput.installedPluginIds).toEqual(["codex"]); + expect(migrationInput.nonInteractive).toBe(true); + }); + + it("does not offer post-install migration when Codex is not required for the selected model", async () => { + const runtime = createRuntime(); + const selectedConfig = { + agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + } as OpenClawConfig; + const runNonInteractive = vi.fn(async () => selectedConfig); + ensureCodexRuntimePluginForModelSelection.mockResolvedValue({ + cfg: selectedConfig, + required: false, + installed: false, + }); + resolvePluginProviders.mockReturnValue([{ id: "openai", pluginId: "openai" }] as never); + resolveProviderPluginChoice.mockReturnValue({ + provider: { id: "openai", pluginId: "openai", label: "OpenAI" }, + method: { runNonInteractive }, + }); + + await applyNonInteractivePluginProviderChoice({ + nextConfig: { agents: { defaults: {} } } as OpenClawConfig, + authChoice: "openai-api-key", + opts: {} as never, + runtime: runtime as never, + baseConfig: { agents: { defaults: {} } } as OpenClawConfig, + resolveApiKey: vi.fn(), + toApiKeyCredential: vi.fn(), + }); + + expect(offerPostInstallMigrations).not.toHaveBeenCalled(); }); }); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts index 32a78587d17f..b62495efb9d8 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts @@ -18,7 +18,10 @@ import type { import type { RuntimeEnv } from "../../../runtime.js"; import { createLazyRuntimeSurface } from "../../../shared/lazy-runtime.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; -import { ensureCodexRuntimePluginForModelSelection } from "../../codex-runtime-plugin-install.js"; +import { + CODEX_RUNTIME_PLUGIN_ID, + ensureCodexRuntimePluginForModelSelection, +} from "../../codex-runtime-plugin-install.js"; import type { OnboardOptions } from "../../onboard-types.js"; const PROVIDER_PLUGIN_CHOICE_PREFIX = "provider-plugin:"; @@ -201,13 +204,27 @@ export async function applyNonInteractivePluginProviderChoice(params: { if (!selectedModel) { return result; } - return ( - await ensureCodexRuntimePluginForModelSelection({ - cfg: result, - model: selectedModel, - prompter: createNonInteractivePluginInstallPrompter(params.runtime), + const nonInteractivePrompter = createNonInteractivePluginInstallPrompter(params.runtime); + const codexInstall = await ensureCodexRuntimePluginForModelSelection({ + cfg: result, + model: selectedModel, + prompter: nonInteractivePrompter, + runtime: params.runtime, + workspaceDir, + }); + if (codexInstall.installed) { + // Non-interactive onboarding never auto-applies migration; emit a hint so + // the operator knows Codex CLI state is available to import deliberately. + // Gated on installed (not freshlyInstalled) so repair runs against an + // already-present harness still surface the hint. + const { offerPostInstallMigrations } = + await import("../../../wizard/setup.post-install-migration.js"); + await offerPostInstallMigrations({ + config: codexInstall.cfg, runtime: params.runtime, - workspaceDir, - }) - ).cfg; + installedPluginIds: [CODEX_RUNTIME_PLUGIN_ID], + nonInteractive: true, + }); + } + return codexInstall.cfg; } diff --git a/src/plugins/provider-auth-choice.ts b/src/plugins/provider-auth-choice.ts index b2052344d1a7..2412b1b89630 100644 --- a/src/plugins/provider-auth-choice.ts +++ b/src/plugins/provider-auth-choice.ts @@ -153,18 +153,32 @@ async function applyDefaultModelFromAuthChoice(params: { preserveExistingPrimary: params.preserveExistingDefaultModel === true, }); if (!preservesDifferentPrimary) { - const { ensureCodexRuntimePluginForModelSelection } = + const { CODEX_RUNTIME_PLUGIN_ID, ensureCodexRuntimePluginForModelSelection } = await import("../commands/codex-runtime-plugin-install.js"); - nextConfig = ( - await ensureCodexRuntimePluginForModelSelection({ - cfg: nextConfig, - model: params.selectedModel, - prompter: params.prompter, - runtime: params.runtime, - ...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}), - }) - ).cfg; + const codexInstall = await ensureCodexRuntimePluginForModelSelection({ + cfg: nextConfig, + model: params.selectedModel, + prompter: params.prompter, + runtime: params.runtime, + ...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}), + }); + nextConfig = codexInstall.cfg; await params.runSelectedModelHook(nextConfig); + if (codexInstall.installed) { + // Offer Codex CLI state migration whenever the harness is in place for + // the selected model, regardless of whether this run was a fresh install + // or a repair against an already-present harness. The user can always + // decline the prompt; surfacing it again costs nothing if there is no + // migratable state to find. + const { offerPostInstallMigrations } = + await import("../wizard/setup.post-install-migration.js"); + await offerPostInstallMigrations({ + config: nextConfig, + runtime: params.runtime, + prompter: params.prompter, + installedPluginIds: [CODEX_RUNTIME_PLUGIN_ID], + }); + } } await noteDefaultModelResult({ previousPrimary, diff --git a/src/wizard/setup.post-install-migration.test.ts b/src/wizard/setup.post-install-migration.test.ts new file mode 100644 index 000000000000..9c8c77d7ec32 --- /dev/null +++ b/src/wizard/setup.post-install-migration.test.ts @@ -0,0 +1,242 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { createNonExitingRuntime } from "../runtime.js"; +import type { WizardPrompter } from "./prompts.js"; + +const ensureStandaloneMigrationProviderRegistryLoaded = vi.hoisted(() => vi.fn()); +const resolvePluginMigrationProviders = vi.hoisted(() => vi.fn(() => [] as unknown[])); +vi.mock("../plugins/migration-provider-runtime.js", () => ({ + ensureStandaloneMigrationProviderRegistryLoaded, + resolvePluginMigrationProviders, +})); + +const resolveManifestContractRuntimePluginResolution = vi.hoisted(() => + vi.fn((_params: { contract: string; value?: string }) => ({ + pluginIds: [] as string[], + bundledCompatPluginIds: [] as string[], + })), +); +vi.mock("../plugins/manifest-contract-runtime.js", () => ({ + resolveManifestContractRuntimePluginResolution, +})); + +const createMigrationLogger = vi.hoisted(() => + vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), +); +vi.mock("../commands/migrate/context.js", () => ({ createMigrationLogger })); + +const resolveStateDir = vi.hoisted(() => vi.fn(() => "/tmp/state")); +vi.mock("../config/paths.js", () => ({ resolveStateDir })); + +const migrateDefaultCommand = vi.hoisted(() => + vi.fn(async (_runtime: unknown, _opts: { provider: string }) => undefined), +); +vi.mock("../commands/migrate.js", () => ({ migrateDefaultCommand })); + +import { offerPostInstallMigrations } from "./setup.post-install-migration.js"; + +type ProviderMock = { + id: string; + label: string; + detect: ReturnType; +}; + +function buildProvider(overrides: Partial = {}): ProviderMock { + return { + id: "codex", + label: "Codex", + detect: vi.fn(async () => ({ found: true, source: "/home/user/.codex" })), + ...overrides, + }; +} + +function setOwnership(providerId: string, owningPluginIds: string[]): void { + resolveManifestContractRuntimePluginResolution.mockImplementation((params) => { + if (params.value === providerId) { + return { pluginIds: owningPluginIds, bundledCompatPluginIds: [] }; + } + return { pluginIds: [], bundledCompatPluginIds: [] }; + }); +} + +function setProviders(providers: ProviderMock[]): void { + resolvePluginMigrationProviders.mockReturnValue(providers as unknown[]); +} + +function setTTY(isTTY: boolean): void { + Object.defineProperty(process.stdin, "isTTY", { value: isTTY, configurable: true }); +} + +function buildBaseArgs(overrides: { + prompter?: WizardPrompter; + installedPluginIds?: readonly string[]; + nonInteractive?: boolean; +}) { + return { + config: {} as OpenClawConfig, + runtime: createNonExitingRuntime(), + prompter: overrides.prompter ?? createWizardPrompter(), + installedPluginIds: overrides.installedPluginIds ?? ["codex"], + ...(overrides.nonInteractive === undefined ? {} : { nonInteractive: overrides.nonInteractive }), + }; +} + +describe("offerPostInstallMigrations", () => { + beforeEach(() => { + // clearAllMocks only resets call history; reset the implementations each + // test would customize so prior cases don't leak across this suite. + ensureStandaloneMigrationProviderRegistryLoaded.mockReset(); + resolvePluginMigrationProviders.mockReset().mockReturnValue([]); + resolveManifestContractRuntimePluginResolution.mockReset().mockReturnValue({ + pluginIds: [], + bundledCompatPluginIds: [], + }); + createMigrationLogger.mockReset().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }); + resolveStateDir.mockReset().mockReturnValue("/tmp/state"); + migrateDefaultCommand.mockReset().mockResolvedValue(undefined); + setTTY(true); + }); + + it("returns early when no plugins were installed in this onboarding step", async () => { + await offerPostInstallMigrations(buildBaseArgs({ installedPluginIds: [] })); + expect(resolvePluginMigrationProviders).not.toHaveBeenCalled(); + expect(migrateDefaultCommand).not.toHaveBeenCalled(); + }); + + it("skips providers not owned by any plugin in installedPluginIds", async () => { + const provider = buildProvider({ id: "codex" }); + setProviders([provider]); + // Ownership map reports the provider lives on the "codex" plugin, but only + // "diagnostics-otel" was installed in this run — so no offer should fire. + setOwnership("codex", ["codex"]); + + await offerPostInstallMigrations(buildBaseArgs({ installedPluginIds: ["diagnostics-otel"] })); + + expect(provider.detect).not.toHaveBeenCalled(); + expect(migrateDefaultCommand).not.toHaveBeenCalled(); + }); + + it("skips providers whose detect reports nothing found", async () => { + const provider = buildProvider({ + detect: vi.fn(async () => ({ found: false })), + }); + setProviders([provider]); + setOwnership("codex", ["codex"]); + const prompter = createWizardPrompter(); + + await offerPostInstallMigrations(buildBaseArgs({ prompter })); + + expect(provider.detect).toHaveBeenCalledOnce(); + expect(prompter.confirm).not.toHaveBeenCalled(); + expect(migrateDefaultCommand).not.toHaveBeenCalled(); + }); + + it("skips providers whose detect confidence is low", async () => { + const provider = buildProvider({ + detect: vi.fn(async () => ({ found: true, confidence: "low" as const })), + }); + setProviders([provider]); + setOwnership("codex", ["codex"]); + const prompter = createWizardPrompter(); + + await offerPostInstallMigrations(buildBaseArgs({ prompter })); + + expect(prompter.confirm).not.toHaveBeenCalled(); + expect(migrateDefaultCommand).not.toHaveBeenCalled(); + }); + + it("invokes migrateDefaultCommand when the user accepts in interactive mode", async () => { + const provider = buildProvider(); + setProviders([provider]); + setOwnership("codex", ["codex"]); + const confirm = vi.fn(async (_params: { message: string; initialValue?: boolean }) => true); + const prompter = createWizardPrompter({ + confirm: confirm as WizardPrompter["confirm"], + }); + + await offerPostInstallMigrations(buildBaseArgs({ prompter })); + + expect(confirm).toHaveBeenCalledOnce(); + expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ initialValue: false })); + expect(migrateDefaultCommand).toHaveBeenCalledOnce(); + expect(migrateDefaultCommand).toHaveBeenCalledWith(expect.anything(), { provider: "codex" }); + }); + + it("does not invoke migrateDefaultCommand when the user declines", async () => { + const provider = buildProvider(); + setProviders([provider]); + setOwnership("codex", ["codex"]); + const prompter = createWizardPrompter({ + confirm: vi.fn(async () => false) as WizardPrompter["confirm"], + }); + + await offerPostInstallMigrations(buildBaseArgs({ prompter })); + + expect(migrateDefaultCommand).not.toHaveBeenCalled(); + }); + + it("never prompts or applies in non-interactive mode", async () => { + const provider = buildProvider(); + setProviders([provider]); + setOwnership("codex", ["codex"]); + const prompter = createWizardPrompter(); + + await offerPostInstallMigrations(buildBaseArgs({ prompter, nonInteractive: true })); + + expect(prompter.confirm).not.toHaveBeenCalled(); + expect(migrateDefaultCommand).not.toHaveBeenCalled(); + }); + + it("treats a non-TTY stdin as non-interactive even when nonInteractive flag is unset", async () => { + setTTY(false); + const provider = buildProvider(); + setProviders([provider]); + setOwnership("codex", ["codex"]); + const prompter = createWizardPrompter(); + + await offerPostInstallMigrations(buildBaseArgs({ prompter })); + + expect(prompter.confirm).not.toHaveBeenCalled(); + expect(migrateDefaultCommand).not.toHaveBeenCalled(); + }); + + it("swallows migrateDefaultCommand failures so onboarding can continue", async () => { + const provider = buildProvider(); + setProviders([provider]); + setOwnership("codex", ["codex"]); + migrateDefaultCommand.mockRejectedValueOnce(new Error("boom")); + const prompter = createWizardPrompter({ + confirm: vi.fn(async () => true) as WizardPrompter["confirm"], + }); + + await expect(offerPostInstallMigrations(buildBaseArgs({ prompter }))).resolves.toBeUndefined(); + expect(migrateDefaultCommand).toHaveBeenCalledOnce(); + }); + + it("falls back to a hint when detect throws", async () => { + const provider = buildProvider({ + detect: vi.fn(async () => { + throw new Error("detect failure"); + }), + }); + setProviders([provider]); + setOwnership("codex", ["codex"]); + const prompter = createWizardPrompter(); + + await offerPostInstallMigrations(buildBaseArgs({ prompter })); + + expect(prompter.confirm).not.toHaveBeenCalled(); + expect(migrateDefaultCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/src/wizard/setup.post-install-migration.ts b/src/wizard/setup.post-install-migration.ts new file mode 100644 index 000000000000..4b57cb1b40eb --- /dev/null +++ b/src/wizard/setup.post-install-migration.ts @@ -0,0 +1,159 @@ +import { formatCliCommand } from "../cli/command-format.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import type { MigrationProviderPlugin } from "../plugins/types.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "./prompts.js"; + +export type PostInstallMigrationOptions = { + config: OpenClawConfig; + runtime: RuntimeEnv; + // Required only on interactive paths; non-interactive callers can omit it + // since the helper only emits hint lines in that mode. + prompter?: WizardPrompter; + // Plugin ids that were just newly installed. Migration offers are gated to + // providers owned by these plugins so existing on-disk plugins don't trigger + // a surprise prompt every onboarding run. + installedPluginIds: readonly string[]; + // When true, the helper only emits hint lines and never prompts or applies. + // Wire this from non-interactive onboarding paths. + nonInteractive?: boolean; +}; + +type ResolvedProviderCandidate = { + provider: MigrationProviderPlugin; + source?: string; +}; + +async function resolveCandidates(params: { + config: OpenClawConfig; + runtime: RuntimeEnv; + installedPluginIds: readonly string[]; +}): Promise { + if (params.installedPluginIds.length === 0) { + return []; + } + const [ + { ensureStandaloneMigrationProviderRegistryLoaded, resolvePluginMigrationProviders }, + { resolveManifestContractRuntimePluginResolution }, + { createMigrationLogger }, + { resolveStateDir }, + ] = await Promise.all([ + import("../plugins/migration-provider-runtime.js"), + import("../plugins/manifest-contract-runtime.js"), + import("../commands/migrate/context.js"), + import("../config/paths.js"), + ]); + ensureStandaloneMigrationProviderRegistryLoaded({ cfg: params.config }); + const installedIds = new Set(params.installedPluginIds); + const providers = resolvePluginMigrationProviders({ cfg: params.config }); + const stateDir = resolveStateDir(); + const logger = createMigrationLogger(params.runtime); + const candidates: ResolvedProviderCandidate[] = []; + for (const provider of providers) { + if (!provider.detect) { + continue; + } + // Ownership check: only offer migration for providers declared by a plugin + // that was just installed in this onboarding step. + const ownership = resolveManifestContractRuntimePluginResolution({ + cfg: params.config, + contract: "migrationProviders", + value: provider.id, + }); + if (!ownership.pluginIds.some((pluginId) => installedIds.has(pluginId))) { + continue; + } + try { + const detection = await provider.detect({ + config: params.config, + stateDir, + logger, + }); + if (!detection.found || detection.confidence === "low") { + continue; + } + candidates.push({ + provider, + ...(detection.source ? { source: detection.source } : {}), + }); + } catch (error) { + logger.debug?.( + `Post-install migration detect for ${provider.id} failed: ${formatErrorMessage(error)}`, + ); + } + } + return candidates; +} + +function describeCandidate(candidate: ResolvedProviderCandidate): string { + const parts = [candidate.provider.label]; + if (candidate.source) { + parts.push(`at ${candidate.source}`); + } + return parts.join(" "); +} + +function logMigrationHint(runtime: RuntimeEnv, candidate: ResolvedProviderCandidate): void { + const command = formatCliCommand(`openclaw migrate ${candidate.provider.id} --dry-run`); + runtime.log(`Detected ${describeCandidate(candidate)}. Preview migration with ${command}.`); +} + +/** + * Offer interactive migration for any migration provider owned by a plugin + * that was just installed during onboarding. In non-interactive mode this is + * a no-op apart from a hint line so scripted setups never mutate state + * unexpectedly. The actual migration UI (skill/plugin checkboxes, confirm + * prompt) is owned by `openclaw migrate `; this helper only owns + * the gate prompt. + */ +export async function offerPostInstallMigrations( + params: PostInstallMigrationOptions, +): Promise { + const candidates = await resolveCandidates({ + config: params.config, + runtime: params.runtime, + installedPluginIds: params.installedPluginIds, + }); + if (candidates.length === 0) { + return; + } + const prompter = params.prompter; + const interactive = + params.nonInteractive !== true && process.stdin.isTTY && prompter !== undefined; + for (const candidate of candidates) { + if (!interactive || !prompter) { + logMigrationHint(params.runtime, candidate); + continue; + } + const description = describeCandidate(candidate); + let accepted = false; + try { + accepted = await prompter.confirm({ + message: `Migrate ${description} into this agent now?`, + initialValue: false, + }); + } catch (error) { + // Prompt cancellations / non-TTY refusals fall back to the hint path so + // onboarding never aborts on an optional offer. + params.runtime.log( + `Skipping ${candidate.provider.label} migration prompt: ${formatErrorMessage(error)}`, + ); + logMigrationHint(params.runtime, candidate); + continue; + } + if (!accepted) { + logMigrationHint(params.runtime, candidate); + continue; + } + try { + const { migrateDefaultCommand } = await import("../commands/migrate.js"); + await migrateDefaultCommand(params.runtime, { provider: candidate.provider.id }); + } catch (error) { + params.runtime.log( + `${candidate.provider.label} migration failed: ${formatErrorMessage(error)}. ` + + `Re-run with ${formatCliCommand(`openclaw migrate ${candidate.provider.id} --dry-run`)} to inspect.`, + ); + } + } +}