Skip to content

Commit 159ae65

Browse files
henrikjeclaude
andauthored
fix: redirect all @InQuirer prompts to stderr to prevent arb cd hanging (#12)
When shell integration is active, `arb cd` (without arguments) hangs because the shell wrapper captures stdout via command substitution, turning it into a pipe. @inquirer/select renders its interactive UI to stdout by default, so the user never sees the prompt. Pass `{ output: process.stderr }` as the context argument to all 12 @InQuirer calls across the codebase. Also strengthen the TTY guard in cd.ts to check `process.stderr.isTTY` since that is now where the UI renders. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7834505 commit 159ae65

8 files changed

Lines changed: 92 additions & 45 deletions

File tree

src/commands/cd.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export function registerCdCommand(program: Command, getCtx: () => ArbContext): v
1616
const ctx = getCtx();
1717

1818
if (!input) {
19-
if (!process.stdin.isTTY) {
19+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
2020
error("Usage: arb cd <workspace>");
2121
process.exit(1);
2222
}
@@ -27,11 +27,14 @@ export function registerCdCommand(program: Command, getCtx: () => ArbContext): v
2727
process.exit(1);
2828
}
2929

30-
const selected = await select({
31-
message: "Select a workspace",
32-
choices: workspaces.map((name) => ({ name, value: name })),
33-
pageSize: 20,
34-
});
30+
const selected = await select(
31+
{
32+
message: "Select a workspace",
33+
choices: workspaces.map((name) => ({ name, value: name })),
34+
pageSize: 20,
35+
},
36+
{ output: process.stderr },
37+
);
3538

3639
process.stdout.write(`${ctx.baseDir}/${selected}\n`);
3740
printHintIfNeeded();

src/commands/create.ts

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,13 @@ export function registerCreateCommand(program: Command, getCtx: () => ArbContext
3939
error("Usage: arb create <name> [repos...]");
4040
process.exit(1);
4141
}
42-
name = await input({
43-
message: "Workspace name:",
44-
validate: (v) => validateWorkspaceName(v) ?? true,
45-
});
42+
name = await input(
43+
{
44+
message: "Workspace name:",
45+
validate: (v) => validateWorkspaceName(v) ?? true,
46+
},
47+
{ output: process.stderr },
48+
);
4649
}
4750

