Skip to content

Commit a387068

Browse files
ai-hpchxy91819
andauthored
fix(cli): handle closed plugin uninstall prompt (#73566)
Merged via squash. Prepared head SHA: d754ddc 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
1 parent e6f5f56 commit a387068

6 files changed

Lines changed: 151 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ Docs: https://docs.openclaw.ai
7979
- 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.
8080
- 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.
8181
- 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.
82+
- 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.
8283
- 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.
8384
- 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.
8485
- 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.

src/cli/plugins-cli-test-helpers.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ const uninstallPlugin: AsyncUnknownMock = vi.fn();
7474
export const updateNpmInstalledPlugins: AsyncUnknownMock = vi.fn();
7575
export const updateNpmInstalledHookPacks: AsyncUnknownMock = vi.fn();
7676
export const promptYesNo: AsyncUnknownMock = vi.fn();
77+
export class PromptInputClosedError extends Error {
78+
constructor() {
79+
super("Prompt input closed before an answer was received.");
80+
this.name = "PromptInputClosedError";
81+
}
82+
}
7783
export const installPluginFromNpmSpec: AsyncUnknownMock = vi.fn();
7884
export const installPluginFromPath: AsyncUnknownMock = vi.fn();
7985
export const installPluginFromClawHub: AsyncUnknownMock = vi.fn();
@@ -455,6 +461,7 @@ vi.mock("../hooks/update.js", () => ({
455461
}));
456462

457463
vi.mock("./prompt.js", () => ({
464+
PromptInputClosedError,
458465
promptYesNo: ((...args: Parameters<(typeof import("./prompt.js"))["promptYesNo"]>) =>
459466
invokeMock<
460467
Parameters<(typeof import("./prompt.js"))["promptYesNo"]>,

src/cli/plugins-cli.uninstall.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
buildPluginSnapshotReport,
88
loadConfig,
99
planPluginUninstall,
10+
PromptInputClosedError,
1011
promptYesNo,
1112
refreshPluginRegistry,
1213
replaceConfigFile,
@@ -148,6 +149,57 @@ describe("plugins cli uninstall", () => {
148149
});
149150
});
150151

152+
it("exits cleanly when confirmation input closes before an answer", async () => {
153+
const baseConfig = {
154+
plugins: {
155+
entries: {
156+
alpha: { enabled: true },
157+
},
158+
installs: {
159+
alpha: {
160+
source: "path",
161+
sourcePath: ALPHA_INSTALL_PATH,
162+
installPath: ALPHA_INSTALL_PATH,
163+
},
164+
},
165+
},
166+
} as OpenClawConfig;
167+
loadConfig.mockReturnValue(baseConfig);
168+
setInstalledPluginIndexInstallRecords(baseConfig.plugins?.installs ?? {});
169+
buildPluginSnapshotReport.mockReturnValue({
170+
plugins: [{ id: "alpha", name: "alpha" }],
171+
diagnostics: [],
172+
});
173+
planPluginUninstall.mockReturnValue({
174+
ok: true,
175+
config: { plugins: { entries: {}, installs: {} } } as OpenClawConfig,
176+
actions: {
177+
entry: true,
178+
install: true,
179+
allowlist: false,
180+
denylist: false,
181+
loadPath: false,
182+
memorySlot: false,
183+
contextEngineSlot: false,
184+
directory: false,
185+
},
186+
directoryRemoval: null,
187+
});
188+
promptYesNo.mockRejectedValueOnce(new PromptInputClosedError());
189+
190+
await expect(runPluginsCommand(["plugins", "uninstall", "alpha"])).rejects.toThrow(
191+
"__exit__:1",
192+
);
193+
194+
expect(runtimeErrors).toContain(
195+
"Error: plugins uninstall requires confirmation input. Re-run in an interactive TTY or pass --force.",
196+
);
197+
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
198+
expect(writeConfigFile).not.toHaveBeenCalled();
199+
expect(refreshPluginRegistry).not.toHaveBeenCalled();
200+
expect(applyPluginUninstallDirectoryRemoval).not.toHaveBeenCalled();
201+
});
202+
151203
it("restores install records when the config write rejects during uninstall", async () => {
152204
const installRecords = {
153205
alpha: {

src/cli/plugins-uninstall-command.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ export type PluginUninstallOptions = {
1919
dryRun?: boolean;
2020
};
2121

22+
function isPromptInputClosedError(
23+
error: unknown,
24+
PromptInputClosedError: typeof import("./prompt.js").PromptInputClosedError,
25+
): error is InstanceType<typeof PromptInputClosedError> {
26+
return error instanceof PromptInputClosedError;
27+
}
28+
2229
export async function runPluginUninstallCommand(
2330
id: string,
2431
opts: PluginUninstallOptions = {},
@@ -44,7 +51,7 @@ export async function runPluginUninstallCommand(
4451
const { refreshPluginRegistryAfterConfigMutation } =
4552
await import("./plugins-registry-refresh.js");
4653
const { resolvePluginUninstallId } = await import("./plugins-uninstall-selection.js");
47-
const { promptYesNo } = await import("./prompt.js");
54+
const { PromptInputClosedError, promptYesNo } = await import("./prompt.js");
4855
const snapshot = await tracePluginLifecyclePhaseAsync(
4956
"config read",
5057
() => readConfigFileSnapshot(),
@@ -143,7 +150,19 @@ export async function runPluginUninstallCommand(
143150
}
144151

145152
if (!opts.force) {
146-
const confirmed = await promptYesNo(`Uninstall plugin "${pluginId}"?`);
153+
let confirmed: boolean;
154+
try {
155+
confirmed = await promptYesNo(`Uninstall plugin "${pluginId}"?`);
156+
} catch (error) {
157+
if (isPromptInputClosedError(error, PromptInputClosedError)) {
158+
runtime.error(
159+
"Error: plugins uninstall requires confirmation input. Re-run in an interactive TTY or pass --force.",
160+
);
161+
runtime.exit(1);
162+
return;
163+
}
164+
throw error;
165+
}
147166
if (!confirmed) {
148167
runtime.log("Cancelled.");
149168
return;

src/cli/prompt.test.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,32 @@
11
import readline from "node:readline/promises";
22
import { beforeEach, describe, expect, it, vi } from "vitest";
33
import { isYes, setVerbose, setYes } from "../globals.js";
4-
import { promptYesNo } from "./prompt.js";
4+
import { PromptInputClosedError, promptYesNo } from "./prompt.js";
55

66
const readlineState = vi.hoisted(() => {
77
const question = vi.fn(async () => "");
88
const close = vi.fn();
9-
const createInterface = vi.fn(() => ({ question, close }));
10-
return { question, close, createInterface };
9+
const listeners = new Map<string, Set<() => void>>();
10+
const once = vi.fn((event: string, listener: () => void) => {
11+
const current = listeners.get(event) ?? new Set<() => void>();
12+
current.add(listener);
13+
listeners.set(event, current);
14+
});
15+
const off = vi.fn((event: string, listener: () => void) => {
16+
listeners.get(event)?.delete(listener);
17+
});
18+
const emit = (event: string) => {
19+
const current = [...(listeners.get(event) ?? [])];
20+
listeners.delete(event);
21+
for (const listener of current) {
22+
listener();
23+
}
24+
};
25+
const resetListeners = () => {
26+
listeners.clear();
27+
};
28+
const createInterface = vi.fn(() => ({ question, close, once, off }));
29+
return { question, close, createInterface, emit, off, once, resetListeners };
1130
});
1231

1332
vi.mock("node:readline/promises", () => ({
@@ -21,6 +40,9 @@ beforeEach(() => {
2140
readlineState.question.mockResolvedValue("");
2241
readlineState.close.mockClear();
2342
readlineState.createInterface.mockClear();
43+
readlineState.off.mockClear();
44+
readlineState.once.mockClear();
45+
readlineState.resetListeners();
2446
});
2547

2648
describe("promptYesNo", () => {
@@ -48,4 +70,14 @@ describe("promptYesNo", () => {
4870
const resultYes = await promptYesNo("Continue?", false);
4971
expect(resultYes).toBe(true);
5072
});
73+
74+
it("rejects when input closes before an answer is received", async () => {
75+
readlineState.question.mockReturnValueOnce(new Promise<string>(() => undefined));
76+
77+
const result = promptYesNo("Continue?");
78+
readlineState.emit("close");
79+
80+
await expect(result).rejects.toThrow(PromptInputClosedError);
81+
expect(readlineState.close).toHaveBeenCalled();
82+
});
5183
});

src/cli/prompt.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,36 @@ import readline from "node:readline/promises";
33
import { isVerbose, isYes } from "../globals.js";
44
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
55

6+
export class PromptInputClosedError extends Error {
7+
constructor() {
8+
super("Prompt input closed before an answer was received.");
9+
this.name = "PromptInputClosedError";
10+
}
11+
}
12+
13+
type ReadlineInterface = ReturnType<typeof readline.createInterface>;
14+
15+
function questionUntilClose(rl: ReadlineInterface, question: string): Promise<string> {
16+
return new Promise((resolve, reject) => {
17+
let settled = false;
18+
const finish = (complete: () => void) => {
19+
if (settled) {
20+
return;
21+
}
22+
settled = true;
23+
rl.off("close", onClose);
24+
complete();
25+
};
26+
const onClose = () => finish(() => reject(new PromptInputClosedError()));
27+
28+
rl.once("close", onClose);
29+
void rl.question(question).then(
30+
(answer) => finish(() => resolve(answer)),
31+
(error: unknown) => finish(() => reject(error)),
32+
);
33+
});
34+
}
35+
636
export async function promptYesNo(question: string, defaultYes = false): Promise<boolean> {
737
// Simple Y/N prompt honoring global --yes and verbosity flags.
838
if (isVerbose() && isYes()) {
@@ -13,8 +43,11 @@ export async function promptYesNo(question: string, defaultYes = false): Promise
1343
}
1444
const rl = readline.createInterface({ input, output });
1545
const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
16-
const answer = normalizeLowercaseStringOrEmpty(await rl.question(`${question}${suffix}`));
17-
rl.close();
46+
const answer = normalizeLowercaseStringOrEmpty(
47+
await questionUntilClose(rl, `${question}${suffix}`).finally(() => {
48+
rl.close();
49+
}),
50+
);
1851
if (!answer) {
1952
return defaultYes;
2053
}

0 commit comments

Comments
 (0)