From c7a91f9632145565297d257bcbf6c02fee955955 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 1 May 2026 03:21:13 -0700 Subject: [PATCH] fix(onboard): run noninteractive migration imports --- CHANGELOG.md | 1 + .../onboard-non-interactive.gateway.test.ts | 106 ++++++++++++++++++ src/commands/onboard-non-interactive.ts | 87 ++++++++++++++ 3 files changed, 194 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cfabf3b6ce..f865e3e2bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - WhatsApp: stage `qrcode` through root mirrored runtime dependencies so packaged QR pairing can render from staged plugin-runtime-deps installs. Fixes #75394. Thanks @FelipeX2001. - Discord/voice: apply per-channel Discord `systemPrompt` overrides to voice transcript turns by forwarding the trusted channel prompt through the voice agent run. Fixes #47095. Thanks @qearlyao. - Gateway/agent: reject strict `openclaw agent --deliver` requests with missing delivery targets before starting the agent run, so users do not wait for a completed turn that cannot send anywhere. Thanks @vincentkoc. +- Setup/import: honor non-interactive `--import-from` onboarding flags by running the migration import path instead of silently completing normal setup without importing anything. Thanks @vincentkoc. - Discord/voice: run voice-channel turns under a voice-output policy that hides the agent `tts` tool and asks for spoken reply text, so `/vc join` sessions synthesize and play agent replies instead of ending with `NO_REPLY`. Fixes #61536. Thanks @aounakram. - Plugins/runtime-deps: include packaged OpenClaw identity in bundled plugin loader cache keys, so same-path package upgrades stop reusing stale versioned runtime-deps mirrors. Fixes #75045. Thanks @sahilsatralkar. - Plugin SDK: restore reply-prefix and reply-pipeline helpers on the deprecated root/compat SDK surface so external plugins still using `openclaw/plugin-sdk` do not fail message dispatch after update. Fixes #75171. Thanks @zhangxiliang. diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index 215015ce7c4..e8e6e30e8da 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { MigrationApplyResult, MigrationPlan } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; import { makeTempWorkspace } from "../test-helpers/workspace.js"; import { captureEnv } from "../test-utils/env.js"; @@ -15,6 +16,14 @@ type InstallGatewayDaemonResult = Awaited vi.fn(async (): Promise => ({ installed: true })), ); +const createPreMigrationBackupMock = vi.hoisted(() => vi.fn(async () => undefined)); +const migrationProviderMock = vi.hoisted(() => ({ + id: "hermes", + label: "Hermes", + description: "Hermes migration provider", + plan: vi.fn(), + apply: vi.fn(), +})); const healthCommandMock = vi.hoisted(() => vi.fn(async () => {})); const gatewayServiceMock = vi.hoisted(() => ({ label: "LaunchAgent", @@ -136,6 +145,20 @@ vi.mock("./post-config-runtime-deps.js", () => ({ preparePostConfigBundledRuntimeDeps: preparePostConfigBundledRuntimeDepsMock, })); +vi.mock("../plugins/migration-provider-runtime.js", () => ({ + resolvePluginMigrationProviders: () => [migrationProviderMock], + resolvePluginMigrationProvider: ({ providerId }: { providerId: string }) => + providerId === migrationProviderMock.id ? migrationProviderMock : undefined, +})); + +vi.mock("./migrate/apply.js", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + createPreMigrationBackup: createPreMigrationBackupMock, + }; +}); + vi.mock("../daemon/service.js", () => ({ resolveGatewayService: () => gatewayServiceMock, })); @@ -316,7 +339,11 @@ describe("onboard (non-interactive): gateway and remote auth", () => { afterEach(() => { waitForGatewayReachableMock = undefined; testConfigStore.clear(); + ensureWorkspaceAndSessionsMock.mockClear(); installGatewayDaemonNonInteractiveMock.mockClear(); + createPreMigrationBackupMock.mockClear(); + migrationProviderMock.plan.mockReset(); + migrationProviderMock.apply.mockReset(); healthCommandMock.mockClear(); gatewayServiceMock.isLoaded.mockClear(); gatewayServiceMock.readRuntime.mockClear(); @@ -399,6 +426,85 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }); }, 60_000); + it("applies non-interactive migration imports instead of ignoring import flags", async () => { + await withStateDir("state-noninteractive-import-", async (stateDir) => { + const source = path.join(stateDir, "hermes-home"); + const workspace = path.join(stateDir, "openclaw"); + const planned: MigrationPlan = { + providerId: "hermes", + source, + target: workspace, + summary: { + total: 1, + planned: 1, + migrated: 0, + skipped: 0, + conflicts: 0, + errors: 0, + sensitive: 0, + }, + items: [ + { + id: "workspace:AGENTS.md", + kind: "workspace", + action: "copy", + status: "planned", + source: path.join(source, "AGENTS.md"), + target: path.join(workspace, "AGENTS.md"), + }, + ], + }; + const applied: MigrationApplyResult = { + ...planned, + summary: { + ...planned.summary, + planned: 0, + migrated: 1, + }, + items: planned.items.map((item) => ({ ...item, status: "migrated" as const })), + }; + migrationProviderMock.plan.mockResolvedValueOnce(planned); + migrationProviderMock.apply.mockResolvedValueOnce(applied); + + await runNonInteractiveSetup( + { + nonInteractive: true, + mode: "local", + workspace, + authChoice: "skip", + skipHealth: true, + importFrom: "hermes", + importSource: source, + }, + runtime, + ); + + expect(migrationProviderMock.plan).toHaveBeenCalledWith( + expect.objectContaining({ + source, + includeSecrets: false, + overwrite: false, + config: expect.objectContaining({ + agents: expect.objectContaining({ + defaults: expect.objectContaining({ workspace }), + }), + }), + }), + ); + expect(migrationProviderMock.apply).toHaveBeenCalledWith( + expect.objectContaining({ + source, + reportDir: expect.stringContaining(path.join(stateDir, "migration", "hermes")), + }), + planned, + ); + expect(readTestConfig().agents?.defaults?.workspace).toBe(workspace); + expect(ensureWorkspaceAndSessionsMock).not.toHaveBeenCalled(); + expect(preparePostConfigBundledRuntimeDepsMock).not.toHaveBeenCalled(); + expect(healthCommandMock).not.toHaveBeenCalled(); + }); + }, 60_000); + it("writes gateway.remote url/token", async () => { await withStateDir("state-remote-", async (_stateDir) => { const port = getPseudoPort(30_000); diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index ed09c371a17..1cb6cdadd3e 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -1,12 +1,94 @@ import { formatCliCommand } from "../cli/command-format.js"; +import { replaceConfigFile } from "../config/config.js"; import { readConfigFileSnapshot } from "../config/io.js"; +import { logConfigUpdated } from "../config/logging.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; import { runNonInteractiveLocalSetup } from "./onboard-non-interactive/local.js"; import { runNonInteractiveRemoteSetup } from "./onboard-non-interactive/remote.js"; import type { OnboardOptions } from "./onboard-types.js"; +function createNonInteractiveMigrationPrompter(runtime: RuntimeEnv): WizardPrompter { + const unavailable = (message: string): never => { + throw new Error( + `Non-interactive migration import needs explicit flags before prompting: ${message}`, + ); + }; + return { + async intro(title) { + runtime.log(title); + }, + async outro(message) { + runtime.log(message); + }, + async note(message, title) { + runtime.log(title ? `${title}\n${message}` : message); + }, + async select(params) { + unavailable(params.message); + }, + async multiselect(params) { + unavailable(params.message); + }, + async text(params) { + unavailable(params.message); + }, + async confirm(params) { + unavailable(params.message); + }, + progress(label) { + runtime.log(label); + return { + update(message) { + runtime.log(message); + }, + stop(message) { + if (message) { + runtime.log(message); + } + }, + }; + }, + }; +} + +async function runNonInteractiveMigrationImport(params: { + opts: OnboardOptions; + runtime: RuntimeEnv; + baseConfig: OpenClawConfig; + baseHash?: string; +}) { + const providerId = params.opts.importFrom?.trim(); + if (!providerId) { + params.runtime.error("--import-from is required for non-interactive migration import."); + params.runtime.exit(1); + return; + } + const { detectSetupMigrationSources, runSetupMigrationImport } = + await import("../wizard/setup.migration-import.js"); + const detections = await detectSetupMigrationSources({ + config: params.baseConfig, + runtime: params.runtime, + }); + await runSetupMigrationImport({ + opts: { ...params.opts, importFrom: providerId, nonInteractive: true }, + baseConfig: params.baseConfig, + detections, + prompter: createNonInteractiveMigrationPrompter(params.runtime), + runtime: params.runtime, + async commitConfigFile(config) { + await replaceConfigFile({ + nextConfig: config, + ...(params.baseHash !== undefined ? { baseHash: params.baseHash } : {}), + }); + logConfigUpdated(params.runtime); + return config; + }, + }); +} + export async function runNonInteractiveSetup( opts: OnboardOptions, runtime: RuntimeEnv = defaultRuntime, @@ -32,6 +114,11 @@ export async function runNonInteractiveSetup( return; } + if (opts.importFrom || opts.importSource || opts.importSecrets || opts.flow === "import") { + await runNonInteractiveMigrationImport({ opts, runtime, baseConfig, baseHash: snapshot.hash }); + return; + } + if (mode === "remote") { await runNonInteractiveRemoteSetup({ opts, runtime, baseConfig, baseHash: snapshot.hash }); return;