goke 6.9.0 → 6.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Shell completion support for goke CLIs.
3
+ *
4
+ * Two pieces work together:
5
+ * 1. A hidden `--get-goke-completions` flag in the CLI binary. When present,
6
+ * the CLI skips normal execution and prints matching completions to stdout.
7
+ * 2. A shell-specific shim script installed into an fpath/completion directory.
8
+ * On each Tab press the shell calls the CLI binary with the flag + current words.
9
+ *
10
+ * The `installCompletions` function finds a writable completion directory and
11
+ * writes the shim. Since the shim calls the binary on every Tab, completions
12
+ * are always up-to-date with the installed CLI.
13
+ */
14
+
15
+ import { process } from '#runtime'
16
+
17
+ // ─── Constants ───
18
+
19
+ /** The hidden flag the shell script passes to the CLI on each Tab press */
20
+ export const COMPLETION_FLAG = 'get-goke-completions'
21
+
22
+ // ─── Shell templates ───
23
+
24
+ /**
25
+ * Zsh completion script template.
26
+ *
27
+ * The `#compdef` header lets zsh autoload this as an fpath function.
28
+ * On Tab press it calls the CLI binary with `--get-goke-completions` and
29
+ * all typed words. The binary returns `name:description` pairs (one per line)
30
+ * and zsh renders them with `_describe`.
31
+ */
32
+ export const zshTemplate = `#compdef {{app_name}}
33
+ ###-begin-{{app_name}}-completions-###
34
+ _{{app_name_safe}}_completions() {
35
+ local reply
36
+ local si=$IFS
37
+ IFS=$'\\n' reply=($(COMP_CWORD="$((CURRENT-1))" COMP_LINE="$BUFFER" COMP_POINT="$CURSOR" GOKE_COMPLETION_SHELL=zsh {{app_path}} --get-goke-completions "\${words[@]}"))
38
+ IFS=$si
39
+ if [[ \${#reply} -gt 0 ]]; then
40
+ _describe 'values' reply
41
+ else
42
+ _default
43
+ fi
44
+ }
45
+ if [[ "'\${zsh_eval_context[-1]}" == "loadautofunc" ]]; then
46
+ _{{app_name_safe}}_completions "$@"
47
+ else
48
+ compdef _{{app_name_safe}}_completions {{app_name}}
49
+ fi
50
+ ###-end-{{app_name}}-completions-###
51
+ `
52
+
53
+ /**
54
+ * Bash completion script template.
55
+ *
56
+ * On Tab press bash calls the function which invokes the CLI binary with
57
+ * `--get-goke-completions` and all typed words. The binary returns plain
58
+ * completion strings (one per line).
59
+ */
60
+ export const bashTemplate = `###-begin-{{app_name}}-completions-###
61
+ _{{app_name_safe}}_completions()
62
+ {
63
+ local cur_word args type_list
64
+
65
+ cur_word="\${COMP_WORDS[COMP_CWORD]}"
66
+ args=("\${COMP_WORDS[@]}")
67
+
68
+ # Bash 3 compatible (no mapfile). Works on macOS default bash.
69
+ local IFS=$'\\n'
70
+ type_list=($(GOKE_COMPLETION_SHELL=bash {{app_path}} --get-goke-completions "\${args[@]}"))
71
+ unset IFS
72
+ COMPREPLY=($(compgen -W "$( printf '%q ' "\${type_list[@]}" )" -- "\${cur_word}" |
73
+ awk '/ / { print "\\""$0"\\"" } /^[^ ]+$/ { print $0 }'))
74
+
75
+ if [ \${#COMPREPLY[@]} -eq 0 ]; then
76
+ COMPREPLY=()
77
+ fi
78
+
79
+ return 0
80
+ }
81
+ complete -o bashdefault -o default -F _{{app_name_safe}}_completions {{app_name}}
82
+ ###-end-{{app_name}}-completions-###
83
+ `
84
+
85
+ // ─── Script generation ───
86
+
87
+ export type ShellType = 'zsh' | 'bash'
88
+
89
+ /**
90
+ * Detect the current shell from environment variables.
91
+ * Returns 'zsh', 'bash', or null if unrecognized.
92
+ */
93
+ export function detectShell(): ShellType | null {
94
+ const shell = process.env.SHELL ?? ''
95
+ if (shell.includes('zsh')) return 'zsh'
96
+ if (shell.includes('bash')) return 'bash'
97
+ return null
98
+ }
99
+
100
+ /**
101
+ * Validate and normalize a shell value from user input.
102
+ * Returns a valid ShellType or throws if the value is invalid.
103
+ */
104
+ export function validateShell(value: unknown): ShellType | undefined {
105
+ if (value == null || value === '') return undefined
106
+ if (value === 'zsh' || value === 'bash') return value
107
+ throw new Error(`Invalid shell "${String(value)}". Expected "zsh" or "bash".`)
108
+ }
109
+
110
+ /**
111
+ * Detect which shell format to use for completion output.
112
+ * Prefers the explicit GOKE_COMPLETION_SHELL env var (set by the shell shim)
113
+ * over the login $SHELL. This prevents format mismatch when a bash shim runs
114
+ * on a system where $SHELL is zsh.
115
+ */
116
+ export function detectCompletionShell(): ShellType | null {
117
+ const explicit = process.env.GOKE_COMPLETION_SHELL
118
+ if (explicit === 'zsh' || explicit === 'bash') return explicit
119
+ return detectShell()
120
+ }
121
+
122
+ /**
123
+ * Make a string safe for use as a shell function name.
124
+ * Replaces non-alphanumeric chars with underscores.
125
+ */
126
+ function safeShellName(name: string): string {
127
+ return name.replace(/[^a-zA-Z0-9]/g, '_')
128
+ }
129
+
130
+ /**
131
+ * Generate a completion script for the given shell.
132
+ *
133
+ * @param shell - Target shell ('zsh' or 'bash')
134
+ * @param cliName - The CLI binary name (e.g. 'my-cli')
135
+ * @param cliPath - Full path to the CLI binary. If not provided, uses cliName.
136
+ */
137
+ export function generateCompletionScript(
138
+ shell: ShellType,
139
+ cliName: string,
140
+ cliPath?: string,
141
+ ): string {
142
+ const template = shell === 'zsh' ? zshTemplate : bashTemplate
143
+ const path = cliPath ?? cliName
144
+ const safeName = safeShellName(cliName)
145
+
146
+ return template
147
+ .replace(/{{app_name}}/g, cliName)
148
+ .replace(/{{app_name_safe}}/g, safeName)
149
+ .replace(/{{app_path}}/g, path)
150
+ }
151
+
152
+ // ─── Well-known completion directories ───
153
+
154
+ /**
155
+ * Well-known zsh fpath directories, ordered by preference.
156
+ * The first writable one wins.
157
+ */
158
+ const ZSH_FPATH_CANDIDATES = [
159
+ // Homebrew (macOS arm64) — user-writable
160
+ '/opt/homebrew/share/zsh/site-functions',
161
+ // Homebrew (macOS x86) / Linux system-wide
162
+ '/usr/local/share/zsh/site-functions',
163
+ // OS vendor
164
+ '/usr/share/zsh/site-functions',
165
+ // User-level fallback (no sudo needed, but user must add to fpath in .zshrc)
166
+ `${process.env.HOME}/.zsh/completions`,
167
+ ]
168
+
169
+ /**
170
+ * Well-known bash completion directories, ordered by preference.
171
+ */
172
+ const BASH_COMPLETION_CANDIDATES = [
173
+ // XDG standard (user-writable)
174
+ `${process.env.HOME}/.local/share/bash-completion/completions`,
175
+ // Legacy user dir
176
+ `${process.env.HOME}/.bash_completion.d`,
177
+ ]
178
+
179
+ // ─── Installation ───
180
+
181
+ interface InstallResult {
182
+ /** The file path where the completion script was written */
183
+ path: string
184
+ /** The shell the script was generated for */
185
+ shell: ShellType
186
+ }
187
+
188
+ /**
189
+ * Find a writable completion directory, generate the shell script, and write it.
190
+ *
191
+ * For zsh, scans `$fpath` directories plus well-known fallbacks.
192
+ * For bash, scans XDG and legacy user completion dirs.
193
+ *
194
+ * Throws if no writable directory is found.
195
+ *
196
+ * @param cliName - The CLI binary name
197
+ * @param cliPath - Full path to the CLI binary
198
+ * @param shell - Target shell. Auto-detected from $SHELL if omitted.
199
+ */
200
+ export async function installCompletions(
201
+ cliName: string,
202
+ cliPath: string,
203
+ shell?: ShellType,
204
+ ): Promise<InstallResult> {
205
+ const { existsSync, mkdirSync, writeFileSync, accessSync, constants } = await import('node:fs')
206
+ const { execSync } = await import('node:child_process')
207
+ const { join } = await import('node:path')
208
+
209
+ const targetShell = shell ?? detectShell()
210
+ if (!targetShell) {
211
+ throw new Error(
212
+ 'Could not detect shell. Set the SHELL environment variable or pass --shell explicitly.',
213
+ )
214
+ }
215
+
216
+ const script = generateCompletionScript(targetShell, cliName, cliPath)
217
+
218
+ // Build candidate directories
219
+ let candidates: string[]
220
+ let filename: string
221
+
222
+ if (targetShell === 'zsh') {
223
+ filename = `_${cliName}`
224
+
225
+ // Get live $fpath from zsh if available
226
+ let fpathDirs: string[] = []
227
+ try {
228
+ const fpathOutput = execSync('zsh -c "echo $fpath"', {
229
+ encoding: 'utf-8',
230
+ timeout: 5000,
231
+ }).trim()
232
+ fpathDirs = fpathOutput.split(/\s+/).filter(Boolean)
233
+ } catch {
234
+ // zsh not available, fall through to well-known paths
235
+ }
236
+
237
+ // Deduplicate: fpath dirs first (in order), then well-known fallbacks
238
+ const seen = new Set<string>()
239
+ candidates = []
240
+ const userZshDir = `${process.env.HOME}/.zsh/completions`
241
+ for (const dir of [...fpathDirs, ...ZSH_FPATH_CANDIDATES]) {
242
+ if (seen.has(dir)) continue
243
+ seen.add(dir)
244
+ // Auto-create the user-level ~/.zsh/completions dir if it's a candidate
245
+ if (!existsSync(dir) && dir === userZshDir) {
246
+ try {
247
+ mkdirSync(dir, { recursive: true })
248
+ } catch {
249
+ continue
250
+ }
251
+ }
252
+ if (existsSync(dir)) {
253
+ candidates.push(dir)
254
+ }
255
+ }
256
+ } else {
257
+ filename = cliName
258
+ candidates = BASH_COMPLETION_CANDIDATES.filter((dir) => {
259
+ // For bash dirs, check parent exists or try creating
260
+ try {
261
+ if (!existsSync(dir)) {
262
+ mkdirSync(dir, { recursive: true })
263
+ }
264
+ return true
265
+ } catch {
266
+ return false
267
+ }
268
+ })
269
+ }
270
+
271
+ // Find first writable directory
272
+ for (const dir of candidates) {
273
+ try {
274
+ accessSync(dir, constants.W_OK)
275
+ const filePath = join(dir, filename)
276
+ writeFileSync(filePath, script, 'utf-8')
277
+ return { path: filePath, shell: targetShell }
278
+ } catch {
279
+ // Not writable, try next
280
+ continue
281
+ }
282
+ }
283
+
284
+ // No writable directory found
285
+ const triedPaths = candidates.length > 0
286
+ ? candidates.map((d) => ` - ${d}`).join('\n')
287
+ : ' (none found)'
288
+
289
+ const hint = targetShell === 'zsh'
290
+ ? 'Create ~/.zsh/completions and add `fpath=(~/.zsh/completions $fpath)` to your .zshrc, then run this command again.'
291
+ : 'Create ~/.local/share/bash-completion/completions and run this command again.'
292
+
293
+ throw new Error(
294
+ `No writable ${targetShell} completion directory found.\n\nTried:\n${triedPaths}\n\n${hint}`,
295
+ )
296
+ }
297
+
298
+ /**
299
+ * Remove an installed completion script.
300
+ *
301
+ * Searches the same candidate directories as `installCompletions` and removes
302
+ * any matching completion files found.
303
+ *
304
+ * @returns The paths that were removed, or empty array if none found.
305
+ */
306
+ export async function uninstallCompletions(
307
+ cliName: string,
308
+ shell?: ShellType,
309
+ ): Promise<string[]> {
310
+ const { existsSync, unlinkSync } = await import('node:fs')
311
+ const { execSync } = await import('node:child_process')
312
+ const { join } = await import('node:path')
313
+
314
+ const targetShell = shell ?? detectShell()
315
+ if (!targetShell) {
316
+ throw new Error(
317
+ 'Could not detect shell. Set the SHELL environment variable or pass --shell explicitly.',
318
+ )
319
+ }
320
+
321
+ let candidates: string[]
322
+ let filename: string
323
+
324
+ if (targetShell === 'zsh') {
325
+ filename = `_${cliName}`
326
+ let fpathDirs: string[] = []
327
+ try {
328
+ const fpathOutput = execSync('zsh -c "echo $fpath"', {
329
+ encoding: 'utf-8',
330
+ timeout: 5000,
331
+ }).trim()
332
+ fpathDirs = fpathOutput.split(/\s+/).filter(Boolean)
333
+ } catch {
334
+ // zsh not available
335
+ }
336
+ const seen = new Set<string>()
337
+ candidates = []
338
+ for (const dir of [...fpathDirs, ...ZSH_FPATH_CANDIDATES]) {
339
+ if (!seen.has(dir)) {
340
+ seen.add(dir)
341
+ candidates.push(dir)
342
+ }
343
+ }
344
+ } else {
345
+ filename = cliName
346
+ candidates = [...BASH_COMPLETION_CANDIDATES]
347
+ }
348
+
349
+ const removed: string[] = []
350
+ for (const dir of candidates) {
351
+ const filePath = join(dir, filename)
352
+ if (existsSync(filePath)) {
353
+ try {
354
+ unlinkSync(filePath)
355
+ removed.push(filePath)
356
+ } catch {
357
+ // Permission denied, skip
358
+ }
359
+ }
360
+ }
361
+
362
+ return removed
363
+ }