Skip to content

Commit 96dfee4

Browse files
LeTamanoirtmm
andauthored
fix: export env vars in bash/zsh completion hooks (#112)
* fix: export COMPLETE and _COMPLETE_INDEX env vars in bash/zsh completion hooks The bash and zsh registration scripts set COMPLETE and _COMPLETE_INDEX as shell variables inside $(...) subshells, but never export them. This means the CLI binary does not see them in process.env, falls through to normal arg parsing, and errors on the -- separator. Fish and nushell are unaffected because they use inline prefix syntax (COMPLETE=fish cmd ...) which always exports to the child. Signed-off-by: LeTamanoir <martin.saldinger@kiln.fi> * test: add shell completion regression coverage * chore: add changeset for shell completion fix --------- Signed-off-by: LeTamanoir <martin.saldinger@kiln.fi> Co-authored-by: tmm <tmm@tmm.dev>
1 parent ede37be commit 96dfee4

File tree

3 files changed

+106
-6
lines changed

3 files changed

+106
-6
lines changed

.changeset/swift-rabbits-dance.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
incur: patch
3+
---
4+
5+
Exported shell completion environment variables in bash and zsh hooks.

src/Completions.test.ts

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import { execFile, spawnSync } from 'node:child_process'
2+
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
15
import { Cli, Completions, z } from 'incur'
26

37
const originalIsTTY = process.stdout.isTTY
@@ -15,6 +19,45 @@ vi.mock('./SyncSkills.js', async (importOriginal) => {
1519
return { ...actual, readHash: () => undefined }
1620
})
1721

22+
function hasShell(shell: string): boolean {
23+
return spawnSync(shell, ['-c', ':'], { stdio: 'ignore' }).status === 0
24+
}
25+
26+
const bash = hasShell('bash')
27+
const zsh = hasShell('zsh')
28+
const fish = hasShell('fish')
29+
30+
function exec(cmd: string, args: string[], env: NodeJS.ProcessEnv): Promise<string> {
31+
return new Promise((resolve, reject) => {
32+
execFile(cmd, args, { env, timeout: 30_000 }, (error, stdout, stderr) => {
33+
if (error) reject(new Error(stderr?.trim() || stdout?.trim() || error.message))
34+
else resolve(stdout)
35+
})
36+
})
37+
}
38+
39+
async function withFakeCli(run: (dir: string) => Promise<void>) {
40+
const dir = await mkdtemp(join(tmpdir(), 'incur-completions-'))
41+
const bin = join(dir, 'fake-cli')
42+
43+
try {
44+
await writeFile(
45+
bin,
46+
`#!/bin/sh
47+
if [ -n "$COMPLETE" ]; then
48+
printf '%s' "$COMPLETE:\${_COMPLETE_INDEX:-missing}"
49+
else
50+
printf 'missing'
51+
fi
52+
`,
53+
)
54+
await chmod(bin, 0o755)
55+
await run(dir)
56+
} finally {
57+
await rm(dir, { recursive: true, force: true })
58+
}
59+
}
60+
1861
async function serve(
1962
cli: { serve: Cli.Cli['serve'] },
2063
argv: string[],
@@ -231,27 +274,79 @@ describe('register', () => {
231274
test('bash: generates complete -F script with nospace support', () => {
232275
const script = Completions.register('bash', 'mycli')
233276
expect(script).toContain('_incur_complete_mycli()')
234-
expect(script).toContain('COMPLETE="bash"')
277+
expect(script).toContain('export COMPLETE="bash"')
235278
expect(script).toContain('complete -o default -o bashdefault -o nosort -F')
236279
expect(script).toContain('"mycli" -- "${COMP_WORDS[@]}"')
237280
expect(script).toContain('compopt -o nospace')
238281
})
239282

283+
test.skipIf(!bash)('bash: exports completion env vars to the CLI subprocess', async () => {
284+
await withFakeCli(async (dir) => {
285+
const output = await exec(
286+
'bash',
287+
[
288+
'-lc',
289+
`${Completions.register('bash', 'fake-cli')}
290+
COMP_WORDS=('fake-cli' 'build' '')
291+
COMP_CWORD=2
292+
_incur_complete_fake_cli
293+
printf '%s' "\${COMPREPLY[*]}"`,
294+
],
295+
{ ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` },
296+
)
297+
298+
expect(output).toBe('bash:2')
299+
})
300+
})
301+
240302
test('zsh: generates compdef script', () => {
241303
const script = Completions.register('zsh', 'mycli')
242304
expect(script).toContain('#compdef mycli')
243-
expect(script).toContain('COMPLETE="zsh"')
305+
expect(script).toContain('export COMPLETE="zsh"')
244306
expect(script).toContain('compdef _incur_complete_mycli mycli')
245307
expect(script).toContain('_describe')
246308
})
247309

310+
test.skipIf(!zsh)('zsh: exports completion env vars to the CLI subprocess', async () => {
311+
await withFakeCli(async (dir) => {
312+
const output = await exec(
313+
'zsh',
314+
[
315+
'-lc',
316+
`compdef() { : }
317+
_describe() { print -r -- "\${(j:|:)\${(@P)2}}" }
318+
${Completions.register('zsh', 'fake-cli')}
319+
words=('fake-cli' 'build' '')
320+
CURRENT=3
321+
_incur_complete_fake_cli`,
322+
],
323+
{ ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` },
324+
)
325+
326+
expect(output.trim()).toBe('zsh:2')
327+
})
328+
})
329+
248330
test('fish: generates complete command', () => {
249331
const script = Completions.register('fish', 'mycli')
250332
expect(script).toContain('complete --keep-order --exclusive --command mycli')
251333
expect(script).toContain('COMPLETE=fish')
252334
expect(script).toContain('commandline --current-token')
253335
})
254336

337+
test.skipIf(!fish)('fish: passes completion env vars to the CLI subprocess', async () => {
338+
await withFakeCli(async (dir) => {
339+
const output = await exec(
340+
'fish',
341+
['-c', `${Completions.register('fish', 'fake-cli')}
342+
complete --do-complete 'fake-cli '`],
343+
{ ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` },
344+
)
345+
346+
expect(output.trim()).toBe('fish:missing')
347+
})
348+
})
349+
255350
test('nushell: generates external completer closure', () => {
256351
const script = Completions.register('nushell', 'mycli')
257352
expect(script).toContain('COMPLETE=nushell')

src/Completions.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -232,8 +232,8 @@ function bashRegister(name: string): string {
232232
local _COMPLETE_INDEX=\${COMP_CWORD}
233233
local _completions
234234
_completions=( $(
235-
COMPLETE="bash"
236-
_COMPLETE_INDEX="$_COMPLETE_INDEX"
235+
export COMPLETE="bash"
236+
export _COMPLETE_INDEX="$_COMPLETE_INDEX"
237237
"${name}" -- "\${COMP_WORDS[@]}"
238238
) )
239239
if [[ $? != 0 ]]; then
@@ -262,8 +262,8 @@ function zshRegister(name: string): string {
262262
return `#compdef ${name}
263263
_incur_complete_${id}() {
264264
local completions=("\${(@f)$(
265-
_COMPLETE_INDEX=$(( CURRENT - 1 ))
266-
COMPLETE="zsh"
265+
export _COMPLETE_INDEX=$(( CURRENT - 1 ))
266+
export COMPLETE="zsh"
267267
"${name}" -- "\${words[@]}" 2>/dev/null
268268
)}")
269269
if [[ -n $completions ]]; then

0 commit comments

Comments
 (0)