mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: serialize onboarding and plugin installation (#121482)
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
committed by
GitHub
parent
f6298bf84d
commit
da2684b890
@@ -3,7 +3,7 @@
|
||||
71522995185b956a0cc4927a472cc8d1153e5e998874bfd9a750513175174713 module/account-id
|
||||
2ccf6bdc0cae7e136a0ed9feba2cab10047f432fb1c50c4f711df1a5cc6e5414 module/account-resolution
|
||||
4fbb1c87e99399f842a20d75d5e35a4b7064a1b7f02115c23f9a2a7cdcfb57ee module/agent-config-primitives
|
||||
fe78f6361092d29375d3ef0306aab785ea51a0f3b05a73cd826d9563e1beedfc module/agent-harness
|
||||
d0773747c5392c38c8406ea218cc5686aaaa8578fce210008394b4ba683d0a99 module/agent-harness
|
||||
61b7d640db1f066c300fef858db8bd729cb46722794405c0159d14f9dbe86aa8 module/agent-harness-runtime
|
||||
cdf661f6e5b9118ae3b33f0c5e4aec1351ad89b0b61e8b3e02abb7409b4e16da module/agent-media-payload
|
||||
c0b60f2e239bc88e8dadbf7b9583c126822678f5d70c880f90f1d447b00e5170 module/agent-runtime
|
||||
|
||||
@@ -95,7 +95,8 @@ const mocks = vi.hoisted(() => ({
|
||||
prepareWorkspaceStateDeletion: vi.fn((workspaceDir: string) => ({ workspaceDir })),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/fs-safe.js", () => ({
|
||||
vi.mock("../infra/fs-safe.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../infra/fs-safe.js")>()),
|
||||
movePathToTrash: mocks.movePathToTrash,
|
||||
}));
|
||||
|
||||
@@ -147,6 +148,44 @@ function expectedTrashSourcePath(targetPath: string): string {
|
||||
}
|
||||
|
||||
describe("handleReset", () => {
|
||||
it("rejects full-reset workspaces that contain the active onboarding lock", async () => {
|
||||
const homeDir = tempDirs.make("openclaw-reset-lock-overlap-");
|
||||
const stateDir = path.join(homeDir, "state");
|
||||
const migrationDir = path.join(stateDir, "migration");
|
||||
const migrationAlias = path.join(homeDir, "migration-alias");
|
||||
const lockSidecar = path.join(migrationDir, "onboarding.lock-target.lock");
|
||||
const lockSidecarViaAlias = path.join(migrationAlias, "onboarding.lock-target.lock");
|
||||
const configPath = path.join(stateDir, "openclaw.json");
|
||||
fs.mkdirSync(migrationDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, "{}\n");
|
||||
fs.symlinkSync(migrationDir, migrationAlias, process.platform === "win32" ? "junction" : "dir");
|
||||
const runtime = { log: vi.fn() } as unknown as RuntimeEnv;
|
||||
|
||||
for (const workspaceDir of [
|
||||
homeDir,
|
||||
stateDir,
|
||||
migrationDir,
|
||||
migrationAlias,
|
||||
lockSidecar,
|
||||
lockSidecarViaAlias,
|
||||
]) {
|
||||
await expect(
|
||||
withEnvAsync(
|
||||
{
|
||||
HOME: homeDir,
|
||||
OPENCLAW_HOME: homeDir,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_CONFIG_PATH: configPath,
|
||||
},
|
||||
async () => await handleReset("full", workspaceDir, runtime),
|
||||
),
|
||||
).rejects.toThrow("overlaps the active onboarding lock directory");
|
||||
}
|
||||
|
||||
expect(mocks.movePathToTrash).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteWorkspaceState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses active profile paths for destructive reset targets", async () => {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-reset-profile-"));
|
||||
const profileStateDir = path.join(homeDir, ".openclaw-work");
|
||||
|
||||
@@ -42,7 +42,11 @@ import {
|
||||
resolveBrowserOpenCommand,
|
||||
} from "../infra/browser-open.js";
|
||||
import { detectBinary } from "../infra/detect-binary.js";
|
||||
import { movePathToTrash } from "../infra/fs-safe.js";
|
||||
import {
|
||||
canonicalPathFromExistingAncestor,
|
||||
isPathInside,
|
||||
movePathToTrash,
|
||||
} from "../infra/fs-safe.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { resolveConfigDir, shortenHomeInString, shortenHomePath, sleep } from "../utils.js";
|
||||
import { VERSION } from "../version.js";
|
||||
@@ -308,8 +312,30 @@ async function resolveMoveToTrashAllowedRoots(targetPath: string): Promise<strin
|
||||
return uniqueStrings(allowedRoots);
|
||||
}
|
||||
|
||||
async function assertFullResetPreservesOnboardingLock(workspaceDir: string): Promise<void> {
|
||||
const [workspacePath, migrationDir] = await Promise.all([
|
||||
canonicalPathFromExistingAncestor(path.resolve(workspaceDir)),
|
||||
canonicalPathFromExistingAncestor(path.join(resolveStateDir(), "migration")),
|
||||
]);
|
||||
if (
|
||||
workspacePath === migrationDir ||
|
||||
isPathInside(workspacePath, migrationDir) ||
|
||||
isPathInside(migrationDir, workspacePath)
|
||||
) {
|
||||
throw new Error(
|
||||
"Full reset workspace overlaps the active onboarding lock directory. " +
|
||||
"Choose a workspace outside the OpenClaw state migration directory or use a narrower reset scope.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes onboarding-managed state according to the selected reset scope. */
|
||||
export async function handleReset(scope: ResetScope, workspaceDir: string, runtime: RuntimeEnv) {
|
||||
if (scope === "full") {
|
||||
// Validate before moving config or credentials so an unsafe full reset has
|
||||
// no partial destructive effects and cannot discard its own lock sidecar.
|
||||
await assertFullResetPreservesOnboardingLock(workspaceDir);
|
||||
}
|
||||
await moveToTrash(resolveConfigPath(), runtime);
|
||||
if (scope === "config") {
|
||||
return;
|
||||
|
||||
@@ -15,6 +15,9 @@ import type { installGatewayDaemonNonInteractive } from "./onboard-non-interacti
|
||||
|
||||
const ensureWorkspaceAndSessionsMock = vi.fn(async (..._args: unknown[]) => {});
|
||||
const testConfigStore = new Map<string, OpenClawConfig>();
|
||||
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
||||
const pluginLifecycleLeaseState = vi.hoisted(() => ({ depth: 0 }));
|
||||
const configWritePluginLeaseDepths: number[] = [];
|
||||
type InstallGatewayDaemonResult = Awaited<ReturnType<typeof installGatewayDaemonNonInteractive>>;
|
||||
const installGatewayDaemonNonInteractiveMock = vi.hoisted(() =>
|
||||
vi.fn(async (): Promise<InstallGatewayDaemonResult> => ({ installed: true })),
|
||||
@@ -52,33 +55,59 @@ function readTestConfig<T = OpenClawConfig>(): T {
|
||||
return (testConfigStore.get(resolveTestConfigPath()) ?? {}) as T;
|
||||
}
|
||||
|
||||
readConfigFileSnapshotMock.mockImplementation(async () => {
|
||||
const configPath = resolveTestConfigPath();
|
||||
const config = testConfigStore.get(configPath);
|
||||
if (config) {
|
||||
const raw = `${JSON.stringify(config, null, 2)}\n`;
|
||||
return {
|
||||
exists: true,
|
||||
valid: true,
|
||||
config,
|
||||
sourceConfig: config,
|
||||
raw,
|
||||
hash: "test-config-hash",
|
||||
};
|
||||
}
|
||||
return {
|
||||
exists: false,
|
||||
valid: true,
|
||||
config: {},
|
||||
sourceConfig: {},
|
||||
raw: null,
|
||||
hash: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../config/io.js", () => ({
|
||||
createConfigIO: () => ({
|
||||
configPath: resolveTestConfigPath(),
|
||||
}),
|
||||
loadConfig: () => testConfigStore.get(resolveTestConfigPath()) ?? {},
|
||||
readConfigFileSnapshot: async () => {
|
||||
const configPath = resolveTestConfigPath();
|
||||
const config = testConfigStore.get(configPath);
|
||||
if (config) {
|
||||
const raw = `${JSON.stringify(config, null, 2)}\n`;
|
||||
return {
|
||||
exists: true,
|
||||
valid: true,
|
||||
config,
|
||||
sourceConfig: config,
|
||||
raw,
|
||||
hash: "test-config-hash",
|
||||
};
|
||||
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/plugin-lifecycle-lease.js", () => ({
|
||||
withPluginLifecycleLease: async (
|
||||
_options: unknown,
|
||||
run: (lease: {
|
||||
databasePath: string;
|
||||
signal: AbortSignal;
|
||||
assertOwned: () => void;
|
||||
assertOwnedInTransaction: () => void;
|
||||
}) => Promise<unknown>,
|
||||
) => {
|
||||
pluginLifecycleLeaseState.depth += 1;
|
||||
try {
|
||||
return await run({
|
||||
databasePath: path.join(path.dirname(resolveTestConfigPath()), "openclaw.sqlite"),
|
||||
signal: new AbortController().signal,
|
||||
assertOwned: () => {},
|
||||
assertOwnedInTransaction: () => {},
|
||||
});
|
||||
} finally {
|
||||
pluginLifecycleLeaseState.depth -= 1;
|
||||
}
|
||||
return {
|
||||
exists: false,
|
||||
valid: true,
|
||||
config: {},
|
||||
sourceConfig: {},
|
||||
raw: null,
|
||||
hash: undefined,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -95,6 +124,7 @@ vi.mock("../config/config.js", () => ({
|
||||
nextConfig: OpenClawConfig;
|
||||
writeOptions?: { allowConfigSizeDrop?: boolean; unsetPaths?: string[][] };
|
||||
}) => {
|
||||
configWritePluginLeaseDepths.push(pluginLifecycleLeaseState.depth);
|
||||
capturedReplaceConfigFileCalls.push({ nextConfig, ...(writeOptions ? { writeOptions } : {}) });
|
||||
testConfigStore.set(resolveTestConfigPath(), nextConfig);
|
||||
},
|
||||
@@ -352,6 +382,8 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||
waitForGatewayReachableMock = undefined;
|
||||
testConfigStore.clear();
|
||||
capturedReplaceConfigFileCalls.length = 0;
|
||||
configWritePluginLeaseDepths.length = 0;
|
||||
readConfigFileSnapshotMock.mockClear();
|
||||
ensureWorkspaceAndSessionsMock.mockClear();
|
||||
installGatewayDaemonNonInteractiveMock.mockClear();
|
||||
healthCommandMock.mockClear();
|
||||
@@ -360,6 +392,66 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||
readLastGatewayErrorLineMock.mockClear();
|
||||
});
|
||||
|
||||
it("serializes concurrent onboarding runs sharing one state directory", async () => {
|
||||
await withStateDir("state-concurrent-onboard-", async (stateDir) => {
|
||||
let activeWorkspaceSetups = 0;
|
||||
let maxActiveWorkspaceSetups = 0;
|
||||
let workspaceSetupCalls = 0;
|
||||
let releaseFirstSetup!: () => void;
|
||||
const firstSetupEntered = new Promise<void>((resolve) => {
|
||||
ensureWorkspaceAndSessionsMock.mockImplementation(async () => {
|
||||
workspaceSetupCalls += 1;
|
||||
activeWorkspaceSetups += 1;
|
||||
maxActiveWorkspaceSetups = Math.max(maxActiveWorkspaceSetups, activeWorkspaceSetups);
|
||||
try {
|
||||
if (workspaceSetupCalls === 1) {
|
||||
resolve();
|
||||
await new Promise<void>((release) => {
|
||||
releaseFirstSetup = release;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
activeWorkspaceSetups -= 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
const options = {
|
||||
nonInteractive: true,
|
||||
mode: "local" as const,
|
||||
workspace: path.join(stateDir, "openclaw"),
|
||||
authChoice: "skip" as const,
|
||||
skipSkills: true,
|
||||
skipHealth: true,
|
||||
installDaemon: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const first = runNonInteractiveSetup(options, runtime);
|
||||
await firstSetupEntered;
|
||||
const readsBeforeSecond = readConfigFileSnapshotMock.mock.calls.length;
|
||||
const writesBeforeSecond = capturedReplaceConfigFileCalls.length;
|
||||
const second = runNonInteractiveSetup(options, runtime);
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
|
||||
expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(readsBeforeSecond);
|
||||
expect(capturedReplaceConfigFileCalls).toHaveLength(writesBeforeSecond);
|
||||
expect(ensureWorkspaceAndSessionsMock).toHaveBeenCalledOnce();
|
||||
|
||||
releaseFirstSetup();
|
||||
await Promise.all([first, second]);
|
||||
expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(readsBeforeSecond + 1);
|
||||
expect(maxActiveWorkspaceSetups).toBe(1);
|
||||
expect(configWritePluginLeaseDepths).toHaveLength(2);
|
||||
expect(configWritePluginLeaseDepths.every((depth) => depth > 0)).toBe(true);
|
||||
} finally {
|
||||
releaseFirstSetup?.();
|
||||
ensureWorkspaceAndSessionsMock.mockImplementation(async () => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("writes the implicit workspace under a non-default state directory", async () => {
|
||||
await withStateDir("state-isolated-workspace-", async (stateDir) => {
|
||||
await runNonInteractiveSetup(
|
||||
|
||||
@@ -9,14 +9,24 @@ import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { ConfigMutationConflictError, replaceConfigFile } from "../config/config.js";
|
||||
import { readConfigFileSnapshot } from "../config/io.js";
|
||||
import { logConfigUpdated } from "../config/logging.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { withPluginLifecycleLease } from "../plugins/plugin-lifecycle-lease.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { withOpenClawStateLease } from "../state/openclaw-state-lease.js";
|
||||
import { withSetupMigrationTargetLock } from "../wizard/setup.migration-snapshot.js";
|
||||
import { createNonInteractiveLoggingPrompter } from "./non-interactive-prompter.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 isMigrationImport(opts: OnboardOptions): boolean {
|
||||
return Boolean(
|
||||
opts.importFrom || opts.importSource || opts.importSecrets || opts.flow === "import",
|
||||
);
|
||||
}
|
||||
|
||||
/** Runs a setup migration import with non-interactive prompt failures. */
|
||||
async function runNonInteractiveMigrationImport(params: {
|
||||
opts: OnboardOptions;
|
||||
@@ -80,11 +90,7 @@ async function runNonInteractiveMigrationImport(params: {
|
||||
await outcome.acknowledgePromotion?.();
|
||||
}
|
||||
|
||||
/** Runs non-interactive onboarding in local, remote, or migration-import mode. */
|
||||
export async function runNonInteractiveSetup(
|
||||
opts: OnboardOptions,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
async function runNonInteractiveSetupExclusive(opts: OnboardOptions, runtime: RuntimeEnv) {
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (snapshot.exists && !snapshot.valid) {
|
||||
// Avoid rewriting an invalid config snapshot; doctor owns recovery so setup
|
||||
@@ -110,7 +116,7 @@ export async function runNonInteractiveSetup(
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.importFrom || opts.importSource || opts.importSecrets || opts.flow === "import") {
|
||||
if (isMigrationImport(opts)) {
|
||||
// Import flow owns its own commit path because migrations may intentionally
|
||||
// shrink legacy config after extracting credentials.
|
||||
await runNonInteractiveMigrationImport({ opts, runtime, baseConfig });
|
||||
@@ -124,3 +130,33 @@ export async function runNonInteractiveSetup(
|
||||
|
||||
await runNonInteractiveLocalSetup({ opts, runtime, baseConfig, baseHash: snapshot.hash });
|
||||
}
|
||||
|
||||
/** Runs non-interactive onboarding in local, remote, or migration-import mode. */
|
||||
export async function runNonInteractiveSetup(
|
||||
opts: OnboardOptions,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
await withSetupMigrationTargetLock(resolveStateDir(), async () => {
|
||||
if (isMigrationImport(opts)) {
|
||||
// Migration must inspect freshness before opening the shared lease DB.
|
||||
await runNonInteractiveSetupExclusive(opts, runtime);
|
||||
return;
|
||||
}
|
||||
await withOpenClawStateLease(
|
||||
{
|
||||
scope: "core:onboarding",
|
||||
key: "global",
|
||||
database: { scope: "shared" },
|
||||
// Bound one run to five minutes while allowing one predecessor to finish.
|
||||
leaseMs: 5 * 60_000,
|
||||
waitMs: 10 * 60_000,
|
||||
leaseLabel: "non-interactive onboarding lease",
|
||||
operationLabel: "onboarding.non-interactive.lease",
|
||||
},
|
||||
async () =>
|
||||
await withPluginLifecycleLease({}, async () =>
|
||||
runNonInteractiveSetupExclusive(opts, runtime),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ const mocks = vi.hoisted(() => ({
|
||||
config: {},
|
||||
})),
|
||||
handleReset: vi.fn(async () => {}),
|
||||
withSetupMigrationTargetLock: vi.fn(
|
||||
async (_stateDir: string, run: () => Promise<unknown>) => await run(),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-interactive.js", () => ({
|
||||
@@ -106,6 +109,10 @@ vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({
|
||||
resolvePluginProviders: mocks.resolvePluginProviders,
|
||||
}));
|
||||
|
||||
vi.mock("../wizard/setup.migration-snapshot.js", () => ({
|
||||
withSetupMigrationTargetLock: mocks.withSetupMigrationTargetLock,
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-helpers.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./onboard-helpers.js")>()),
|
||||
DEFAULT_WORKSPACE: "~/.openclaw/workspace",
|
||||
@@ -262,13 +269,16 @@ describe("setupWizardCommand", () => {
|
||||
|
||||
await setupWizardCommand({ reset: true, nonInteractive: true, acceptRisk: true }, runtime);
|
||||
|
||||
expect(mocks.withSetupMigrationTargetLock).toHaveBeenCalledOnce();
|
||||
expect(mocks.handleReset).toHaveBeenCalledOnce();
|
||||
expect(mocks.runNonInteractiveSetup).toHaveBeenCalledOnce();
|
||||
const lockOrder = mocks.withSetupMigrationTargetLock.mock.invocationCallOrder[0];
|
||||
const resetOrder = mocks.handleReset.mock.invocationCallOrder[0];
|
||||
const setupOrder = mocks.runNonInteractiveSetup.mock.invocationCallOrder[0];
|
||||
if (resetOrder === undefined || setupOrder === undefined) {
|
||||
throw new Error("expected reset and non-interactive setup calls");
|
||||
if (lockOrder === undefined || resetOrder === undefined || setupOrder === undefined) {
|
||||
throw new Error("expected lock, reset, and non-interactive setup calls");
|
||||
}
|
||||
expect(lockOrder).toBeLessThan(resetOrder);
|
||||
expect(resetOrder).toBeLessThan(setupOrder);
|
||||
});
|
||||
|
||||
|
||||
+75
-70
@@ -7,6 +7,7 @@
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { formatInvalidPortOption } from "../cli/error-format.js";
|
||||
import { readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { isValidEnvSecretRefId } from "../config/types.secrets.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
@@ -23,6 +24,7 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { t } from "../wizard/i18n/index.js";
|
||||
import { withSetupMigrationTargetLock } from "../wizard/setup.migration-snapshot.js";
|
||||
import {
|
||||
formatDeprecatedNonInteractiveAuthChoiceError,
|
||||
isDeprecatedAuthChoice,
|
||||
@@ -563,77 +565,80 @@ export async function setupWizardCommand(
|
||||
? runInteractiveSetup
|
||||
: runGuidedOnboarding;
|
||||
|
||||
if (normalizedOpts.reset) {
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const baseConfig = snapshot.sourceConfig ?? (snapshot.valid ? snapshot.config : {});
|
||||
const resetScope: ResetScope = normalizedOpts.resetScope ?? "config+creds+sessions";
|
||||
// Every reset scope removes the config file. Validate setup against the
|
||||
// empty config and requested/default workspace that dispatch will see.
|
||||
const setupBaseConfig: OpenClawConfig = {};
|
||||
const setupWorkspaceDir = resolveUserPath(normalizedOpts.workspace ?? DEFAULT_WORKSPACE);
|
||||
const configuredWorkspace: unknown =
|
||||
normalizedOpts.workspace ?? baseConfig.agents?.defaults?.workspace;
|
||||
if (
|
||||
resetScope === "full" &&
|
||||
normalizedOpts.workspace === undefined &&
|
||||
snapshot.exists &&
|
||||
!snapshot.valid &&
|
||||
// A snapshot always carries a sourceConfig object (empty on failure), so
|
||||
// only readError distinguishes "config could not be read" from "config
|
||||
// parsed but configures no workspace", where the default is correct.
|
||||
snapshot.readError !== undefined
|
||||
) {
|
||||
rejectOption(
|
||||
runtime,
|
||||
"Cannot determine the configured workspace from an unreadable config. Pass --workspace with the workspace to remove, or use a narrower --reset-scope.",
|
||||
const runSetupAfterOptionalReset = async () => {
|
||||
if (normalizedOpts.reset) {
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const baseConfig = snapshot.sourceConfig ?? (snapshot.valid ? snapshot.config : {});
|
||||
const resetScope: ResetScope = normalizedOpts.resetScope ?? "config+creds+sessions";
|
||||
// Every reset scope removes the config file. Validate setup against the
|
||||
// empty config and requested/default workspace that dispatch will see.
|
||||
const setupBaseConfig: OpenClawConfig = {};
|
||||
const setupWorkspaceDir = resolveUserPath(normalizedOpts.workspace ?? DEFAULT_WORKSPACE);
|
||||
const configuredWorkspace: unknown =
|
||||
normalizedOpts.workspace ?? baseConfig.agents?.defaults?.workspace;
|
||||
if (
|
||||
resetScope === "full" &&
|
||||
normalizedOpts.workspace === undefined &&
|
||||
snapshot.exists &&
|
||||
!snapshot.valid &&
|
||||
// A snapshot always carries a sourceConfig object (empty on failure), so
|
||||
// only readError distinguishes "config could not be read" from "config
|
||||
// parsed but configures no workspace", where the default is correct.
|
||||
snapshot.readError !== undefined
|
||||
) {
|
||||
rejectOption(
|
||||
runtime,
|
||||
"Cannot determine the configured workspace from an unreadable config. Pass --workspace with the workspace to remove, or use a narrower --reset-scope.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
resetScope === "full" &&
|
||||
configuredWorkspace !== undefined &&
|
||||
(typeof configuredWorkspace !== "string" || !configuredWorkspace.trim())
|
||||
) {
|
||||
rejectOption(
|
||||
runtime,
|
||||
"Configured workspace is invalid. Pass --workspace with the workspace to remove, or use a narrower --reset-scope.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Non-full scopes never touch the workspace, so the fallback is only an
|
||||
// inert handleReset argument when an invalid config contains bad data.
|
||||
const workspaceDir = resolveUserPath(
|
||||
typeof configuredWorkspace === "string" && configuredWorkspace.trim()
|
||||
? configuredWorkspace
|
||||
: DEFAULT_WORKSPACE,
|
||||
);
|
||||
return;
|
||||
if (
|
||||
!(await validateResetAuthChoice({
|
||||
opts: normalizedOpts,
|
||||
runtime,
|
||||
baseConfig: setupBaseConfig,
|
||||
workspaceDir: setupWorkspaceDir,
|
||||
resetScope,
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!validateResetNonInteractiveGateway({
|
||||
opts: normalizedOpts,
|
||||
runtime,
|
||||
baseConfig: setupBaseConfig,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!validateResetMigrationImport({ opts: normalizedOpts, runtime })) {
|
||||
return;
|
||||
}
|
||||
// Reset is deliberately the final pre-dispatch step: no rejectable option
|
||||
// checks may run after user state has moved to Trash.
|
||||
await handleReset(resetScope, workspaceDir, runtime);
|
||||
}
|
||||
if (
|
||||
resetScope === "full" &&
|
||||
configuredWorkspace !== undefined &&
|
||||
(typeof configuredWorkspace !== "string" || !configuredWorkspace.trim())
|
||||
) {
|
||||
rejectOption(
|
||||
runtime,
|
||||
"Configured workspace is invalid. Pass --workspace with the workspace to remove, or use a narrower --reset-scope.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Non-full scopes never touch the workspace, so the fallback is only an
|
||||
// inert handleReset argument when an invalid config contains bad data.
|
||||
const workspaceDir = resolveUserPath(
|
||||
typeof configuredWorkspace === "string" && configuredWorkspace.trim()
|
||||
? configuredWorkspace
|
||||
: DEFAULT_WORKSPACE,
|
||||
);
|
||||
if (
|
||||
!(await validateResetAuthChoice({
|
||||
opts: normalizedOpts,
|
||||
runtime,
|
||||
baseConfig: setupBaseConfig,
|
||||
workspaceDir: setupWorkspaceDir,
|
||||
resetScope,
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!validateResetNonInteractiveGateway({
|
||||
opts: normalizedOpts,
|
||||
runtime,
|
||||
baseConfig: setupBaseConfig,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!validateResetMigrationImport({ opts: normalizedOpts, runtime })) {
|
||||
return;
|
||||
}
|
||||
// Reset is deliberately the final pre-dispatch step: no rejectable option
|
||||
// checks may run after user state has moved to Trash.
|
||||
await handleReset(resetScope, workspaceDir, runtime);
|
||||
}
|
||||
|
||||
await runSetup(normalizedOpts, runtime);
|
||||
await runSetup(normalizedOpts, runtime);
|
||||
};
|
||||
await withSetupMigrationTargetLock(resolveStateDir(), runSetupAfterOptionalReset);
|
||||
}
|
||||
|
||||
@@ -112,6 +112,13 @@ vi.mock("../plugins/installed-plugin-index-records.js", () => ({
|
||||
clearLoadInstalledPluginIndexInstallRecordsCache,
|
||||
}));
|
||||
|
||||
const withPluginLifecycleLease = vi.hoisted(() =>
|
||||
vi.fn(async (_options: unknown, run: () => Promise<unknown>) => await run()),
|
||||
);
|
||||
vi.mock("../plugins/plugin-lifecycle-lease.js", () => ({
|
||||
withPluginLifecycleLease,
|
||||
}));
|
||||
|
||||
const withTimeout = vi.hoisted(() => vi.fn(async <T>(promise: Promise<T>) => await promise));
|
||||
vi.mock("../utils/with-timeout.js", () => ({
|
||||
withTimeout,
|
||||
@@ -308,6 +315,7 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
|
||||
expect(progress).toHaveBeenCalledWith("正在安装 Demo Plugin 插件...");
|
||||
expect(note).toHaveBeenCalledWith("无法启用 Demo Plugin:blocked by allowlist。", "插件安装");
|
||||
expect(withPluginLifecycleLease).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
if (previousLocale === undefined) {
|
||||
delete process.env.OPENCLAW_LOCALE;
|
||||
@@ -873,12 +881,45 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
expect(result.status).toBe("installed");
|
||||
});
|
||||
|
||||
it("returns a timed out status and notes the retry path when npm install hangs", async () => {
|
||||
it("cancels a timed out npm install before returning and releasing its lease", async () => {
|
||||
const note = vi.fn(async () => {});
|
||||
const stop = vi.fn();
|
||||
let installSignal: AbortSignal | undefined;
|
||||
let releaseCleanup = () => {};
|
||||
let leaseActive = false;
|
||||
const cleanupGate = new Promise<void>((resolve) => {
|
||||
releaseCleanup = resolve;
|
||||
});
|
||||
let observeAbort = () => {};
|
||||
const abortObserved = new Promise<void>((resolve) => {
|
||||
observeAbort = resolve;
|
||||
});
|
||||
withPluginLifecycleLease.mockImplementationOnce(async (_options, run) => {
|
||||
leaseActive = true;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
leaseActive = false;
|
||||
}
|
||||
});
|
||||
installPluginFromNpmSpec.mockImplementationOnce(async (params: { signal?: AbortSignal }) => {
|
||||
installSignal = params.signal;
|
||||
await new Promise<void>((resolve) => {
|
||||
params.signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
observeAbort();
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
await cleanupGate;
|
||||
return { ok: false, error: "installer canceled" };
|
||||
});
|
||||
withTimeout.mockRejectedValue(new Error("timeout"));
|
||||
|
||||
const result = await ensureOnboardingPluginInstalled({
|
||||
const pendingResult = ensureOnboardingPluginInstalled({
|
||||
cfg: {},
|
||||
entry: {
|
||||
pluginId: "demo-plugin",
|
||||
@@ -897,7 +938,20 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
error: vi.fn(),
|
||||
} as never,
|
||||
});
|
||||
let returned = false;
|
||||
void pendingResult.then(() => {
|
||||
returned = true;
|
||||
});
|
||||
|
||||
await abortObserved;
|
||||
expect(installSignal?.aborted).toBe(true);
|
||||
expect(leaseActive).toBe(true);
|
||||
expect(returned).toBe(false);
|
||||
|
||||
releaseCleanup();
|
||||
const result = await pendingResult;
|
||||
|
||||
expect(leaseActive).toBe(false);
|
||||
expect(result).toEqual({
|
||||
cfg: {},
|
||||
installed: false,
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
resolveNpmInstallRecordSpec,
|
||||
} from "../plugins/installs.js";
|
||||
import type { PluginPackageInstall } from "../plugins/manifest.js";
|
||||
import { withPluginLifecycleLease } from "../plugins/plugin-lifecycle-lease.js";
|
||||
import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js";
|
||||
import { invalidatePluginRuntimeDiscoveryAfterConfigMutation } from "../plugins/registry-refresh.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
@@ -61,6 +62,7 @@ type InstallChoice = "clawhub" | "npm" | "local" | "skip";
|
||||
type InstallPluginFromClawHubResult = Awaited<
|
||||
ReturnType<(typeof import("../plugins/clawhub.js"))["installPluginFromClawHub"]>
|
||||
>;
|
||||
type InstallOutcome<T> = { status: "timed_out" } | { status: "completed"; result: T };
|
||||
const ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS = ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS + 5_000;
|
||||
|
||||
@@ -86,6 +88,15 @@ type OnboardingPluginInstallResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function incompletePluginInstall(
|
||||
cfg: OpenClawConfig,
|
||||
pluginId: string,
|
||||
status: Exclude<OnboardingPluginInstallStatus, "installed">,
|
||||
error?: string,
|
||||
): OnboardingPluginInstallResult {
|
||||
return { cfg, installed: false, pluginId, status, ...(error === undefined ? {} : { error }) };
|
||||
}
|
||||
|
||||
async function markOnboardingPluginInstalled(params: {
|
||||
cfg: OpenClawConfig;
|
||||
pluginId: string;
|
||||
@@ -242,13 +253,13 @@ function formatPortableLocalPath(localPath: string, workspaceDir?: string): stri
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function recordLocalPluginInstall(params: {
|
||||
function recordLocalPluginInstall(params: {
|
||||
cfg: OpenClawConfig;
|
||||
entry: OnboardingPluginInstallEntry;
|
||||
localPath: string;
|
||||
npmSpec?: string | null;
|
||||
workspaceDir?: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
}): OpenClawConfig {
|
||||
const sourcePath = formatPortableLocalPath(params.localPath, params.workspaceDir);
|
||||
const install = {
|
||||
pluginId: params.entry.pluginId,
|
||||
@@ -408,24 +419,14 @@ async function promptInstallChoice(params: {
|
||||
bundledLocalPath?: string | null;
|
||||
defaultChoice: InstallChoice;
|
||||
prompter: WizardPrompter;
|
||||
/** When true and only one real install source (npm *or* local, not both)
|
||||
* exists, skip the "Install <plugin>? / Skip" prompt and resolve directly
|
||||
* to that source. Useful when the caller already knows the user's intent
|
||||
* (e.g. they just picked the channel in a previous menu). */
|
||||
/** Skip the redundant prompt when the caller already chose the only viable source. */
|
||||
autoConfirmSingleSource?: boolean;
|
||||
effectiveNpmSpec?: string | null;
|
||||
effectiveClawHubSpec?: string | null;
|
||||
}): Promise<InstallChoice> {
|
||||
const rawClawHubSpec = resolveClawHubSpecForOnboarding(params.entry.install);
|
||||
const rawNpmSpec = resolveNpmSpecForOnboarding(params.entry.install);
|
||||
// When the plugin already ships bundled with the host (i.e. lives under
|
||||
// `extensions/<id>` and is discovered via `resolveBundledPluginSources`),
|
||||
// the bundled copy is the source of truth: it is version-locked to the
|
||||
// current host build and is what `defaultChoice` will pick anyway (see
|
||||
// `resolveInstallDefaultChoice`). Surfacing remote download options in that
|
||||
// case is misleading; those catalog specs only exist as fallback metadata for
|
||||
// non-bundled builds. Hide them so bundled channels like Tlon look identical
|
||||
// to Twitch / Slack in the menu.
|
||||
// Bundled plugins are version-locked to the host; remote specs are fallback metadata only.
|
||||
const clawhubSpec = params.bundledLocalPath
|
||||
? null
|
||||
: (params.effectiveClawHubSpec ?? rawClawHubSpec);
|
||||
@@ -467,8 +468,6 @@ async function promptInstallChoice(params: {
|
||||
realSources.push("local");
|
||||
}
|
||||
if (realSources.length === 1) {
|
||||
// Callers that already selected a plugin/channel can skip an extra prompt
|
||||
// when there is only one viable source.
|
||||
return expectDefined(realSources[0], "real sources entry at 0");
|
||||
}
|
||||
}
|
||||
@@ -564,6 +563,23 @@ function formatInstallErrorDetail(message: string): string {
|
||||
return `${truncateUtf16Safe(cleaned, ONBOARDING_PLUGIN_INSTALL_ERROR_MAX_CHARS - marker.length).trimEnd()}${marker}`;
|
||||
}
|
||||
|
||||
async function notePluginInstallFailure(
|
||||
prompter: WizardPrompter,
|
||||
spec: string,
|
||||
error: string,
|
||||
): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
t("wizard.plugins.installFailed", {
|
||||
spec: sanitizeTerminalText(spec),
|
||||
error: summarizeInstallError(error),
|
||||
}),
|
||||
t("wizard.plugins.returningToSelection"),
|
||||
].join("\n"),
|
||||
t("wizard.plugins.installTitle"),
|
||||
);
|
||||
}
|
||||
|
||||
const testing = { formatInstallErrorDetail, summarizeInstallError };
|
||||
|
||||
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
||||
@@ -606,20 +622,16 @@ async function finishOnboardingPluginInstall(params: {
|
||||
prompter: WizardPrompter;
|
||||
runtime: RuntimeEnv;
|
||||
install?: Parameters<typeof recordPluginInstall>[1];
|
||||
prepareConfig?: (cfg: OpenClawConfig) => OpenClawConfig;
|
||||
}): Promise<OnboardingPluginInstallResult> {
|
||||
const enableResult = await applyPluginEnablement(params);
|
||||
if (!enableResult.enabled) {
|
||||
return {
|
||||
cfg: enableResult.config,
|
||||
installed: false,
|
||||
pluginId: params.pluginId,
|
||||
status: "failed",
|
||||
};
|
||||
return incompletePluginInstall(enableResult.config, params.pluginId, "failed");
|
||||
}
|
||||
return await markOnboardingPluginInstalled({
|
||||
cfg: params.install
|
||||
? recordPluginInstall(enableResult.config, params.install)
|
||||
: enableResult.config,
|
||||
: (params.prepareConfig?.(enableResult.config) ?? enableResult.config),
|
||||
pluginId: params.pluginId,
|
||||
runtime: params.runtime,
|
||||
});
|
||||
@@ -635,36 +647,23 @@ async function installLocalOnboardingPlugin(params: {
|
||||
prompter: WizardPrompter;
|
||||
runtime: RuntimeEnv;
|
||||
}): Promise<OnboardingPluginInstallResult> {
|
||||
const enableResult = await applyPluginEnablement({
|
||||
return await finishOnboardingPluginInstall({
|
||||
cfg: params.cfg,
|
||||
pluginId: params.entry.pluginId,
|
||||
label: params.entry.label,
|
||||
prompter: params.prompter,
|
||||
runtime: params.runtime,
|
||||
});
|
||||
if (!enableResult.enabled) {
|
||||
return {
|
||||
cfg: enableResult.config,
|
||||
installed: false,
|
||||
pluginId: params.entry.pluginId,
|
||||
status: "failed",
|
||||
};
|
||||
}
|
||||
// Bundled sources already belong to the host and must not gain an install
|
||||
// record or a duplicate plugin load path.
|
||||
const cfg = pathsReferToSameDirectory(params.localPath, params.bundledLocalPath)
|
||||
? enableResult.config
|
||||
: await recordLocalPluginInstall({
|
||||
cfg: addPluginLoadPath(enableResult.config, params.localPath),
|
||||
entry: params.entry,
|
||||
localPath: params.localPath,
|
||||
npmSpec: params.npmSpec,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
return await markOnboardingPluginInstalled({
|
||||
cfg,
|
||||
pluginId: params.entry.pluginId,
|
||||
runtime: params.runtime,
|
||||
// Bundled sources already belong to the host and need no record or load path.
|
||||
prepareConfig: (cfg) =>
|
||||
pathsReferToSameDirectory(params.localPath, params.bundledLocalPath)
|
||||
? cfg
|
||||
: recordLocalPluginInstall({
|
||||
cfg: addPluginLoadPath(cfg, params.localPath),
|
||||
entry: params.entry,
|
||||
localPath: params.localPath,
|
||||
npmSpec: params.npmSpec,
|
||||
workspaceDir: params.workspaceDir,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -678,18 +677,9 @@ const PROGRESS_BAR_TICK_MS = 200;
|
||||
const PROGRESS_BAR_DURATION_MS = 10_000;
|
||||
const PROGRESS_BAR_MAX_PERCENT = 99;
|
||||
|
||||
/**
|
||||
* Maps a verbose install log line (e.g. `Downloading @scope/pkg@1.2.3 from
|
||||
* ClawHub…`, `Extracting /tmp/…/wecom-…-2026.4.23.tgz…`, `Installing to
|
||||
* /home/.../plugins/demo…`) to a short verb suitable for a progress label.
|
||||
*
|
||||
* Falls back to the raw message when no known verb prefix is recognised so
|
||||
* that unexpected log lines still surface to the user instead of being
|
||||
* swallowed.
|
||||
*/
|
||||
/** Shortens known install steps while preserving unfamiliar output verbatim. */
|
||||
function shortenInstallLabel(message: string): string {
|
||||
const trimmed = message.trim();
|
||||
// Match a leading verb phrase. Order matters: more specific phrases first.
|
||||
const patterns: Array<[RegExp, string]> = [
|
||||
[/^Downloading\b/i, "Downloading"],
|
||||
[/^Extracting\b/i, "Extracting"],
|
||||
@@ -713,23 +703,7 @@ function shortenInstallLabel(message: string): string {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a {@link WizardProgress} so the spinner message keeps a steadily
|
||||
* growing ASCII bar attached to whatever the current install step label is.
|
||||
*
|
||||
* The plugin install pipeline only emits coarse `info` log lines, so without
|
||||
* animation the spinner can sit on the same string for many seconds with no
|
||||
* visible feedback. We render a deterministic left-to-right filling bar that
|
||||
* advances linearly over {@link PROGRESS_BAR_DURATION_MS} (default 10s) up to
|
||||
* {@link PROGRESS_BAR_MAX_PERCENT} (99%). If the install takes longer than the
|
||||
* preset duration the bar simply stays pinned at 99% — never wrapping back to
|
||||
* 0% — so the user always sees forward motion and a ceiling that signals
|
||||
* "almost there, just waiting on the last bit".
|
||||
*
|
||||
* The bare label is forwarded to `progress.update` first on every label
|
||||
* change so callers/tests that assert on the unadorned message continue to
|
||||
* observe it before any decorated frame is overlaid.
|
||||
*/
|
||||
/** Adds a steadily growing, 99%-capped bar between coarse installer updates. */
|
||||
function createAnimatedInstallProgress(
|
||||
progress: { update: (message: string) => void },
|
||||
options: { totalMs?: number } = {},
|
||||
@@ -763,8 +737,7 @@ function createAnimatedInstallProgress(
|
||||
progress.update(decorate(currentLabel));
|
||||
}
|
||||
}, PROGRESS_BAR_TICK_MS);
|
||||
// Animation is decorative: never let it hold the event loop open if a caller
|
||||
// forgets to stop us (e.g. an unexpected throw bypasses the `finally`).
|
||||
// Decorative progress must never keep the process alive.
|
||||
if (typeof timer.unref === "function") {
|
||||
timer.unref();
|
||||
}
|
||||
@@ -772,8 +745,7 @@ function createAnimatedInstallProgress(
|
||||
return {
|
||||
setLabel: (label: string) => {
|
||||
currentLabel = label;
|
||||
// Always emit the bare label first so existing log/test expectations
|
||||
// continue to observe the unadorned message before any animation frame.
|
||||
// Emit the bare label before decorated animation frames.
|
||||
progress.update(label);
|
||||
},
|
||||
stop: () => {
|
||||
@@ -814,23 +786,35 @@ function isClawHubTrustWarning(message: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
async function runInstallWatchdog<T>(install: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const ownedInstallPromise = install(controller.signal);
|
||||
try {
|
||||
return await withTimeout(ownedInstallPromise, ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS);
|
||||
} catch (error) {
|
||||
if (isTimeoutError(error)) {
|
||||
// Cancel owned child processes, then retain the lifecycle lease through rollback.
|
||||
controller.abort();
|
||||
await ownedInstallPromise.catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function runOnboardingPluginInstallWithProgress(params: {
|
||||
cfg: OpenClawConfig;
|
||||
entry: OnboardingPluginInstallEntry;
|
||||
prompter: WizardPrompter;
|
||||
runtime: RuntimeEnv;
|
||||
install: (logger: {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
}) => Promise<InstallPluginResult>;
|
||||
install: (
|
||||
logger: {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
},
|
||||
signal: AbortSignal,
|
||||
) => Promise<InstallPluginResult>;
|
||||
rethrowUnexpectedErrors?: boolean;
|
||||
}): Promise<
|
||||
| { status: "timed_out" }
|
||||
| {
|
||||
status: "completed";
|
||||
result: InstallPluginResult;
|
||||
}
|
||||
> {
|
||||
}): Promise<InstallOutcome<InstallPluginResult>> {
|
||||
const safeLabel = sanitizeTerminalText(params.entry.label);
|
||||
const progress = params.prompter.progress(formatPluginInstallProgress(safeLabel));
|
||||
const animated = createAnimatedInstallProgress(progress);
|
||||
@@ -844,23 +828,23 @@ async function runOnboardingPluginInstallWithProgress(params: {
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await withTimeout(
|
||||
params.install({
|
||||
info: updateProgress,
|
||||
warn: (message) => {
|
||||
updateProgress(message);
|
||||
logInstallWarningWithSpacing(params.runtime, message);
|
||||
const result = await runInstallWatchdog((signal) =>
|
||||
params.install(
|
||||
{
|
||||
info: updateProgress,
|
||||
warn: (message) => {
|
||||
updateProgress(message);
|
||||
logInstallWarningWithSpacing(params.runtime, message);
|
||||
},
|
||||
},
|
||||
}),
|
||||
ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS,
|
||||
signal,
|
||||
),
|
||||
);
|
||||
animated.stop();
|
||||
progress.stop(
|
||||
result.ok ? formatPluginInstalled(safeLabel) : formatPluginInstallFailed(safeLabel),
|
||||
);
|
||||
return { status: "completed", result };
|
||||
} catch (error) {
|
||||
animated.stop();
|
||||
if (isTimeoutError(error)) {
|
||||
progress.stop(formatPluginInstallTimedOut(safeLabel));
|
||||
return { status: "timed_out" };
|
||||
@@ -888,16 +872,10 @@ async function installPluginFromNpmSpecWithProgress(params: {
|
||||
prompter: WizardPrompter;
|
||||
runtime: RuntimeEnv;
|
||||
trustedSourceLinkedOfficialInstall?: boolean;
|
||||
}): Promise<
|
||||
| { status: "timed_out" }
|
||||
| {
|
||||
status: "completed";
|
||||
result: InstallPluginResult;
|
||||
}
|
||||
> {
|
||||
}): Promise<InstallOutcome<InstallPluginResult>> {
|
||||
return await runOnboardingPluginInstallWithProgress({
|
||||
...params,
|
||||
install: (logger) =>
|
||||
install: (logger, signal) =>
|
||||
installPluginFromNpmSpec({
|
||||
spec: params.npmSpec,
|
||||
mode: "update",
|
||||
@@ -911,6 +889,7 @@ async function installPluginFromNpmSpecWithProgress(params: {
|
||||
: {}),
|
||||
extensionsDir: resolveDefaultPluginExtensionsDir(),
|
||||
logger,
|
||||
signal,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -921,16 +900,10 @@ async function installPluginFromNpmPackArchiveWithProgress(params: {
|
||||
archivePath: string;
|
||||
prompter: WizardPrompter;
|
||||
runtime: RuntimeEnv;
|
||||
}): Promise<
|
||||
| { status: "timed_out" }
|
||||
| {
|
||||
status: "completed";
|
||||
result: InstallPluginResult & { npmTarballName?: string };
|
||||
}
|
||||
> {
|
||||
}): Promise<InstallOutcome<InstallPluginResult & { npmTarballName?: string }>> {
|
||||
return await runOnboardingPluginInstallWithProgress({
|
||||
...params,
|
||||
install: (logger) =>
|
||||
install: (logger, signal) =>
|
||||
installPluginFromNpmPackArchive({
|
||||
archivePath: params.archivePath,
|
||||
timeoutMs: ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS,
|
||||
@@ -939,6 +912,7 @@ async function installPluginFromNpmPackArchiveWithProgress(params: {
|
||||
expectedIntegrity: params.entry.install.expectedIntegrity,
|
||||
extensionsDir: resolveDefaultPluginExtensionsDir(),
|
||||
logger,
|
||||
signal,
|
||||
}),
|
||||
// Archive overrides retain their existing unexpected-error contract.
|
||||
rethrowUnexpectedErrors: true,
|
||||
@@ -988,35 +962,15 @@ async function installPluginFromOverride(params: {
|
||||
runtime.error?.(
|
||||
`Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(displaySpec)}`,
|
||||
);
|
||||
return {
|
||||
cfg: params.cfg,
|
||||
installed: false,
|
||||
pluginId: entry.pluginId,
|
||||
status: "timed_out",
|
||||
};
|
||||
return incompletePluginInstall(params.cfg, entry.pluginId, "timed_out");
|
||||
}
|
||||
|
||||
const { result } = installOutcome;
|
||||
if (!result.ok) {
|
||||
const errorDetail = formatInstallErrorDetail(result.error);
|
||||
await prompter.note(
|
||||
[
|
||||
t("wizard.plugins.installFailed", {
|
||||
spec: sanitizeTerminalText(displaySpec),
|
||||
error: summarizeInstallError(result.error),
|
||||
}),
|
||||
t("wizard.plugins.returningToSelection"),
|
||||
].join("\n"),
|
||||
t("wizard.plugins.installTitle"),
|
||||
);
|
||||
await notePluginInstallFailure(prompter, displaySpec, result.error);
|
||||
runtime.error?.(`Plugin install failed: ${summarizeInstallError(result.error)}`);
|
||||
return {
|
||||
cfg: params.cfg,
|
||||
installed: false,
|
||||
pluginId: entry.pluginId,
|
||||
status: "failed",
|
||||
error: errorDetail,
|
||||
};
|
||||
return incompletePluginInstall(params.cfg, entry.pluginId, "failed", errorDetail);
|
||||
}
|
||||
|
||||
const npmTarballName =
|
||||
@@ -1065,13 +1019,7 @@ async function installPluginFromClawHubSpecWithProgress(params: {
|
||||
clawhubSpec: string;
|
||||
prompter: WizardPrompter;
|
||||
runtime: RuntimeEnv;
|
||||
}): Promise<
|
||||
| { status: "timed_out" }
|
||||
| {
|
||||
status: "completed";
|
||||
result: InstallPluginFromClawHubResult;
|
||||
}
|
||||
> {
|
||||
}): Promise<InstallPluginFromClawHubResult> {
|
||||
const safeLabel = sanitizeTerminalText(params.entry.label);
|
||||
const progress = params.prompter.progress(formatPluginInstallProgress(safeLabel));
|
||||
const animated = createAnimatedInstallProgress(progress);
|
||||
@@ -1091,49 +1039,46 @@ async function installPluginFromClawHubSpecWithProgress(params: {
|
||||
|
||||
try {
|
||||
const { installPluginFromClawHub } = await import("../plugins/clawhub.js");
|
||||
const result = await withTimeout(
|
||||
installPluginFromClawHub({
|
||||
spec: params.clawhubSpec,
|
||||
timeoutMs: ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS,
|
||||
config: params.cfg,
|
||||
extensionsDir: resolveDefaultPluginExtensionsDir(),
|
||||
expectedPluginId: params.entry.pluginId,
|
||||
mode: "install",
|
||||
logger: {
|
||||
info: updateProgress,
|
||||
warn: (message) => {
|
||||
updateProgress(message);
|
||||
if (isReviewRequiredClawHubTrustWarning(message)) {
|
||||
return;
|
||||
}
|
||||
if (isClawHubTrustWarning(message)) {
|
||||
renderTrustWarning(message);
|
||||
return;
|
||||
}
|
||||
logInstallWarningWithSpacing(params.runtime, message);
|
||||
},
|
||||
},
|
||||
onClawHubRisk: async (request) => {
|
||||
animated.stop();
|
||||
progress.stop("Review ClawHub warning");
|
||||
renderTrustWarning(request.warning);
|
||||
const packageName = sanitizeTerminalText(request.packageName);
|
||||
const releaseLabel = `${packageName}@${sanitizeTerminalText(request.version)}`;
|
||||
if (request.acknowledgementKind === "type-package") {
|
||||
const answer = await params.prompter.text({
|
||||
message: `To install anyway, type the package name for "${releaseLabel}"`,
|
||||
placeholder: packageName,
|
||||
});
|
||||
return answer.trim() === packageName;
|
||||
const result = await installPluginFromClawHub({
|
||||
spec: params.clawhubSpec,
|
||||
timeoutMs: ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS,
|
||||
config: params.cfg,
|
||||
extensionsDir: resolveDefaultPluginExtensionsDir(),
|
||||
expectedPluginId: params.entry.pluginId,
|
||||
mode: "install",
|
||||
logger: {
|
||||
info: updateProgress,
|
||||
warn: (message) => {
|
||||
updateProgress(message);
|
||||
if (isReviewRequiredClawHubTrustWarning(message)) {
|
||||
return;
|
||||
}
|
||||
return await params.prompter.confirm({
|
||||
message: `Install ClawHub package "${releaseLabel}" after reviewing the warning above?`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (isClawHubTrustWarning(message)) {
|
||||
renderTrustWarning(message);
|
||||
return;
|
||||
}
|
||||
logInstallWarningWithSpacing(params.runtime, message);
|
||||
},
|
||||
}),
|
||||
ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS,
|
||||
);
|
||||
},
|
||||
onClawHubRisk: async (request) => {
|
||||
animated.stop();
|
||||
progress.stop("Review ClawHub warning");
|
||||
renderTrustWarning(request.warning);
|
||||
const packageName = sanitizeTerminalText(request.packageName);
|
||||
const releaseLabel = `${packageName}@${sanitizeTerminalText(request.version)}`;
|
||||
if (request.acknowledgementKind === "type-package") {
|
||||
const answer = await params.prompter.text({
|
||||
message: `To install anyway, type the package name for "${releaseLabel}"`,
|
||||
placeholder: packageName,
|
||||
});
|
||||
return answer.trim() === packageName;
|
||||
}
|
||||
return await params.prompter.confirm({
|
||||
message: `Install ClawHub package "${releaseLabel}" after reviewing the warning above?`,
|
||||
initialValue: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
animated.stop();
|
||||
const failureWarning = readInstallFailureWarning(result);
|
||||
if (failureWarning && !renderedTrustWarning) {
|
||||
@@ -1145,23 +1090,13 @@ async function installPluginFromClawHubSpecWithProgress(params: {
|
||||
} else {
|
||||
progress.stop(formatPluginInstallFailed(safeLabel));
|
||||
}
|
||||
return {
|
||||
status: "completed",
|
||||
result,
|
||||
};
|
||||
return result;
|
||||
} catch (error) {
|
||||
animated.stop();
|
||||
if (isTimeoutError(error)) {
|
||||
progress.stop(formatPluginInstallTimedOut(safeLabel));
|
||||
return { status: "timed_out" };
|
||||
}
|
||||
progress.stop(formatPluginInstallFailed(safeLabel));
|
||||
return {
|
||||
status: "completed",
|
||||
result: {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1185,13 +1120,15 @@ export async function ensureOnboardingPluginInstalled(params: {
|
||||
// same write-mode check as normal installs.
|
||||
assertConfigWriteAllowedInCurrentMode();
|
||||
await params.beforePersistentEffect?.();
|
||||
return await installPluginFromOverride({
|
||||
cfg: next,
|
||||
entry,
|
||||
override: installOverride,
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
return await withPluginLifecycleLease({}, async () =>
|
||||
installPluginFromOverride({
|
||||
cfg: next,
|
||||
entry,
|
||||
override: installOverride,
|
||||
prompter,
|
||||
runtime,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const allowLocal = hasGitWorkspace(workspaceDir);
|
||||
const bundledLocalPath = entry.preferRemoteInstall
|
||||
@@ -1253,203 +1190,12 @@ export async function ensureOnboardingPluginInstalled(params: {
|
||||
});
|
||||
|
||||
if (choice === "skip") {
|
||||
return {
|
||||
cfg: next,
|
||||
installed: false,
|
||||
pluginId: entry.pluginId,
|
||||
status: "skipped",
|
||||
};
|
||||
return incompletePluginInstall(next, entry.pluginId, "skipped");
|
||||
}
|
||||
assertConfigWriteAllowedInCurrentMode();
|
||||
|
||||
if (choice === "local" && localPath) {
|
||||
return await installLocalOnboardingPlugin({
|
||||
cfg: next,
|
||||
entry,
|
||||
localPath,
|
||||
bundledLocalPath,
|
||||
npmSpec,
|
||||
workspaceDir,
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
|
||||
let shouldTryNpm = choice === "npm";
|
||||
if (choice === "clawhub" && clawhubInstallSpec) {
|
||||
await params.beforePersistentEffect?.();
|
||||
const installOutcome = await installPluginFromClawHubSpecWithProgress({
|
||||
cfg: next,
|
||||
entry,
|
||||
clawhubSpec: clawhubInstallSpec,
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
|
||||
if (installOutcome.status === "timed_out") {
|
||||
await prompter.note(
|
||||
formatPluginInstallTimedOutNote(sanitizeTerminalText(clawhubInstallSpec)),
|
||||
t("wizard.plugins.installTitle"),
|
||||
);
|
||||
runtime.error?.(
|
||||
`Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(clawhubInstallSpec)}`,
|
||||
);
|
||||
return {
|
||||
cfg: next,
|
||||
installed: false,
|
||||
pluginId: entry.pluginId,
|
||||
status: "timed_out",
|
||||
};
|
||||
}
|
||||
|
||||
const { result } = installOutcome;
|
||||
if (result.ok) {
|
||||
return await finishOnboardingPluginInstall({
|
||||
cfg: next,
|
||||
pluginId: result.pluginId,
|
||||
label: entry.label,
|
||||
prompter,
|
||||
runtime,
|
||||
install: {
|
||||
pluginId: result.pluginId,
|
||||
...buildClawHubPluginInstallRecordFields(result.clawhub),
|
||||
spec: clawhubSpecs?.recordSpec ?? clawhubInstallSpec,
|
||||
installPath: result.targetDir,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
[
|
||||
t("wizard.plugins.installFailed", {
|
||||
spec: sanitizeTerminalText(clawhubInstallSpec),
|
||||
error: summarizeInstallError(result.error),
|
||||
}),
|
||||
t("wizard.plugins.returningToSelection"),
|
||||
].join("\n"),
|
||||
t("wizard.plugins.installTitle"),
|
||||
);
|
||||
const errorDetail = formatInstallErrorDetail(result.error);
|
||||
|
||||
if (!npmInstallSpec || !shouldFallbackClawHubToNpm({ result, npmSpec: npmInstallSpec })) {
|
||||
runtime.error?.(`Plugin install failed: ${summarizeInstallError(result.error)}`);
|
||||
return {
|
||||
cfg: next,
|
||||
installed: false,
|
||||
pluginId: entry.pluginId,
|
||||
status: "failed",
|
||||
error: errorDetail,
|
||||
};
|
||||
}
|
||||
|
||||
// ClawHub package/version misses for official packages can recover through
|
||||
// npm, but keep the operator in control before changing install source.
|
||||
shouldTryNpm = await prompter.confirm({
|
||||
message: t("wizard.plugins.useNpmPackageInstead", {
|
||||
spec: sanitizeTerminalText(npmInstallSpec),
|
||||
}),
|
||||
initialValue: true,
|
||||
});
|
||||
if (!shouldTryNpm) {
|
||||
runtime.error?.(`Plugin install failed: ${summarizeInstallError(result.error)}`);
|
||||
return {
|
||||
cfg: next,
|
||||
installed: false,
|
||||
pluginId: entry.pluginId,
|
||||
status: "failed",
|
||||
error: errorDetail,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldTryNpm || !npmInstallSpec) {
|
||||
await prompter.note(
|
||||
t("wizard.plugins.noRemoteInstallSource", {
|
||||
plugin: sanitizeTerminalText(entry.label),
|
||||
}),
|
||||
t("wizard.plugins.installTitle"),
|
||||
);
|
||||
runtime.error?.(
|
||||
`Plugin install failed: no remote spec available for ${sanitizeTerminalText(entry.pluginId)}.`,
|
||||
);
|
||||
return {
|
||||
cfg: next,
|
||||
installed: false,
|
||||
pluginId: entry.pluginId,
|
||||
status: "failed",
|
||||
};
|
||||
}
|
||||
|
||||
await params.beforePersistentEffect?.();
|
||||
const installOutcome = await installPluginFromNpmSpecWithProgress({
|
||||
cfg: next,
|
||||
entry,
|
||||
npmSpec: npmInstallSpec,
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
|
||||
if (installOutcome.status === "timed_out") {
|
||||
await prompter.note(
|
||||
formatPluginInstallTimedOutNote(sanitizeTerminalText(npmInstallSpec)),
|
||||
t("wizard.plugins.installTitle"),
|
||||
);
|
||||
runtime.error?.(
|
||||
`Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(npmInstallSpec)}`,
|
||||
);
|
||||
return {
|
||||
cfg: next,
|
||||
installed: false,
|
||||
pluginId: entry.pluginId,
|
||||
status: "timed_out",
|
||||
};
|
||||
}
|
||||
|
||||
const { result } = installOutcome;
|
||||
|
||||
if (result.ok) {
|
||||
return await finishOnboardingPluginInstall({
|
||||
cfg: next,
|
||||
pluginId: result.pluginId,
|
||||
label: entry.label,
|
||||
prompter,
|
||||
runtime,
|
||||
install: {
|
||||
pluginId: result.pluginId,
|
||||
source: "npm",
|
||||
spec: resolveNpmInstallRecordSpec({
|
||||
requestedSpec: npmSpecs?.recordSpec ?? npmInstallSpec,
|
||||
resolution: result.npmResolution,
|
||||
pinResolvedRegistrySpec: false,
|
||||
}),
|
||||
installPath: result.targetDir,
|
||||
version: result.version,
|
||||
...buildNpmResolutionInstallFields(result.npmResolution),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
[
|
||||
t("wizard.plugins.installFailed", {
|
||||
spec: sanitizeTerminalText(npmInstallSpec),
|
||||
error: summarizeInstallError(result.error),
|
||||
}),
|
||||
t("wizard.plugins.returningToSelection"),
|
||||
].join("\n"),
|
||||
t("wizard.plugins.installTitle"),
|
||||
);
|
||||
|
||||
if (localPath) {
|
||||
// If npm fails and a trusted local checkout exists, offer it as a recovery
|
||||
// path instead of leaving setup stuck on the remote artifact.
|
||||
const fallback = await prompter.confirm({
|
||||
message: t("wizard.plugins.useLocalPluginPathInstead", {
|
||||
path: sanitizeTerminalText(localPath),
|
||||
}),
|
||||
initialValue: true,
|
||||
});
|
||||
if (fallback) {
|
||||
return await withPluginLifecycleLease({}, async () => {
|
||||
if (choice === "local" && localPath) {
|
||||
return await installLocalOnboardingPlugin({
|
||||
cfg: next,
|
||||
entry,
|
||||
@@ -1461,16 +1207,140 @@ export async function ensureOnboardingPluginInstalled(params: {
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const errorDetail = formatInstallErrorDetail(result.error);
|
||||
runtime.error?.(`Plugin install failed: ${summarizeInstallError(result.error)}`);
|
||||
return {
|
||||
cfg: next,
|
||||
installed: false,
|
||||
pluginId: entry.pluginId,
|
||||
status: "failed",
|
||||
error: errorDetail,
|
||||
};
|
||||
let shouldTryNpm = choice === "npm";
|
||||
if (choice === "clawhub" && clawhubInstallSpec) {
|
||||
await params.beforePersistentEffect?.();
|
||||
const result = await installPluginFromClawHubSpecWithProgress({
|
||||
cfg: next,
|
||||
entry,
|
||||
clawhubSpec: clawhubInstallSpec,
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
if (result.ok) {
|
||||
return await finishOnboardingPluginInstall({
|
||||
cfg: next,
|
||||
pluginId: result.pluginId,
|
||||
label: entry.label,
|
||||
prompter,
|
||||
runtime,
|
||||
install: {
|
||||
pluginId: result.pluginId,
|
||||
...buildClawHubPluginInstallRecordFields(result.clawhub),
|
||||
spec: clawhubSpecs?.recordSpec ?? clawhubInstallSpec,
|
||||
installPath: result.targetDir,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await notePluginInstallFailure(prompter, clawhubInstallSpec, result.error);
|
||||
const errorDetail = formatInstallErrorDetail(result.error);
|
||||
|
||||
if (!npmInstallSpec || !shouldFallbackClawHubToNpm({ result, npmSpec: npmInstallSpec })) {
|
||||
runtime.error?.(`Plugin install failed: ${summarizeInstallError(result.error)}`);
|
||||
return incompletePluginInstall(next, entry.pluginId, "failed", errorDetail);
|
||||
}
|
||||
|
||||
// ClawHub package/version misses for official packages can recover through
|
||||
// npm, but keep the operator in control before changing install source.
|
||||
shouldTryNpm = await prompter.confirm({
|
||||
message: t("wizard.plugins.useNpmPackageInstead", {
|
||||
spec: sanitizeTerminalText(npmInstallSpec),
|
||||
}),
|
||||
initialValue: true,
|
||||
});
|
||||
if (!shouldTryNpm) {
|
||||
runtime.error?.(`Plugin install failed: ${summarizeInstallError(result.error)}`);
|
||||
return incompletePluginInstall(next, entry.pluginId, "failed", errorDetail);
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldTryNpm || !npmInstallSpec) {
|
||||
await prompter.note(
|
||||
t("wizard.plugins.noRemoteInstallSource", {
|
||||
plugin: sanitizeTerminalText(entry.label),
|
||||
}),
|
||||
t("wizard.plugins.installTitle"),
|
||||
);
|
||||
runtime.error?.(
|
||||
`Plugin install failed: no remote spec available for ${sanitizeTerminalText(entry.pluginId)}.`,
|
||||
);
|
||||
return incompletePluginInstall(next, entry.pluginId, "failed");
|
||||
}
|
||||
|
||||
await params.beforePersistentEffect?.();
|
||||
const installOutcome = await installPluginFromNpmSpecWithProgress({
|
||||
cfg: next,
|
||||
entry,
|
||||
npmSpec: npmInstallSpec,
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
|
||||
if (installOutcome.status === "timed_out") {
|
||||
await prompter.note(
|
||||
formatPluginInstallTimedOutNote(sanitizeTerminalText(npmInstallSpec)),
|
||||
t("wizard.plugins.installTitle"),
|
||||
);
|
||||
runtime.error?.(
|
||||
`Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(npmInstallSpec)}`,
|
||||
);
|
||||
return incompletePluginInstall(next, entry.pluginId, "timed_out");
|
||||
}
|
||||
|
||||
const { result } = installOutcome;
|
||||
|
||||
if (result.ok) {
|
||||
return await finishOnboardingPluginInstall({
|
||||
cfg: next,
|
||||
pluginId: result.pluginId,
|
||||
label: entry.label,
|
||||
prompter,
|
||||
runtime,
|
||||
install: {
|
||||
pluginId: result.pluginId,
|
||||
source: "npm",
|
||||
spec: resolveNpmInstallRecordSpec({
|
||||
requestedSpec: npmSpecs?.recordSpec ?? npmInstallSpec,
|
||||
resolution: result.npmResolution,
|
||||
pinResolvedRegistrySpec: false,
|
||||
}),
|
||||
installPath: result.targetDir,
|
||||
version: result.version,
|
||||
...buildNpmResolutionInstallFields(result.npmResolution),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await notePluginInstallFailure(prompter, npmInstallSpec, result.error);
|
||||
|
||||
if (localPath) {
|
||||
// If npm fails and a trusted local checkout exists, offer it as a recovery
|
||||
// path instead of leaving setup stuck on the remote artifact.
|
||||
const fallback = await prompter.confirm({
|
||||
message: t("wizard.plugins.useLocalPluginPathInstead", {
|
||||
path: sanitizeTerminalText(localPath),
|
||||
}),
|
||||
initialValue: true,
|
||||
});
|
||||
if (fallback) {
|
||||
return await installLocalOnboardingPlugin({
|
||||
cfg: next,
|
||||
entry,
|
||||
localPath,
|
||||
bundledLocalPath,
|
||||
npmSpec,
|
||||
workspaceDir,
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const errorDetail = formatInstallErrorDetail(result.error);
|
||||
runtime.error?.(`Plugin install failed: ${summarizeInstallError(result.error)}`);
|
||||
return incompletePluginInstall(next, entry.pluginId, "failed", errorDetail);
|
||||
});
|
||||
}
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -378,7 +378,13 @@ describe("packNpmSpecToArchive", () => {
|
||||
]),
|
||||
});
|
||||
|
||||
const result = await runPack("openclaw-plugin@1.2.3", cwd);
|
||||
const signal = new AbortController().signal;
|
||||
const result = await packNpmSpecToArchive({
|
||||
spec: "openclaw-plugin@1.2.3",
|
||||
timeoutMs: 1000,
|
||||
cwd,
|
||||
signal,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
@@ -396,6 +402,8 @@ describe("packNpmSpecToArchive", () => {
|
||||
{
|
||||
cwd,
|
||||
timeoutMs: 300_000,
|
||||
signal,
|
||||
killProcessTree: true,
|
||||
env: {
|
||||
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true",
|
||||
|
||||
@@ -122,7 +122,11 @@ function normalizeNpmViewMetadata(value: unknown, spec: string): NpmSpecResoluti
|
||||
/** Reads npm registry metadata for a package spec without running package scripts. */
|
||||
type NpmMetadataFailureCategory = "metadata-env";
|
||||
|
||||
export async function resolveNpmSpecMetadata(params: { spec: string; timeoutMs?: number }): Promise<
|
||||
export async function resolveNpmSpecMetadata(params: {
|
||||
spec: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<
|
||||
| {
|
||||
ok: true;
|
||||
metadata: NpmSpecResolution;
|
||||
@@ -147,6 +151,8 @@ export async function resolveNpmSpecMetadata(params: { spec: string; timeoutMs?:
|
||||
],
|
||||
{
|
||||
timeoutMs: Math.max(params.timeoutMs ?? 60_000, 60_000),
|
||||
signal: params.signal,
|
||||
killProcessTree: true,
|
||||
env: createNpmMetadataEnv(),
|
||||
},
|
||||
);
|
||||
@@ -344,6 +350,7 @@ export async function packNpmSpecToArchive(params: {
|
||||
spec: string;
|
||||
timeoutMs: number;
|
||||
cwd: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<
|
||||
| {
|
||||
ok: true;
|
||||
@@ -359,6 +366,8 @@ export async function packNpmSpecToArchive(params: {
|
||||
["npm", "pack", params.spec, "--ignore-scripts", "--json"],
|
||||
{
|
||||
timeoutMs: Math.max(params.timeoutMs, 300_000),
|
||||
signal: params.signal,
|
||||
killProcessTree: true,
|
||||
cwd: params.cwd,
|
||||
env: createNpmMetadataEnv({ npmConfigCwd: params.cwd }),
|
||||
},
|
||||
@@ -407,6 +416,7 @@ export async function packNpmSpecToArchive(params: {
|
||||
export async function resolveNpmPackArchiveMetadata(params: {
|
||||
archivePath: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<
|
||||
| {
|
||||
ok: true;
|
||||
@@ -431,6 +441,8 @@ export async function resolveNpmPackArchiveMetadata(params: {
|
||||
["npm", "pack", archivePath, "--ignore-scripts", "--dry-run", "--json"],
|
||||
{
|
||||
timeoutMs: Math.max(params.timeoutMs ?? archiveMetadataTimeoutMs, archiveMetadataTimeoutMs),
|
||||
signal: params.signal,
|
||||
killProcessTree: true,
|
||||
env: createNpmMetadataEnv(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -747,6 +747,7 @@ async function collectNpmResolvedManagedNpmRootPeerDependencyPins(params: {
|
||||
npmRoot: string;
|
||||
runCommand?: ManagedNpmRootRunCommand;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<Record<string, string>> {
|
||||
const manifest = await readManagedNpmRootManifest(path.join(params.npmRoot, "package.json"));
|
||||
const dependencies = readDependencyRecord(manifest.dependencies);
|
||||
@@ -787,6 +788,8 @@ async function collectNpmResolvedManagedNpmRootPeerDependencyPins(params: {
|
||||
const npmPlanOptions = {
|
||||
cwd: tempRoot,
|
||||
timeoutMs: Math.max(params.timeoutMs ?? 300_000, 300_000),
|
||||
signal: params.signal,
|
||||
killProcessTree: true,
|
||||
env: createSafeNpmInstallEnv(process.env, {
|
||||
legacyPeerDeps: false,
|
||||
npmConfigCwd: tempRoot,
|
||||
@@ -900,6 +903,7 @@ export async function syncManagedNpmRootPeerDependencies(params: {
|
||||
overrideOmissions?: ManagedNpmOverrideOmissions;
|
||||
runCommand?: ManagedNpmRootRunCommand;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<boolean> {
|
||||
const manifestPath = path.join(params.npmRoot, "package.json");
|
||||
const manifest = await readManagedNpmRootManifest(manifestPath);
|
||||
@@ -910,6 +914,7 @@ export async function syncManagedNpmRootPeerDependencies(params: {
|
||||
npmRoot: params.npmRoot,
|
||||
runCommand: params.runCommand,
|
||||
timeoutMs: params.timeoutMs,
|
||||
signal: params.signal,
|
||||
});
|
||||
const managedPeerDependencyNames = new Set(
|
||||
Object.keys(peerPins).filter(
|
||||
@@ -977,6 +982,7 @@ export async function repairManagedNpmRootOpenClawPeer(params: {
|
||||
npmRoot: string;
|
||||
packageRoot?: string | null;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
logger?: ManagedNpmRootLogger;
|
||||
runCommand?: ManagedNpmRootRunCommand;
|
||||
}): Promise<boolean> {
|
||||
@@ -1034,6 +1040,8 @@ export async function repairManagedNpmRootOpenClawPeer(params: {
|
||||
const result = await command(npmArgs, {
|
||||
cwd: params.npmRoot,
|
||||
timeoutMs: Math.max(params.timeoutMs ?? 300_000, 300_000),
|
||||
signal: params.signal,
|
||||
killProcessTree: true,
|
||||
env: createSafeNpmInstallEnv(process.env, {
|
||||
legacyPeerDeps: true,
|
||||
npmConfigCwd: params.npmRoot,
|
||||
|
||||
@@ -84,6 +84,7 @@ export async function installPluginFromManagedNpmRoot(
|
||||
extensionsDir?: string;
|
||||
npmDir?: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
logger?: PluginInstallLogger;
|
||||
mode?: "install" | "update";
|
||||
dryRun?: boolean;
|
||||
@@ -170,6 +171,7 @@ export async function installPluginFromManagedNpmRoot(
|
||||
...(params.integrityDrift ? { integrityDrift: params.integrityDrift } : {}),
|
||||
};
|
||||
}
|
||||
params.signal?.throwIfAborted();
|
||||
|
||||
let rollbackSnapshot: ManagedNpmPluginInstallRollbackSnapshot;
|
||||
let preparedDependency: ManagedNpmRootPreparedDependency | undefined;
|
||||
@@ -199,6 +201,7 @@ export async function installPluginFromManagedNpmRoot(
|
||||
const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({
|
||||
npmRoot,
|
||||
timeoutMs,
|
||||
signal: params.signal,
|
||||
logger,
|
||||
});
|
||||
if (repairedOpenClawPeer) {
|
||||
@@ -256,6 +259,7 @@ export async function installPluginFromManagedNpmRoot(
|
||||
managedOverrides,
|
||||
overrideOmissions: options?.overrideOmissions,
|
||||
timeoutMs,
|
||||
signal: params.signal,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -294,6 +298,8 @@ export async function installPluginFromManagedNpmRoot(
|
||||
const npmInstallOptions = {
|
||||
cwd: npmRoot,
|
||||
timeoutMs: Math.max(timeoutMs, 300_000),
|
||||
signal: params.signal,
|
||||
killProcessTree: true,
|
||||
env: createSafeNpmInstallEnv(process.env, {
|
||||
legacyPeerDeps: true,
|
||||
npmConfigCwd: npmRoot,
|
||||
@@ -488,6 +494,7 @@ export async function installPluginFromManagedNpmRoot(
|
||||
const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({
|
||||
npmRoot,
|
||||
timeoutMs,
|
||||
signal: params.signal,
|
||||
logger,
|
||||
});
|
||||
if (repairedOpenClawPeer) {
|
||||
|
||||
@@ -46,11 +46,14 @@ type TrustedOfficialPrereleaseResolution =
|
||||
async function loadNpmPackageVersions(params: {
|
||||
packageName: string;
|
||||
timeoutMs: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string[] | null> {
|
||||
const versions = await runCommandWithTimeout(
|
||||
["npm", "view", params.packageName, "versions", "--json"],
|
||||
{
|
||||
timeoutMs: Math.max(params.timeoutMs, 60_000),
|
||||
signal: params.signal,
|
||||
killProcessTree: true,
|
||||
env: createNpmMetadataEnv(),
|
||||
},
|
||||
);
|
||||
@@ -73,6 +76,7 @@ export async function resolveTrustedOfficialPrereleaseResolution(params: {
|
||||
spec: ParsedRegistryNpmSpec;
|
||||
resolvedPrereleaseVersion: string;
|
||||
timeoutMs: number;
|
||||
signal?: AbortSignal;
|
||||
logger: PluginInstallLogger;
|
||||
}): Promise<TrustedOfficialPrereleaseResolution | null> {
|
||||
if (!params.spec.name.startsWith("@openclaw/")) {
|
||||
@@ -81,6 +85,7 @@ export async function resolveTrustedOfficialPrereleaseResolution(params: {
|
||||
const semverVersions = await loadNpmPackageVersions({
|
||||
packageName: params.spec.name,
|
||||
timeoutMs: params.timeoutMs,
|
||||
signal: params.signal,
|
||||
});
|
||||
if (!semverVersions) {
|
||||
return null;
|
||||
@@ -100,6 +105,7 @@ export async function resolveTrustedOfficialPrereleaseResolution(params: {
|
||||
const metadataResult = await resolveNpmSpecMetadata({
|
||||
spec: prereleaseSpec,
|
||||
timeoutMs: params.timeoutMs,
|
||||
signal: params.signal,
|
||||
});
|
||||
if (!metadataResult.ok) {
|
||||
return null;
|
||||
@@ -121,6 +127,7 @@ export async function resolveTrustedOfficialPrereleaseResolution(params: {
|
||||
const metadataResult = await resolveNpmSpecMetadata({
|
||||
spec: stableSpec,
|
||||
timeoutMs: params.timeoutMs,
|
||||
signal: params.signal,
|
||||
});
|
||||
if (!metadataResult.ok) {
|
||||
return null;
|
||||
@@ -187,6 +194,7 @@ export async function resolveLatestCompatibleNpmResolution(params: {
|
||||
expectedPluginId?: string;
|
||||
currentResolution: NpmSpecResolution;
|
||||
timeoutMs: number;
|
||||
signal?: AbortSignal;
|
||||
logger: PluginInstallLogger;
|
||||
}): Promise<NpmSpecResolution | null> {
|
||||
if (!params.currentResolution.version) {
|
||||
@@ -207,6 +215,7 @@ export async function resolveLatestCompatibleNpmResolution(params: {
|
||||
const versions = await loadNpmPackageVersions({
|
||||
packageName: params.parsedSpec.name,
|
||||
timeoutMs: params.timeoutMs,
|
||||
signal: params.signal,
|
||||
});
|
||||
if (!versions) {
|
||||
return null;
|
||||
@@ -226,6 +235,7 @@ export async function resolveLatestCompatibleNpmResolution(params: {
|
||||
const metadataResult = await resolveNpmSpecMetadata({
|
||||
spec,
|
||||
timeoutMs: params.timeoutMs,
|
||||
signal: params.signal,
|
||||
});
|
||||
if (!metadataResult.ok) {
|
||||
params.logger.warn?.(
|
||||
|
||||
@@ -161,6 +161,7 @@ export async function installPluginFromNpmPackArchive(
|
||||
extensionsDir?: string;
|
||||
npmDir?: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
logger?: PluginInstallLogger;
|
||||
mode?: "install" | "update";
|
||||
dryRun?: boolean;
|
||||
@@ -177,6 +178,7 @@ export async function installPluginFromNpmPackArchive(
|
||||
const metadataResult = await resolveNpmPackArchiveMetadata({
|
||||
archivePath: params.archivePath,
|
||||
timeoutMs,
|
||||
signal: params.signal,
|
||||
});
|
||||
if (!metadataResult.ok) {
|
||||
return metadataResult;
|
||||
@@ -267,6 +269,7 @@ export async function installPluginFromNpmPackArchive(
|
||||
extensionsDir: params.extensionsDir,
|
||||
npmDir: npmBaseDir,
|
||||
timeoutMs,
|
||||
signal: params.signal,
|
||||
logger,
|
||||
mode,
|
||||
dryRun,
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function installPluginFromNpmSpec(
|
||||
extensionsDir?: string;
|
||||
npmDir?: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
logger?: PluginInstallLogger;
|
||||
mode?: "install" | "update";
|
||||
dryRun?: boolean;
|
||||
@@ -83,7 +84,7 @@ export async function installPluginFromNpmSpec(
|
||||
};
|
||||
}
|
||||
|
||||
const metadataResult = await resolveNpmSpecMetadata({ spec, timeoutMs });
|
||||
const metadataResult = await resolveNpmSpecMetadata({ spec, timeoutMs, signal: params.signal });
|
||||
if (!metadataResult.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -111,6 +112,7 @@ export async function installPluginFromNpmSpec(
|
||||
spec: parsedSpec,
|
||||
resolvedPrereleaseVersion: npmResolution.version,
|
||||
timeoutMs,
|
||||
signal: params.signal,
|
||||
logger,
|
||||
})
|
||||
: null;
|
||||
@@ -143,6 +145,7 @@ export async function installPluginFromNpmSpec(
|
||||
expectedPluginId,
|
||||
currentResolution: npmResolution,
|
||||
timeoutMs,
|
||||
signal: params.signal,
|
||||
logger,
|
||||
});
|
||||
if (compatibleResolution) {
|
||||
@@ -265,6 +268,7 @@ export async function installPluginFromNpmSpec(
|
||||
extensionsDir: params.extensionsDir,
|
||||
npmDir: params.npmDir,
|
||||
timeoutMs,
|
||||
signal: params.signal,
|
||||
logger,
|
||||
mode,
|
||||
dryRun,
|
||||
|
||||
@@ -113,7 +113,7 @@ describe("setup migration import options", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("offers official installable Codex when bundled plugins are unavailable", async () => {
|
||||
it("does not offer install-only providers during a transactional import", async () => {
|
||||
const previousDisableBundled = process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS;
|
||||
process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "1";
|
||||
try {
|
||||
@@ -122,8 +122,8 @@ describe("setup migration import options", () => {
|
||||
detections: [],
|
||||
});
|
||||
|
||||
expect(options).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ providerId: "codex", label: "Codex" })]),
|
||||
expect(options).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ providerId: "codex" })]),
|
||||
);
|
||||
} finally {
|
||||
if (previousDisableBundled === undefined) {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type { OnboardOptions } from "../commands/onboard-types.js";
|
||||
import {
|
||||
ensureOnboardingPluginInstalled,
|
||||
type OnboardingPluginInstallEntry,
|
||||
} from "../commands/onboarding-plugin-install.js";
|
||||
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
@@ -10,13 +6,6 @@ import {
|
||||
listAvailableManifestContractPlugins,
|
||||
loadManifestContractSnapshot,
|
||||
} from "../plugins/manifest-contract-eligibility.js";
|
||||
import {
|
||||
getOfficialExternalPluginCatalogManifest,
|
||||
listOfficialExternalPluginCatalogEntries,
|
||||
resolveOfficialExternalPluginId,
|
||||
resolveOfficialExternalPluginInstall,
|
||||
resolveOfficialExternalPluginLabel,
|
||||
} from "../plugins/official-external-plugin-catalog.js";
|
||||
import type {
|
||||
MigrationPlan,
|
||||
MigrationProviderContext,
|
||||
@@ -59,11 +48,6 @@ type SetupMigrationOption = {
|
||||
label: string;
|
||||
hint?: string;
|
||||
};
|
||||
type InstallableSetupMigrationProvider = {
|
||||
providerId: string;
|
||||
entry: OnboardingPluginInstallEntry;
|
||||
description?: string;
|
||||
};
|
||||
type ManifestSetupMigrationProvider = {
|
||||
providerId: string;
|
||||
label: string;
|
||||
@@ -138,31 +122,6 @@ function resolveImportSourceDefault(params: {
|
||||
return params.providerId === "hermes" ? "~/.hermes" : "";
|
||||
}
|
||||
|
||||
function resolveInstallableSetupMigrationProviders(): InstallableSetupMigrationProvider[] {
|
||||
const providers: InstallableSetupMigrationProvider[] = [];
|
||||
for (const catalogEntry of listOfficialExternalPluginCatalogEntries()) {
|
||||
const manifest = getOfficialExternalPluginCatalogManifest(catalogEntry);
|
||||
const pluginId = resolveOfficialExternalPluginId(catalogEntry);
|
||||
const install = resolveOfficialExternalPluginInstall(catalogEntry);
|
||||
if (!pluginId || !install) {
|
||||
continue;
|
||||
}
|
||||
for (const providerId of manifest?.contracts?.migrationProviders ?? []) {
|
||||
providers.push({
|
||||
providerId,
|
||||
entry: {
|
||||
pluginId,
|
||||
label: resolveOfficialExternalPluginLabel(catalogEntry),
|
||||
install,
|
||||
trustedSourceLinkedOfficialInstall: true,
|
||||
},
|
||||
...(catalogEntry.description ? { description: catalogEntry.description } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
function formatMigrationProviderId(providerId: string): string {
|
||||
return providerId
|
||||
.split(/[-_]+/)
|
||||
@@ -240,13 +199,6 @@ export async function listSetupMigrationOptions(params: {
|
||||
hint: provider.description ?? t("wizard.migration.sourcePathHint"),
|
||||
});
|
||||
}
|
||||
for (const provider of resolveInstallableSetupMigrationProviders()) {
|
||||
addOption({
|
||||
providerId: provider.providerId,
|
||||
label: provider.entry.label,
|
||||
hint: provider.description ?? t("wizard.migration.sourcePathHint"),
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
@@ -261,11 +213,17 @@ async function selectSetupMigrationProvider(params: {
|
||||
baseConfig: params.baseConfig,
|
||||
detections: params.detections,
|
||||
});
|
||||
const requestedProviderId = params.opts.importFrom?.trim();
|
||||
if (requestedProviderId && !options.some((option) => option.providerId === requestedProviderId)) {
|
||||
throw new Error(
|
||||
`Migration provider "${requestedProviderId}" is not installed or bundled. Install it before starting the transactional import.`,
|
||||
);
|
||||
}
|
||||
if (options.length === 0) {
|
||||
throw new Error("No migration providers found.");
|
||||
}
|
||||
const providerId =
|
||||
params.opts.importFrom?.trim() ||
|
||||
requestedProviderId ||
|
||||
(await params.prompter.select({
|
||||
message: t("wizard.migration.source"),
|
||||
options: options.map((option) => ({
|
||||
@@ -276,7 +234,9 @@ async function selectSetupMigrationProvider(params: {
|
||||
initialValue: params.detections[0]?.providerId ?? options[0]?.providerId,
|
||||
}));
|
||||
if (!options.some((option) => option.providerId === providerId)) {
|
||||
throw new Error(`Unknown migration provider "${providerId}".`);
|
||||
throw new Error(
|
||||
`Migration provider "${providerId}" is not installed or bundled. Install it before starting the transactional import.`,
|
||||
);
|
||||
}
|
||||
return providerId;
|
||||
}
|
||||
@@ -284,9 +244,6 @@ async function selectSetupMigrationProvider(params: {
|
||||
async function resolveSetupMigrationProvider(params: {
|
||||
providerId: string;
|
||||
baseConfig: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
runtime: RuntimeEnv;
|
||||
workspaceDir: string;
|
||||
}): Promise<{ provider: MigrationProviderPlugin; baseConfig: OpenClawConfig }> {
|
||||
const { ensureStandaloneMigrationProviderRegistryLoaded, resolvePluginMigrationProvider } =
|
||||
await loadMigrationProviderRuntimeModule();
|
||||
@@ -301,35 +258,7 @@ async function resolveSetupMigrationProvider(params: {
|
||||
if (existing) {
|
||||
return { provider: existing, baseConfig: params.baseConfig };
|
||||
}
|
||||
const installable = resolveInstallableSetupMigrationProviders().find(
|
||||
(provider) => provider.providerId === params.providerId,
|
||||
);
|
||||
if (!installable) {
|
||||
throw new Error(`Unknown migration provider "${params.providerId}".`);
|
||||
}
|
||||
const result = await ensureOnboardingPluginInstalled({
|
||||
cfg: params.baseConfig,
|
||||
entry: installable.entry,
|
||||
prompter: params.prompter,
|
||||
runtime: params.runtime,
|
||||
workspaceDir: params.workspaceDir,
|
||||
promptInstall: false,
|
||||
});
|
||||
if (!result.installed) {
|
||||
throw new Error(`Could not install migration provider "${params.providerId}".`);
|
||||
}
|
||||
ensureStandaloneMigrationProviderRegistryLoaded({
|
||||
cfg: result.cfg,
|
||||
providerId: params.providerId,
|
||||
});
|
||||
const provider = resolvePluginMigrationProvider({
|
||||
providerId: params.providerId,
|
||||
cfg: result.cfg,
|
||||
});
|
||||
if (!provider) {
|
||||
throw new Error(`Installed plugin did not register migration provider "${params.providerId}".`);
|
||||
}
|
||||
return { provider, baseConfig: result.cfg };
|
||||
throw new Error(`Migration provider "${params.providerId}" did not register after activation.`);
|
||||
}
|
||||
|
||||
function hasCredentialCandidate(plan: MigrationPlan): boolean {
|
||||
@@ -416,9 +345,6 @@ export async function runSetupMigrationImport(params: {
|
||||
const resolvedProvider = await resolveSetupMigrationProvider({
|
||||
providerId,
|
||||
baseConfig: committedConfig,
|
||||
prompter: params.prompter,
|
||||
runtime: params.runtime,
|
||||
workspaceDir: promotionResume.continuation.workspaceDir,
|
||||
});
|
||||
assertDeferredMigrationApplyContract(
|
||||
resolvedProvider.provider,
|
||||
@@ -447,9 +373,6 @@ export async function runSetupMigrationImport(params: {
|
||||
const resolvedProvider = await resolveSetupMigrationProvider({
|
||||
providerId,
|
||||
baseConfig: lockedBaseConfig,
|
||||
prompter: params.prompter,
|
||||
runtime: params.runtime,
|
||||
workspaceDir,
|
||||
});
|
||||
const planningBaseConfig = await params.readConfigFile();
|
||||
const planningTargetSnapshotHash = await buildSetupMigrationTargetSnapshot({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Setup migration snapshots bind retries to unchanged source and target state.
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import crypto from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
@@ -10,11 +11,12 @@ import type { MigrationPlan } from "../plugins/types.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { canonicalizeSetupMigrationValue } from "./setup.migration-canonical.js";
|
||||
|
||||
const SETUP_MIGRATION_LOCK_OPTIONS = {
|
||||
retries: { retries: 60, factor: 1, minTimeout: 500, maxTimeout: 500 },
|
||||
const ONBOARDING_TARGET_LOCK_OPTIONS = {
|
||||
retries: { retries: 1_200, factor: 1, minTimeout: 500, maxTimeout: 500 },
|
||||
stale: 30 * 60 * 1000,
|
||||
staleRecovery: "remove-if-unchanged" as const,
|
||||
};
|
||||
const activeSetupMigrationTargetLock = new AsyncLocalStorage<string>();
|
||||
const MEANINGFUL_CONFIG_IGNORED_KEYS = new Set(["$schema", "meta"]);
|
||||
const MEANINGFUL_WIZARD_CONFIG_IGNORED_KEYS = new Set(["securityAcknowledgedAt"]);
|
||||
const MEANINGFUL_WORKSPACE_ENTRIES = [
|
||||
@@ -301,17 +303,25 @@ export async function prepareSetupMigrationAttemptBoundary(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/** Serializes all onboarding migration writes that share one OpenClaw state target. */
|
||||
/** Serializes onboarding writes that share one OpenClaw state target. */
|
||||
export async function withSetupMigrationTargetLock<T>(
|
||||
stateDir: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const migrationDir = path.join(stateDir, "migration");
|
||||
const resolvedStateDir = path.resolve(stateDir);
|
||||
const activeStateDir = activeSetupMigrationTargetLock.getStore();
|
||||
if (activeStateDir) {
|
||||
if (activeStateDir !== resolvedStateDir) {
|
||||
throw new Error("nested onboarding target lock cannot switch the OpenClaw state directory");
|
||||
}
|
||||
return await fn();
|
||||
}
|
||||
const migrationDir = path.join(resolvedStateDir, "migration");
|
||||
await fs.mkdir(migrationDir, { recursive: true, mode: 0o700 });
|
||||
return await withFileLock(
|
||||
path.join(migrationDir, "onboarding.lock-target"),
|
||||
SETUP_MIGRATION_LOCK_OPTIONS,
|
||||
fn,
|
||||
ONBOARDING_TARGET_LOCK_OPTIONS,
|
||||
async () => await activeSetupMigrationTargetLock.run(resolvedStateDir, fn),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user