Skip to content

Commit b0c1d70

Browse files
committed
fix: migrate post-rebase inquirer call sites to node:readline prompts
1 parent 4e6122b commit b0c1d70

6 files changed

Lines changed: 138 additions & 58 deletions

File tree

src/commands/accounts/switch.ts

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { Args } from "@oclif/core";
22
import chalk from "chalk";
3-
import inquirer from "inquirer";
43

54
import { ControlBaseCommand } from "../../control-base-command.js";
65
import { endpointFlag } from "../../flags.js";
76
import { type AccountSummary } from "../../services/control-api.js";
87
import { formatResource } from "../../utils/output.js";
8+
import {
9+
promptForSelection,
10+
type SelectionChoice,
11+
} from "../../utils/prompt-selection.js";
912
import { pickUniqueAlias, slugifyAccountName } from "../../utils/slugify.js";
1013

1114
export default class AccountsSwitch extends ControlBaseCommand {
@@ -120,18 +123,15 @@ export default class AccountsSwitch extends ControlBaseCommand {
120123
// Remote accounts not already configured locally
121124
const remoteOnly = remoteAccounts.filter((r) => !localAccountIds.has(r.id));
122125

123-
type Choice = {
124-
name: string;
125-
value:
126-
| { type: "local"; alias: string }
127-
| { type: "remote"; account: AccountSummary };
128-
};
126+
type SelectedAccount =
127+
| { type: "local"; alias: string }
128+
| { type: "remote"; account: AccountSummary };
129129

130-
const choices: Array<Choice | inquirer.Separator> = [];
130+
const choices: Array<SelectionChoice<SelectedAccount>> = [];
131131

132132
// Local accounts section
133133
if (localAccounts.length > 0) {
134-
choices.push(new inquirer.Separator("── Local accounts ──"));
134+
choices.push({ separator: "── Local accounts ──" });
135135
for (const { account, alias } of localAccounts) {
136136
const isCurrent = alias === currentAlias;
137137
const name = account.accountName || account.accountId || "Unknown";
@@ -142,9 +142,9 @@ export default class AccountsSwitch extends ControlBaseCommand {
142142

143143
// Remote-only accounts section
144144
if (remoteOnly.length > 0) {
145-
choices.push(
146-
new inquirer.Separator("── Other accounts (no login required) ──"),
147-
);
145+
choices.push({
146+
separator: "── Other accounts (no login required) ──",
147+
});
148148
for (const account of remoteOnly) {
149149
const label = ` ${account.name} ${chalk.dim(`(${account.id})`)}`;
150150
choices.push({ name: label, value: { type: "remote", account } });
@@ -156,18 +156,11 @@ export default class AccountsSwitch extends ControlBaseCommand {
156156
return false;
157157
}
158158

159-
const { selected } = (await inquirer.prompt([
160-
{
161-
choices,
162-
message: "Select an account:",
163-
name: "selected",
164-
type: "list",
165-
},
166-
])) as {
167-
selected:
168-
| { type: "local"; alias: string }
169-
| { type: "remote"; account: AccountSummary };
170-
};
159+
const selected = await promptForSelection("Select an account:", choices);
160+
161+
if (!selected) {
162+
return false;
163+
}
171164

172165
if (selected.type === "local") {
173166
await this.switchToLocalAccount(selected.alias, flags);

src/services/interactive-helper.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -71,19 +71,12 @@ export class InteractiveHelper {
7171
return null;
7272
}
7373

74-
const { selectedAccount } = (await inquirer.prompt([
75-
{
76-
choices: accounts.map((account) => ({
77-
name: `${account.name} (${account.id})`,
78-
value: account,
79-
})),
80-
message: "Select an account:",
81-
name: "selectedAccount",
82-
type: "list",
83-
},
84-
])) as { selectedAccount: AccountSummary };
85-
86-
return selectedAccount;
74+
const choices = accounts.map((account) => ({
75+
name: `${account.name} (${account.id})`,
76+
value: account,
77+
}));
78+
79+
return await promptForSelection("Select an account:", choices);
8780
} catch (error) {
8881
if (this.logErrors) {
8982
this.log(

src/utils/prompt-confirmation.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,7 @@ export function promptForConfirmation(
3838
const finish = (result: boolean) => {
3939
if (settled) return;
4040
settled = true;
41-
if (!rl.closed) {
42-
rl.close();
43-
}
41+
rl.close();
4442
resolve(result);
4543
};
4644

src/utils/prompt-selection.ts

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,39 @@
11
import * as readline from "node:readline";
22

3+
/**
4+
* A choice item is either selectable (has a value) or a separator (header line, not selectable).
5+
*/
6+
export type SelectionChoice<T> =
7+
| { name: string; value: T }
8+
| { separator: string };
9+
10+
function isSeparator<T>(
11+
choice: SelectionChoice<T>,
12+
): choice is { separator: string } {
13+
return "separator" in choice;
14+
}
15+
316
/**
417
* Prompts the user to select an item from a numbered list.
518
* Displays choices as "[1] Choice one", "[2] Choice two", etc.
19+
* Separator entries render as un-numbered header lines and cannot be selected.
620
* Re-prompts on invalid input (out-of-range, non-numeric).
7-
* Returns null if the user enters empty input, stdin closes, or choices is empty.
21+
* Returns null if the user enters empty input, stdin closes, the choices list
22+
* has no selectable entries, or SIGINT is received.
823
*
924
* @param message - The prompt message displayed above the list
10-
* @param choices - Array of { name, value } pairs (same format as inquirer list choices)
25+
* @param choices - Array of selectable items or separators
1126
* @returns Promise<T | null> - The selected item's value, or null if cancelled
1227
*/
1328
export function promptForSelection<T>(
1429
message: string,
15-
choices: Array<{ name: string; value: T }>,
30+
choices: Array<SelectionChoice<T>>,
1631
): Promise<T | null> {
17-
if (choices.length === 0) {
32+
const selectable = choices.filter(
33+
(c): c is { name: string; value: T } => !isSeparator(c),
34+
);
35+
36+
if (selectable.length === 0) {
1837
return Promise.resolve(null);
1938
}
2039

@@ -23,10 +42,18 @@ export function promptForSelection<T>(
2342
output: process.stdout,
2443
});
2544

26-
// Display the list
45+
// Display the list. Selectable items get sequential numbers; separators render as plain
46+
// headers with a single leading space (matches inquirer's separator rendering in
47+
// @inquirer/select, which prints ` ${separator}` so headers visually outdent from items).
2748
rl.write(`${message}\n`);
28-
for (let i = 0; i < choices.length; i++) {
29-
rl.write(` [${i + 1}] ${choices[i]!.name}\n`);
49+
let index = 0;
50+
for (const choice of choices) {
51+
if (isSeparator(choice)) {
52+
rl.write(` ${choice.separator}\n`);
53+
} else {
54+
index += 1;
55+
rl.write(` [${index}] ${choice.name}\n`);
56+
}
3057
}
3158

3259
return new Promise<T | null>((resolve) => {
@@ -35,9 +62,7 @@ export function promptForSelection<T>(
3562
const finish = (result: T | null) => {
3663
if (settled) return;
3764
settled = true;
38-
if (!rl.closed) {
39-
rl.close();
40-
}
65+
rl.close();
4166
resolve(result);
4267
};
4368

@@ -62,7 +87,7 @@ export function promptForSelection<T>(
6287
// Require the entire input to be a base-10 integer string
6388
if (!/^\d+$/.test(trimmed)) {
6489
rl.write(
65-
`Invalid selection. Enter a number between 1 and ${choices.length}.\n`,
90+
`Invalid selection. Enter a number between 1 and ${selectable.length}.\n`,
6691
);
6792
ask();
6893
return;
@@ -71,15 +96,15 @@ export function promptForSelection<T>(
7196
const num = Number.parseInt(trimmed, 10);
7297

7398
// Out of range → re-prompt
74-
if (num < 1 || num > choices.length) {
99+
if (num < 1 || num > selectable.length) {
75100
rl.write(
76-
`Invalid selection. Enter a number between 1 and ${choices.length}.\n`,
101+
`Invalid selection. Enter a number between 1 and ${selectable.length}.\n`,
77102
);
78103
ask();
79104
return;
80105
}
81106

82-
finish(choices[num - 1]!.value);
107+
finish(selectable[num - 1]!.value);
83108
});
84109
};
85110

test/unit/hooks/interactive-did-you-mean.test.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -290,10 +290,7 @@ describe("Did You Mean Hook - Interactive Mode", function () {
290290
expect(mockReadline.removeAllListeners).toHaveBeenCalledWith("line");
291291
expect(mockReadline.on.mock.calls.length).toBe(lineListeners.length);
292292
lineListeners.forEach((listener, index) => {
293-
expect(mockReadline.on.mock.calls[index]).toEqual([
294-
"line",
295-
listener,
296-
]);
293+
expect(mockReadline.on.mock.calls[index]).toEqual(["line", listener]);
297294
});
298295

299296
// Verify terminal state was restored

test/unit/utils/prompt-selection.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,4 +187,78 @@ describe("promptForSelection", () => {
187187
const result = await promptForSelection("Select:", choices);
188188
expect(result).toBeNull();
189189
});
190+
191+
describe("separators", () => {
192+
it("renders separator entries un-numbered with single-space prefix", async () => {
193+
mockQuestion = (_query, callback) => callback("1");
194+
await promptForSelection("Select an account:", [
195+
{ separator: "── Local accounts ──" },
196+
{ name: "prod", value: "prod" },
197+
{ name: "staging", value: "staging" },
198+
{ separator: "── Other accounts ──" },
199+
{ name: "external", value: "external" },
200+
]);
201+
202+
// Separators: single leading space, no [N] marker (matches inquirer's render)
203+
expect(mockWrite).toHaveBeenCalledWith(" ── Local accounts ──\n");
204+
expect(mockWrite).toHaveBeenCalledWith(" ── Other accounts ──\n");
205+
// Selectable items numbered sequentially, ignoring separators
206+
expect(mockWrite).toHaveBeenCalledWith(" [1] prod\n");
207+
expect(mockWrite).toHaveBeenCalledWith(" [2] staging\n");
208+
expect(mockWrite).toHaveBeenCalledWith(" [3] external\n");
209+
});
210+
211+
it("numbers only selectable items so '1' picks the first selectable", async () => {
212+
mockQuestion = (_query, callback) => callback("1");
213+
const result = await promptForSelection<string>("Pick:", [
214+
{ separator: "── Section ──" },
215+
{ name: "first", value: "first" },
216+
{ name: "second", value: "second" },
217+
]);
218+
expect(result).toBe("first");
219+
});
220+
221+
it("rejects out-of-range numbers based on selectable count, not total entries", async () => {
222+
// 2 selectables + 2 separators = 4 entries. "3" must be rejected.
223+
let callCount = 0;
224+
mockQuestion = (_query, callback) => {
225+
callCount++;
226+
if (callCount === 1) {
227+
callback("3");
228+
} else {
229+
callback("2");
230+
}
231+
};
232+
const result = await promptForSelection<string>("Pick:", [
233+
{ separator: "── A ──" },
234+
{ name: "first", value: "first" },
235+
{ separator: "── B ──" },
236+
{ name: "second", value: "second" },
237+
]);
238+
expect(result).toBe("second");
239+
expect(callCount).toBe(2);
240+
expect(mockWrite).toHaveBeenCalledWith(
241+
"Invalid selection. Enter a number between 1 and 2.\n",
242+
);
243+
});
244+
245+
it("returns null when choices contain only separators (no selectables)", async () => {
246+
const result = await promptForSelection("Pick:", [
247+
{ separator: "── A ──" },
248+
{ separator: "── B ──" },
249+
]);
250+
expect(result).toBeNull();
251+
expect(mockWrite).not.toHaveBeenCalled();
252+
});
253+
254+
it("supports separators interleaved at any position", async () => {
255+
mockQuestion = (_query, callback) => callback("2");
256+
const result = await promptForSelection<string>("Pick:", [
257+
{ name: "alpha", value: "alpha" },
258+
{ separator: "── divider ──" },
259+
{ name: "beta", value: "beta" },
260+
]);
261+
expect(result).toBe("beta");
262+
});
263+
});
190264
});

0 commit comments

Comments
 (0)