mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-05 01:21:15 +00:00
feat(onboard): offer codex migration after harness install (#81192)
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.
This commit is contained in:
@@ -58,7 +58,12 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
|
||||
return await client.request<T>(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 });
|
||||
}
|
||||
}
|
||||
})(),
|
||||
|
||||
@@ -126,6 +126,7 @@ async function applyCodexPluginInstallItem(
|
||||
startOptions: appServer.start,
|
||||
agentDir: resolveCodexMigrationTargets(ctx).agentDir,
|
||||
config: ctx.config,
|
||||
isolated: true,
|
||||
}),
|
||||
appCache: defaultCodexAppInventoryCache,
|
||||
appCacheKey,
|
||||
|
||||
@@ -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<CodexRuntimePluginInstallResult> {
|
||||
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)) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
242
src/wizard/setup.post-install-migration.test.ts
Normal file
242
src/wizard/setup.post-install-migration.test.ts
Normal file
@@ -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<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function buildProvider(overrides: Partial<ProviderMock> = {}): 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();
|
||||
});
|
||||
});
|
||||
159
src/wizard/setup.post-install-migration.ts
Normal file
159
src/wizard/setup.post-install-migration.ts
Normal file
@@ -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<ResolvedProviderCandidate[]> {
|
||||
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 <provider>`; this helper only owns
|
||||
* the gate prompt.
|
||||
*/
|
||||
export async function offerPostInstallMigrations(
|
||||
params: PostInstallMigrationOptions,
|
||||
): Promise<void> {
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user