Skip to content

Commit 9fe4b9b

Browse files
committed
feat(setup): install bash completions as fallback for unsupported shells
1 parent 25fe2f4 commit 9fe4b9b

File tree

4 files changed

+159
-11
lines changed

4 files changed

+159
-11
lines changed

src/commands/cli/setup.ts

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
addToPath,
2121
detectShell,
2222
getPathCommand,
23+
isBashAvailable,
2324
isInPath,
2425
type ShellInfo,
2526
} from "../../lib/shell.js";
@@ -117,31 +118,82 @@ async function handlePathModification(
117118
}
118119
}
119120

121+
/**
122+
* Attempt to install bash completions as a fallback for unsupported shells.
123+
*
124+
* Many custom shells (xonsh, nushell, etc.) can load bash completions,
125+
* so this is a useful fallback when the user's shell isn't directly supported.
126+
*
127+
* @param pathEnv - The PATH to search for bash, forwarded from the process env.
128+
*/
129+
async function tryBashCompletionFallback(
130+
homeDir: string,
131+
xdgDataHome: string | undefined,
132+
pathEnv: string | undefined
133+
): Promise<string | null> {
134+
if (!isBashAvailable(pathEnv)) {
135+
return null;
136+
}
137+
138+
const fallback = await installCompletions("bash", homeDir, xdgDataHome);
139+
if (!fallback) {
140+
// Defensive: installCompletions returns null only if the shell type has no
141+
// completion script or path configured. "bash" is always supported, but
142+
// we guard here in case that changes in future.
143+
return null;
144+
}
145+
const action = fallback.created ? "Installed" : "Updated";
146+
return ` ${action} bash completions as a fallback: ${fallback.path}`;
147+
}
148+
120149
/**
121150
* Handle shell completion installation.
151+
*
152+
* For unsupported shells (xonsh, nushell, etc.), falls back to installing
153+
* bash completions if bash is available on the system. Uses the provided
154+
* PATH env to check for bash so the call is testable without side effects.
122155
*/
123156
async function handleCompletions(
124157
shell: ShellInfo,
125158
homeDir: string,
126159
xdgDataHome: string | undefined,
127-
log: Logger
128-
): Promise<void> {
160+
pathEnv: string | undefined
161+
): Promise<string[]> {
129162
const location = await installCompletions(shell.type, homeDir, xdgDataHome);
130163

131164
if (location) {
132165
const action = location.created ? "Installed to" : "Updated";
133-
log(`Completions: ${action} ${location.path}`);
166+
const lines = [`Completions: ${action} ${location.path}`];
134167

135168
// Zsh may need fpath hint
136169
if (shell.type === "zsh") {
137170
const completionDir = dirname(location.path);
138-
log(
171+
lines.push(
139172
` You may need to add to .zshrc: fpath=(${completionDir} $fpath)`
140173
);
141174
}
142-
} else if (shell.type !== "sh" && shell.type !== "ash") {
143-
log(`Completions: Not supported for ${shell.type} shell`);
175+
return lines;
176+
}
177+
178+
// sh/ash are minimal POSIX shells — completions aren't expected
179+
if (shell.type === "sh" || shell.type === "ash") {
180+
return [];
144181
}
182+
183+
const fallbackMsg = await tryBashCompletionFallback(
184+
homeDir,
185+
xdgDataHome,
186+
pathEnv
187+
);
188+
189+
if (fallbackMsg) {
190+
return [
191+
`Completions: Your shell (${shell.type}) is not directly supported`,
192+
fallbackMsg,
193+
];
194+
}
195+
196+
return [`Completions: Not supported for ${shell.type} shell`];
145197
}
146198

147199
/**
@@ -284,7 +336,15 @@ export const setupCommand = buildCommand({
284336

285337
// 3. Install shell completions
286338
if (!flags["no-completions"]) {
287-
await handleCompletions(shell, homeDir, process.env.XDG_DATA_HOME, log);
339+
const completionLines = await handleCompletions(
340+
shell,
341+
homeDir,
342+
process.env.XDG_DATA_HOME,
343+
process.env.PATH
344+
);
345+
for (const line of completionLines) {
346+
log(line);
347+
}
288348
}
289349

290350
// 4. Install agent skills (auto-detected, silent when no agent found)

src/lib/shell.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,3 +266,17 @@ export async function addToGitHubPath(
266266
return false;
267267
}
268268
}
269+
270+
/**
271+
* Check if bash is available on the system.
272+
*
273+
* Uses `Bun.which` to search PATH for a bash executable.
274+
* Useful as a fallback for unsupported shells — many custom shells
275+
* (xonsh, nushell, etc.) support bash completions.
276+
*
277+
* @param pathEnv - Override PATH for testing. Defaults to the process PATH.
278+
*/
279+
export function isBashAvailable(pathEnv?: string): boolean {
280+
const opts = pathEnv !== undefined ? { PATH: pathEnv } : undefined;
281+
return Bun.which("bash", opts) !== null;
282+
}

test/commands/cli/setup.test.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -297,13 +297,65 @@ describe("sentry cli setup", () => {
297297
expect(combined).toContain("GITHUB_PATH");
298298
});
299299

300-
test("shows unsupported message for sh shell completions", async () => {
300+
test("falls back to bash completions for unsupported shell when bash is available", async () => {
301+
// Create a fake bash executable in testDir/bin so isBashAvailable() returns
302+
// true with PATH pointing there — no dependency on the host system.
303+
const binDir = join(testDir, "bin");
304+
mkdirSync(binDir, { recursive: true });
305+
const { chmodSync, writeFileSync: wf } = await import("node:fs");
306+
const fakeBash = join(binDir, "bash");
307+
wf(fakeBash, "#!/bin/sh\necho fake-bash");
308+
chmodSync(fakeBash, 0o755);
309+
310+
const { context, output } = createMockContext({
311+
homeDir: testDir,
312+
execPath: join(testDir, "bin", "sentry"),
313+
env: {
314+
PATH: binDir,
315+
SHELL: "/bin/xonsh",
316+
},
317+
});
318+
319+
await run(
320+
app,
321+
["cli", "setup", "--no-modify-path", "--no-agent-skills"],
322+
context
323+
);
324+
325+
const combined = output.join("");
326+
expect(combined).toContain("not directly supported");
327+
expect(combined).toContain("bash completions as a fallback");
328+
expect(combined).toContain("bash-completion");
329+
});
330+
331+
test("shows unsupported message for unknown shell when bash is not in PATH", async () => {
332+
const { context, output } = createMockContext({
333+
homeDir: testDir,
334+
execPath: join(testDir, "bin", "sentry"),
335+
env: {
336+
// Empty PATH so isBashAvailable() returns false
337+
PATH: "",
338+
SHELL: "/bin/xonsh",
339+
},
340+
});
341+
342+
await run(
343+
app,
344+
["cli", "setup", "--no-modify-path", "--no-agent-skills"],
345+
context
346+
);
347+
348+
const combined = output.join("");
349+
expect(combined).toContain("Not supported for unknown shell");
350+
});
351+
352+
test("silently skips completions for sh shell", async () => {
301353
const { context, output } = createMockContext({
302354
homeDir: testDir,
303355
execPath: join(testDir, "bin", "sentry"),
304356
env: {
305357
PATH: `/usr/bin:${join(testDir, "bin")}:/bin`,
306-
SHELL: "/bin/tcsh",
358+
SHELL: "/bin/sh",
307359
},
308360
});
309361

@@ -314,7 +366,8 @@ describe("sentry cli setup", () => {
314366
);
315367

316368
const combined = output.join("");
317-
expect(combined).toContain("Not supported for");
369+
// sh/ash shells silently skip completions — no message at all
370+
expect(combined).not.toContain("Completions:");
318371
});
319372

320373
test("supports kebab-case flags", async () => {

test/lib/shell.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77

88
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
99
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
10-
import { join } from "node:path";
10+
import { dirname, join } from "node:path";
1111
import {
1212
addToGitHubPath,
1313
addToPath,
1414
detectShell,
1515
findExistingConfigFile,
1616
getConfigCandidates,
17+
isBashAvailable,
1718
} from "../../src/lib/shell.js";
1819

1920
describe("shell utilities", () => {
@@ -257,3 +258,23 @@ describe("shell utilities", () => {
257258
});
258259
});
259260
});
261+
262+
describe("isBashAvailable", () => {
263+
test("returns true when bash is in PATH", () => {
264+
// Point PATH at the directory containing bash
265+
const bashPath = Bun.which("bash");
266+
if (!bashPath) {
267+
// Skip if bash truly isn't on this system
268+
return;
269+
}
270+
expect(isBashAvailable(dirname(bashPath))).toBe(true);
271+
});
272+
273+
test("returns false when PATH has no bash", () => {
274+
expect(isBashAvailable("/nonexistent")).toBe(false);
275+
});
276+
277+
test("returns false when PATH is empty", () => {
278+
expect(isBashAvailable("")).toBe(false);
279+
});
280+
});

0 commit comments

Comments
 (0)