4851
const validationError = validateWorkspaceName(name);
@@ -55,11 +58,14 @@ export function registerCreateCommand(program: Command, getCtx: () => ArbContext
5558
if (!branch) {
5659
const defaultBranch = name.toLowerCase();
5760
if (!nameArg && process.stdin.isTTY) {
58-
branch = await input({
59-
message: "Branch name:",
60-
default: defaultBranch,
61-
validate: (v) => (validateBranchName(v) ? true : "Invalid branch name"),
62-
});
61+
branch = await input(
62+
{
63+
message: "Branch name:",
64+
default: defaultBranch,
65+
validate: (v) => (validateBranchName(v) ? true : "Invalid branch name"),
66+
},
67+
{ output: process.stderr },
68+
);
6369
} else {
6470
branch = defaultBranch;
6571
}
@@ -72,9 +78,12 @@ export function registerCreateCommand(program: Command, getCtx: () => ArbContext
7278

7379
let base = options.base;
7480
if (!base && !nameArg && process.stdin.isTTY) {
75-
const baseInput = await input({
76-
message: "Base branch (leave blank for repo default):",
77-
});
81+
const baseInput = await input(
82+
{
83+
message: "Base branch (leave blank for repo default):",
84+
},
85+
{ output: process.stderr },
86+
);
7887
if (baseInput.trim()) {
7988
base = baseInput.trim();
8089
}

src/commands/pull.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,13 @@ export function registerPullCommand(program: Command, getCtx: () => ArbContext):
101101
error("Not a terminal. Use --yes to skip confirmation.");
102102
process.exit(1);
103103
}
104-
const ok = await confirm({
105-
message: `Pull ${plural(willPull.length, "repo")}?`,
106-
default: false,
107-
});
104+
const ok = await confirm(
105+
{
106+
message: `Pull ${plural(willPull.length, "repo")}?`,
107+
default: false,
108+
},
109+
{ output: process.stderr },
110+
);
108111
if (!ok) {
109112
process.stderr.write("Aborted.\n");
110113
process.exit(130);

src/commands/push.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,13 @@ export function registerPushCommand(program: Command, getCtx: () => ArbContext):
101101
error("Not a terminal. Use --yes to skip confirmation.");
102102
process.exit(1);
103103
}
104-
const ok = await confirm({
105-
message: `Push ${plural(willPush.length, "repo")}?`,
106-
default: false,
107-
});
104+
const ok = await confirm(
105+
{
106+
message: `Push ${plural(willPush.length, "repo")}?`,
107+
default: false,
108+
},
109+
{ output: process.stderr },
110+
);
108111
if (!ok) {
109112
process.stderr.write("Aborted.\n");
110113
process.exit(130);

src/commands/remove.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -286,10 +286,13 @@ export function registerRemoveCommand(program: Command, getCtx: () => ArbContext
286286
error("Cannot prompt for confirmation: not a terminal. Use --force to skip prompts.");
287287
process.exit(1);
288288
}
289-
const shouldRemove = await confirm({
290-
message: `Remove ${plural(okEntries.length, "workspace")}?`,
291-
default: false,
292-
});
289+
const shouldRemove = await confirm(
290+
{
291+
message: `Remove ${plural(okEntries.length, "workspace")}?`,
292+
default: false,
293+
},
294+
{ output: process.stderr },
295+
);
293296
if (!shouldRemove) {
294297
process.stderr.write("Aborted.\n");
295298
return;
@@ -368,15 +371,18 @@ export function registerRemoveCommand(program: Command, getCtx: () => ArbContext
368371
const confirmMsg = isSingle
369372
? `Remove workspace ${singleName}?`
370373
: `Remove ${plural(assessments.length, "workspace")}?`;
371-
const shouldRemove = await confirm({ message: confirmMsg, default: false });
374+
const shouldRemove = await confirm({ message: confirmMsg, default: false }, { output: process.stderr });
372375
if (!shouldRemove) {
373376
process.stderr.write("Aborted.\n");
374377
return;
375378
}
376379

377380
const allRemoteRepos = assessments.flatMap((a) => a.remoteRepos);
378381
if (allRemoteRepos.length > 0 && !deleteRemote) {
379-
deleteRemote = await confirm({ message: "Also delete remote branches?", default: false });
382+
deleteRemote = await confirm(
383+
{ message: "Also delete remote branches?", default: false },
384+
{ output: process.stderr },
385+
);
380386
}
381387
}
382388

src/lib/integrate.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,13 @@ export async function integrate(
9898
error("Not a terminal. Use --yes to skip confirmation.");
9999
process.exit(1);
100100
}
101-
const ok = await confirm({
102-
message: `${verb} ${plural(willOperate.length, "repo")}?`,
103-
default: false,
104-
});
101+
const ok = await confirm(
102+
{
103+
message: `${verb} ${plural(willOperate.length, "repo")}?`,
104+
default: false,
105+
},
106+
{ output: process.stderr },
107+
);
105108
if (!ok) {
106109
process.stderr.write("Aborted.\n");
107110
process.exit(130);

src/lib/repos.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,24 @@ export async function selectInteractive(items: string[], message: string): Promi
3737
}
3838

3939
if (items.length === 1) {
40-
const yes = await confirm({
41-
message: `Only option: ${items[0]}. Include it?`,
42-
default: true,
43-
});
40+
const yes = await confirm(
41+
{
42+
message: `Only option: ${items[0]}. Include it?`,
43+
default: true,
44+
},
45+
{ output: process.stderr },
46+
);
4447
return yes ? items : [];
4548
}
4649

47-
return checkbox({
48-
message,
49-
choices: items.map((name) => ({ name, value: name })),
50-
pageSize: 20,
51-
});
50+
return checkbox(
51+
{
52+
message,
53+
choices: items.map((name) => ({ name, value: name })),
54+
pageSize: 20,
55+
},
56+
{ output: process.stderr },
57+
);
5258
}
5359

5460
export async function selectReposInteractive(reposDir: string): Promise<string[]> {

test/arb.bats

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,20 @@ teardown() {
849849
[[ "$output" == *"does not exist"* ]]
850850
}
851851

852+
@test "arb cd path output is clean when stdout is captured (shell wrapper pattern)" {
853+
arb create my-feature --all-repos
854+
# Simulate the shell wrapper: capture stdout via $(), which makes stdout a pipe.
855+
# Verify only the workspace path appears on stdout (no UI, no hint).
856+
_arb_dir="$(arb cd my-feature 2>/dev/null)"
857+
[ "$_arb_dir" = "$TEST_DIR/project/my-feature" ]
858+
}
859+
860+
@test "arb cd subpath output is clean when stdout is captured" {
861+
arb create my-feature repo-a
862+
_arb_dir="$(arb cd my-feature/repo-a 2>/dev/null)"
863+
[ "$_arb_dir" = "$TEST_DIR/project/my-feature/repo-a" ]
864+
}
865+
852866
# ── status ───────────────────────────────────────────────────────
853867

854868
@test "arb status shows base branch name" {

0 commit comments

Comments
 (0)