mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:50:43 +00:00
fix(cli): handle closed plugin uninstall prompt (#73566)
Merged via squash.
Prepared head SHA: d754ddcf29
Co-authored-by: ai-hpc <183861985+ai-hpc@users.noreply.github.com>
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Reviewed-by: @hxy91819
This commit is contained in:
@@ -79,6 +79,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Video generation: accept provider-specific aspect-ratio and resolution hints at the tool boundary, normalize `720P` to MiniMax's supported `768P`, and stop sending Google `generateAudio` on Gemini video requests so provider fallback can recover from model-specific parameter differences. Thanks @vincentkoc.
|
||||
- OpenAI/Google Meet: fail realtime voice connection attempts when the socket closes before `session.updated`, avoiding stuck Meet joins waiting on a bridge that never became ready. Thanks @vincentkoc.
|
||||
- Hooks/session-memory: run reset memory capture off the command reply path and make model-generated memory filename slugs opt-in with `llmSlug: true`, so `/new` and `/reset` no longer block WhatsApp and other message-channel reset replies on hook housekeeping or a nested model call. Thanks @vincentkoc.
|
||||
- CLI/plugins: handle closed stdin during `plugins uninstall` confirmation prompt and exit 1 with actionable `--force` guidance instead of crashing with Node exit 13 unsettled top-level await. Fixes #73562. (#73566) Thanks @ai-hpc.
|
||||
- CLI/gateway: pause non-TTY stdin after full CLI command completion and stop `openclaw agent` from falling back to embedded mode after gateway request/auth failures, so parent help commands exit cleanly and scoped delivery probes surface the real Gateway error immediately. Thanks @vincentkoc.
|
||||
- Gateway/model catalog: cache empty read-only model catalog results until reload, so TUI and control-plane refresh loops cannot hammer plugin metadata reads when no usable models are currently discovered. Thanks @vincentkoc.
|
||||
- Google Meet: fork the caller's current agent transcript into agent-mode meeting consultant sessions, so Meet replies inherit the context from the tool call that joined the meeting.
|
||||
|
||||
@@ -74,6 +74,12 @@ const uninstallPlugin: AsyncUnknownMock = vi.fn();
|
||||
export const updateNpmInstalledPlugins: AsyncUnknownMock = vi.fn();
|
||||
export const updateNpmInstalledHookPacks: AsyncUnknownMock = vi.fn();
|
||||
export const promptYesNo: AsyncUnknownMock = vi.fn();
|
||||
export class PromptInputClosedError extends Error {
|
||||
constructor() {
|
||||
super("Prompt input closed before an answer was received.");
|
||||
this.name = "PromptInputClosedError";
|
||||
}
|
||||
}
|
||||
export const installPluginFromNpmSpec: AsyncUnknownMock = vi.fn();
|
||||
export const installPluginFromPath: AsyncUnknownMock = vi.fn();
|
||||
export const installPluginFromClawHub: AsyncUnknownMock = vi.fn();
|
||||
@@ -455,6 +461,7 @@ vi.mock("../hooks/update.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./prompt.js", () => ({
|
||||
PromptInputClosedError,
|
||||
promptYesNo: ((...args: Parameters<(typeof import("./prompt.js"))["promptYesNo"]>) =>
|
||||
invokeMock<
|
||||
Parameters<(typeof import("./prompt.js"))["promptYesNo"]>,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildPluginSnapshotReport,
|
||||
loadConfig,
|
||||
planPluginUninstall,
|
||||
PromptInputClosedError,
|
||||
promptYesNo,
|
||||
refreshPluginRegistry,
|
||||
replaceConfigFile,
|
||||
@@ -148,6 +149,57 @@ describe("plugins cli uninstall", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("exits cleanly when confirmation input closes before an answer", async () => {
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
alpha: { enabled: true },
|
||||
},
|
||||
installs: {
|
||||
alpha: {
|
||||
source: "path",
|
||||
sourcePath: ALPHA_INSTALL_PATH,
|
||||
installPath: ALPHA_INSTALL_PATH,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
loadConfig.mockReturnValue(baseConfig);
|
||||
setInstalledPluginIndexInstallRecords(baseConfig.plugins?.installs ?? {});
|
||||
buildPluginSnapshotReport.mockReturnValue({
|
||||
plugins: [{ id: "alpha", name: "alpha" }],
|
||||
diagnostics: [],
|
||||
});
|
||||
planPluginUninstall.mockReturnValue({
|
||||
ok: true,
|
||||
config: { plugins: { entries: {}, installs: {} } } as OpenClawConfig,
|
||||
actions: {
|
||||
entry: true,
|
||||
install: true,
|
||||
allowlist: false,
|
||||
denylist: false,
|
||||
loadPath: false,
|
||||
memorySlot: false,
|
||||
contextEngineSlot: false,
|
||||
directory: false,
|
||||
},
|
||||
directoryRemoval: null,
|
||||
});
|
||||
promptYesNo.mockRejectedValueOnce(new PromptInputClosedError());
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "uninstall", "alpha"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
|
||||
expect(runtimeErrors).toContain(
|
||||
"Error: plugins uninstall requires confirmation input. Re-run in an interactive TTY or pass --force.",
|
||||
);
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(refreshPluginRegistry).not.toHaveBeenCalled();
|
||||
expect(applyPluginUninstallDirectoryRemoval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores install records when the config write rejects during uninstall", async () => {
|
||||
const installRecords = {
|
||||
alpha: {
|
||||
|
||||
@@ -19,6 +19,13 @@ export type PluginUninstallOptions = {
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
function isPromptInputClosedError(
|
||||
error: unknown,
|
||||
PromptInputClosedError: typeof import("./prompt.js").PromptInputClosedError,
|
||||
): error is InstanceType<typeof PromptInputClosedError> {
|
||||
return error instanceof PromptInputClosedError;
|
||||
}
|
||||
|
||||
export async function runPluginUninstallCommand(
|
||||
id: string,
|
||||
opts: PluginUninstallOptions = {},
|
||||
@@ -44,7 +51,7 @@ export async function runPluginUninstallCommand(
|
||||
const { refreshPluginRegistryAfterConfigMutation } =
|
||||
await import("./plugins-registry-refresh.js");
|
||||
const { resolvePluginUninstallId } = await import("./plugins-uninstall-selection.js");
|
||||
const { promptYesNo } = await import("./prompt.js");
|
||||
const { PromptInputClosedError, promptYesNo } = await import("./prompt.js");
|
||||
const snapshot = await tracePluginLifecyclePhaseAsync(
|
||||
"config read",
|
||||
() => readConfigFileSnapshot(),
|
||||
@@ -143,7 +150,19 @@ export async function runPluginUninstallCommand(
|
||||
}
|
||||
|
||||
if (!opts.force) {
|
||||
const confirmed = await promptYesNo(`Uninstall plugin "${pluginId}"?`);
|
||||
let confirmed: boolean;
|
||||
try {
|
||||
confirmed = await promptYesNo(`Uninstall plugin "${pluginId}"?`);
|
||||
} catch (error) {
|
||||
if (isPromptInputClosedError(error, PromptInputClosedError)) {
|
||||
runtime.error(
|
||||
"Error: plugins uninstall requires confirmation input. Re-run in an interactive TTY or pass --force.",
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!confirmed) {
|
||||
runtime.log("Cancelled.");
|
||||
return;
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
import readline from "node:readline/promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { isYes, setVerbose, setYes } from "../globals.js";
|
||||
import { promptYesNo } from "./prompt.js";
|
||||
import { PromptInputClosedError, promptYesNo } from "./prompt.js";
|
||||
|
||||
const readlineState = vi.hoisted(() => {
|
||||
const question = vi.fn(async () => "");
|
||||
const close = vi.fn();
|
||||
const createInterface = vi.fn(() => ({ question, close }));
|
||||
return { question, close, createInterface };
|
||||
const listeners = new Map<string, Set<() => void>>();
|
||||
const once = vi.fn((event: string, listener: () => void) => {
|
||||
const current = listeners.get(event) ?? new Set<() => void>();
|
||||
current.add(listener);
|
||||
listeners.set(event, current);
|
||||
});
|
||||
const off = vi.fn((event: string, listener: () => void) => {
|
||||
listeners.get(event)?.delete(listener);
|
||||
});
|
||||
const emit = (event: string) => {
|
||||
const current = [...(listeners.get(event) ?? [])];
|
||||
listeners.delete(event);
|
||||
for (const listener of current) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
const resetListeners = () => {
|
||||
listeners.clear();
|
||||
};
|
||||
const createInterface = vi.fn(() => ({ question, close, once, off }));
|
||||
return { question, close, createInterface, emit, off, once, resetListeners };
|
||||
});
|
||||
|
||||
vi.mock("node:readline/promises", () => ({
|
||||
@@ -21,6 +40,9 @@ beforeEach(() => {
|
||||
readlineState.question.mockResolvedValue("");
|
||||
readlineState.close.mockClear();
|
||||
readlineState.createInterface.mockClear();
|
||||
readlineState.off.mockClear();
|
||||
readlineState.once.mockClear();
|
||||
readlineState.resetListeners();
|
||||
});
|
||||
|
||||
describe("promptYesNo", () => {
|
||||
@@ -48,4 +70,14 @@ describe("promptYesNo", () => {
|
||||
const resultYes = await promptYesNo("Continue?", false);
|
||||
expect(resultYes).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when input closes before an answer is received", async () => {
|
||||
readlineState.question.mockReturnValueOnce(new Promise<string>(() => undefined));
|
||||
|
||||
const result = promptYesNo("Continue?");
|
||||
readlineState.emit("close");
|
||||
|
||||
await expect(result).rejects.toThrow(PromptInputClosedError);
|
||||
expect(readlineState.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,36 @@ import readline from "node:readline/promises";
|
||||
import { isVerbose, isYes } from "../globals.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
||||
|
||||
export class PromptInputClosedError extends Error {
|
||||
constructor() {
|
||||
super("Prompt input closed before an answer was received.");
|
||||
this.name = "PromptInputClosedError";
|
||||
}
|
||||
}
|
||||
|
||||
type ReadlineInterface = ReturnType<typeof readline.createInterface>;
|
||||
|
||||
function questionUntilClose(rl: ReadlineInterface, question: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (complete: () => void) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
rl.off("close", onClose);
|
||||
complete();
|
||||
};
|
||||
const onClose = () => finish(() => reject(new PromptInputClosedError()));
|
||||
|
||||
rl.once("close", onClose);
|
||||
void rl.question(question).then(
|
||||
(answer) => finish(() => resolve(answer)),
|
||||
(error: unknown) => finish(() => reject(error)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function promptYesNo(question: string, defaultYes = false): Promise<boolean> {
|
||||
// Simple Y/N prompt honoring global --yes and verbosity flags.
|
||||
if (isVerbose() && isYes()) {
|
||||
@@ -13,8 +43,11 @@ export async function promptYesNo(question: string, defaultYes = false): Promise
|
||||
}
|
||||
const rl = readline.createInterface({ input, output });
|
||||
const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
|
||||
const answer = normalizeLowercaseStringOrEmpty(await rl.question(`${question}${suffix}`));
|
||||
rl.close();
|
||||
const answer = normalizeLowercaseStringOrEmpty(
|
||||
await questionUntilClose(rl, `${question}${suffix}`).finally(() => {
|
||||
rl.close();
|
||||
}),
|
||||
);
|
||||
if (!answer) {
|
||||
return defaultYes;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user