Skip to content

Commit d5bf0cc

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

File tree

4 files changed

+128
-11
lines changed

4 files changed

+128
-11
lines changed

src/commands/cli/setup.ts

Lines changed: 65 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,80 @@ 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+
return null;
141+
}
142+
143+
const action = fallback.created ? "Installed" : "Updated";
144+
return ` ${action} bash completions as a fallback: ${fallback.path}`;
145+
}
146+
120147
/**
121148
* Handle shell completion installation.
149+
*
150+
* For unsupported shells (xonsh, nushell, etc.), falls back to installing
151+
* bash completions if bash is available on the system. Uses the provided
152+
* PATH env to check for bash so the call is testable without side effects.
122153
*/
123154
async function handleCompletions(
124155
shell: ShellInfo,
125156
homeDir: string,
126157
xdgDataHome: string | undefined,
127-
log: Logger
128-
): Promise<void> {
158+
pathEnv: string | undefined
159+
): Promise<string[]> {
129160
const location = await installCompletions(shell.type, homeDir, xdgDataHome);
130161

131162
if (location) {
132163
const action = location.created ? "Installed to" : "Updated";
133-
log(`Completions: ${action} ${location.path}`);
164+
const lines = [`Completions: ${action} ${location.path}`];
134165

135166
// Zsh may need fpath hint
136167
if (shell.type === "zsh") {
137168
const completionDir = dirname(location.path);
138-
log(
169+
lines.push(
139170
` You may need to add to .zshrc: fpath=(${completionDir} $fpath)`
140171
);
141172
}
142-
} else if (shell.type !== "sh" && shell.type !== "ash") {
143-
log(`Completions: Not supported for ${shell.type} shell`);
173+
return lines;
174+
}
175+
176+
// sh/ash are minimal POSIX shells — completions aren't expected
177+
if (shell.type === "sh" || shell.type === "ash") {
178+
return [];
179+
}
180+
181+
const fallbackMsg = await tryBashCompletionFallback(
182+
homeDir,
183+
xdgDataHome,
184+
pathEnv
185+
);
186+
187+
if (fallbackMsg) {
188+
return [
189+
`Completions: Your shell (${shell.type}) is not directly supported`,
190+
fallbackMsg,
191+
];
144192
}
193+
194+
return [`Completions: Not supported for ${shell.type} shell`];
145195
}
146196

147197
/**
@@ -284,7 +334,15 @@ export const setupCommand = buildCommand({
284334

285335
// 3. Install shell completions
286336
if (!flags["no-completions"]) {
287-
await handleCompletions(shell, homeDir, process.env.XDG_DATA_HOME, log);
337+
const completionLines = await handleCompletions(
338+
shell,
339+
homeDir,
340+
process.env.XDG_DATA_HOME,
341+
process.env.PATH
342+
);
343+
for (const line of completionLines) {
344+
log(line);
345+
}
288346
}
289347

290348
// 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: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -297,13 +297,36 @@ 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 () => {
301301
const { context, output } = createMockContext({
302302
homeDir: testDir,
303303
execPath: join(testDir, "bin", "sentry"),
304304
env: {
305305
PATH: `/usr/bin:${join(testDir, "bin")}:/bin`,
306-
SHELL: "/bin/tcsh",
306+
SHELL: "/bin/xonsh",
307+
},
308+
});
309+
310+
await run(
311+
app,
312+
["cli", "setup", "--no-modify-path", "--no-agent-skills"],
313+
context
314+
);
315+
316+
const combined = output.join("");
317+
// bash is available on the test system, so we should get fallback completions
318+
expect(combined).toContain("not directly supported");
319+
expect(combined).toContain("bash completions as a fallback");
320+
expect(combined).toContain("bash-completion");
321+
});
322+
323+
test("silently skips completions for sh shell", async () => {
324+
const { context, output } = createMockContext({
325+
homeDir: testDir,
326+
execPath: join(testDir, "bin", "sentry"),
327+
env: {
328+
PATH: `/usr/bin:${join(testDir, "bin")}:/bin`,
329+
SHELL: "/bin/sh",
307330
},
308331
});
309332

@@ -314,7 +337,8 @@ describe("sentry cli setup", () => {
314337
);
315338

316339
const combined = output.join("");
317-
expect(combined).toContain("Not supported for");
340+
// sh/ash shells silently skip completions — no message at all
341+
expect(combined).not.toContain("Completions:");
318342
});
319343

320344
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)