@tikoci/quickchr 0.2.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,513 @@
1
+ /**
2
+ * Shell completions — generation, detection, install, and uninstall.
3
+ * Supports bash, zsh, and fish. No external dependencies.
4
+ */
5
+
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
7
+ import { join, dirname } from "node:path";
8
+ import { listMachineNames, getMachinesDir } from "./state.ts";
9
+
10
+ export type SupportedShell = "bash" | "zsh" | "fish";
11
+ const SUPPORTED_SHELLS: SupportedShell[] = ["bash", "zsh", "fish"];
12
+
13
+ // ─── Shell detection ────────────────────────────────────────────────────────
14
+
15
+ export interface ShellInfo {
16
+ /** Value of $SHELL env var or parent-process name. */
17
+ shell: string;
18
+ /** Version string from `<shell> --version`, if available. */
19
+ version: string | undefined;
20
+ /** True if shell is one quickchr can install completions for. */
21
+ supported: boolean;
22
+ }
23
+
24
+ /**
25
+ * Detect the current interactive shell.
26
+ *
27
+ * $SHELL reflects the *login* shell, not the currently running one — e.g. it stays
28
+ * "/bin/zsh" even inside a fish session. Shell-specific env vars exported by the
29
+ * running shell are more reliable: fish exports FISH_VERSION, zsh exports ZSH_VERSION,
30
+ * bash exports BASH_VERSION. We check those first, then fall back to $SHELL.
31
+ */
32
+ export function detectCurrentShell(): ShellInfo {
33
+ // Shell-specific env vars take priority over $SHELL (login shell).
34
+ // These are exported by the *currently running* interactive shell.
35
+ let detectedBinary: string | undefined;
36
+ let version: string | undefined;
37
+
38
+ if (process.env.FISH_VERSION) {
39
+ detectedBinary = "fish";
40
+ version = process.env.FISH_VERSION;
41
+ } else if (process.env.ZSH_VERSION) {
42
+ detectedBinary = "zsh";
43
+ version = process.env.ZSH_VERSION;
44
+ } else if (process.env.BASH_VERSION) {
45
+ detectedBinary = "bash";
46
+ const m = process.env.BASH_VERSION.match(/(\d+\.\d+[\w.]*)/);
47
+ version = m?.[1] ?? process.env.BASH_VERSION.slice(0, 60);
48
+ }
49
+
50
+ // Resolve full path for the detected binary (or fall back to $SHELL).
51
+ let shell: string;
52
+ if (detectedBinary) {
53
+ const probe = process.platform === "win32" ? ["where.exe", detectedBinary] : ["which", detectedBinary];
54
+ const which = Bun.spawnSync(probe, { stdout: "pipe", stderr: "pipe" });
55
+ shell = which.exitCode === 0
56
+ ? new TextDecoder().decode(which.stdout).trim().split(/\r?\n/)[0] ?? detectedBinary
57
+ : detectedBinary;
58
+ } else {
59
+ const shellEnv = process.env.SHELL ?? "";
60
+ shell = shellEnv || "unknown";
61
+ // Get version from the login shell binary.
62
+ if (shellEnv) {
63
+ try {
64
+ const result = Bun.spawnSync([shellEnv, "--version"], { stdout: "pipe", stderr: "pipe" });
65
+ if (result.exitCode === 0) {
66
+ const out = new TextDecoder().decode(result.stdout).trim();
67
+ const firstLine = out.split("\n")[0] ?? "";
68
+ const match = firstLine.match(/(\d+\.\d+[\w.]*)/);
69
+ version = (match?.[1] ?? firstLine.slice(0, 60)) || undefined;
70
+ }
71
+ } catch {
72
+ // version unavailable
73
+ }
74
+ }
75
+ }
76
+
77
+ // Handle both POSIX (/) and Windows (\) separators — `where.exe bash` on
78
+ // windows-latest returns paths like "C:\Program Files\Git\bin\bash.exe".
79
+ const binary = (shell.split(/[\\/]/).at(-1) ?? shell).replace(/\.exe$/i, "");
80
+ const supported = (SUPPORTED_SHELLS as string[]).includes(binary);
81
+
82
+ return { shell, version, supported };
83
+ }
84
+
85
+ /** Parse the shell binary name from a full path or name string. */
86
+ export function shellBinary(shellInfo: ShellInfo): string {
87
+ const raw = shellInfo.shell.split(/[\\/]/).at(-1) ?? shellInfo.shell;
88
+ return raw.replace(/\.exe$/i, "");
89
+ }
90
+
91
+ // ─── Install paths ──────────────────────────────────────────────────────────
92
+
93
+ export interface CompletionInstallPath {
94
+ /** Absolute path where the completion file should be written. */
95
+ file: string;
96
+ /** If the rc file needs modification, this is the line to append. */
97
+ rcLine?: string;
98
+ /** The rc file to modify. */
99
+ rcFile?: string;
100
+ }
101
+
102
+ function homeDir(): string {
103
+ return process.env.HOME ?? process.env.USERPROFILE ?? "";
104
+ }
105
+
106
+ function brewPrefix(): string | undefined {
107
+ // Check common Homebrew prefix locations; avoid spawning brew for speed.
108
+ for (const p of ["/opt/homebrew", "/usr/local"]) {
109
+ if (existsSync(p)) return p;
110
+ }
111
+ return undefined;
112
+ }
113
+
114
+ function quickchrCompletionsDir(): string {
115
+ const dataDir = join(homeDir(), ".local", "share", "quickchr", "completions");
116
+ return dataDir;
117
+ }
118
+
119
+ /**
120
+ * Resolve the install path (and optional rc-file line) for a given shell.
121
+ * Prefers Homebrew locations when available (no rc modification needed).
122
+ */
123
+ export function completionInstallPath(shell: SupportedShell): CompletionInstallPath {
124
+ const home = homeDir();
125
+ const brew = brewPrefix();
126
+
127
+ if (shell === "bash") {
128
+ // Homebrew: files sourced automatically by bash-completion
129
+ if (brew) {
130
+ const brewDir = `${brew}/etc/bash_completion.d`;
131
+ if (existsSync(brewDir)) {
132
+ return { file: join(brewDir, "quickchr") };
133
+ }
134
+ }
135
+ // XDG user dir: bash-completion ≥2 sources these automatically
136
+ const xdgDir = join(home, ".local", "share", "bash-completion", "completions");
137
+ if (existsSync(xdgDir)) {
138
+ return { file: join(xdgDir, "quickchr") };
139
+ }
140
+ // Fallback: write to quickchr data dir and source from rc
141
+ const file = join(quickchrCompletionsDir(), "quickchr.bash");
142
+ const rcFile = process.platform === "darwin"
143
+ ? join(home, ".bash_profile")
144
+ : join(home, ".bashrc");
145
+ return {
146
+ file,
147
+ rcLine: `[ -f "${file}" ] && . "${file}"`,
148
+ rcFile,
149
+ };
150
+ }
151
+
152
+ if (shell === "zsh") {
153
+ // Homebrew: files in site-functions are in default fpath
154
+ if (brew) {
155
+ const brewDir = `${brew}/share/zsh/site-functions`;
156
+ if (existsSync(brewDir)) {
157
+ return { file: join(brewDir, "_quickchr") };
158
+ }
159
+ }
160
+ // User dir: must be added to fpath before compinit
161
+ const file = join(quickchrCompletionsDir(), "_quickchr");
162
+ const completionsDir = quickchrCompletionsDir();
163
+ const rcFile = join(home, ".zshrc");
164
+ return {
165
+ file,
166
+ rcLine: `fpath=("${completionsDir}" $fpath)`,
167
+ rcFile,
168
+ };
169
+ }
170
+
171
+ // fish: auto-sourced from this directory, no rc modification needed
172
+ const fishDir = join(home, ".config", "fish", "completions");
173
+ return { file: join(fishDir, "quickchr.fish") };
174
+ }
175
+
176
+ // ─── Status ─────────────────────────────────────────────────────────────────
177
+
178
+ export interface CompletionStatus {
179
+ shell: SupportedShell;
180
+ installed: boolean;
181
+ /** Path where the completion file was found (or expected). */
182
+ path: string;
183
+ }
184
+
185
+ /** Check whether completions are installed for a given shell. */
186
+ export function completionStatusFor(shell: SupportedShell): CompletionStatus {
187
+ const { file } = completionInstallPath(shell);
188
+ return {
189
+ shell,
190
+ installed: existsSync(file),
191
+ path: file,
192
+ };
193
+ }
194
+
195
+ /** Check completion status for all supported shells. */
196
+ export function allCompletionStatuses(): CompletionStatus[] {
197
+ return SUPPORTED_SHELLS.map(completionStatusFor);
198
+ }
199
+
200
+ // ─── Install / Uninstall ────────────────────────────────────────────────────
201
+
202
+ export interface InstallResult {
203
+ shell: SupportedShell;
204
+ file: string;
205
+ /** Line appended to rc file, if any. */
206
+ rcLine?: string;
207
+ rcFile?: string;
208
+ /** True if the file was already present (skipped rewrite). */
209
+ alreadyInstalled: boolean;
210
+ /** True when --dry-run: nothing was written. */
211
+ dryRun: boolean;
212
+ }
213
+
214
+ /** Install completions for the given shell. Returns what was done. */
215
+ export function installCompletions(
216
+ shell: SupportedShell,
217
+ opts: { dryRun?: boolean } = {},
218
+ ): InstallResult {
219
+ const { file, rcLine, rcFile } = completionInstallPath(shell);
220
+ const alreadyInstalled = existsSync(file);
221
+ const dryRun = opts.dryRun ?? false;
222
+
223
+ if (!dryRun) {
224
+ mkdirSync(dirname(file), { recursive: true });
225
+ writeFileSync(file, generateCompletionScript(shell), "utf8");
226
+
227
+ if (rcLine && rcFile) {
228
+ appendRcLine(rcFile, rcLine);
229
+ }
230
+ }
231
+
232
+ return { shell, file, rcLine, rcFile, alreadyInstalled, dryRun };
233
+ }
234
+
235
+ /** Remove completions for the given shell. Returns true if the file existed. */
236
+ export function uninstallCompletions(shell: SupportedShell): { removed: boolean; file: string; rcLine?: string; rcFile?: string } {
237
+ const { file, rcLine, rcFile } = completionInstallPath(shell);
238
+ const removed = existsSync(file);
239
+ if (removed) {
240
+ unlinkSync(file);
241
+ }
242
+ if (rcLine && rcFile && existsSync(rcFile)) {
243
+ removeRcLine(rcFile, rcLine);
244
+ }
245
+ return { removed, file, rcLine, rcFile };
246
+ }
247
+
248
+ /** Append a line to an rc file, only if it's not already present. */
249
+ function appendRcLine(rcFile: string, line: string): void {
250
+ let content = "";
251
+ if (existsSync(rcFile)) {
252
+ content = readFileSync(rcFile, "utf8");
253
+ }
254
+ if (content.includes(line)) return;
255
+ const sep = content.endsWith("\n") || content.length === 0 ? "" : "\n";
256
+ writeFileSync(rcFile, `${content}${sep}${line}\n`, "utf8");
257
+ }
258
+
259
+ /** Remove a previously-appended line from an rc file (exact match). */
260
+ function removeRcLine(rcFile: string, line: string): void {
261
+ if (!existsSync(rcFile)) return;
262
+ const content = readFileSync(rcFile, "utf8");
263
+ const filtered = content
264
+ .split("\n")
265
+ .filter((l) => l !== line)
266
+ .join("\n");
267
+ writeFileSync(rcFile, filtered, "utf8");
268
+ }
269
+
270
+ // ─── Machine name helpers (used by completion scripts) ──────────────────────
271
+
272
+ /** List all machine names for completion. */
273
+ export function listMachineNamesForCompletion(): string[] {
274
+ try {
275
+ return listMachineNames();
276
+ } catch {
277
+ return [];
278
+ }
279
+ }
280
+
281
+ /** List running machine names for completion. */
282
+ export function listRunningMachineNamesForCompletion(): string[] {
283
+ try {
284
+ const machinesDir = getMachinesDir();
285
+ if (!existsSync(machinesDir)) return [];
286
+
287
+ const names: string[] = [];
288
+ for (const entry of readdirSync(machinesDir, { withFileTypes: true })) {
289
+ if (!entry.isDirectory()) continue;
290
+ const mjPath = join(machinesDir, entry.name, "machine.json");
291
+ if (!existsSync(mjPath)) continue;
292
+ try {
293
+ const state = JSON.parse(readFileSync(mjPath, "utf8")) as { status?: string };
294
+ if (state.status === "running") names.push(entry.name);
295
+ } catch {
296
+ // skip unreadable
297
+ }
298
+ }
299
+ return names;
300
+ } catch {
301
+ return [];
302
+ }
303
+ }
304
+
305
+ // ─── Completion script generators ───────────────────────────────────────────
306
+
307
+ /** Generate a completion script for the given shell. */
308
+ export function generateCompletionScript(shell: SupportedShell): string {
309
+ switch (shell) {
310
+ case "bash": return generateBashCompletion();
311
+ case "zsh": return generateZshCompletion();
312
+ case "fish": return generateFishCompletion();
313
+ }
314
+ }
315
+
316
+ function generateBashCompletion(): string {
317
+ // Use $ as a variable to embed ${...} patterns without triggering JS template interpolation.
318
+ const D = "$";
319
+ return `# quickchr bash completion
320
+ # Generated by quickchr. Source this file or let bash-completion do it automatically.
321
+
322
+ _quickchr() {
323
+ local cur prev words cword
324
+ _init_completion 2>/dev/null || {
325
+ cur="${D}{COMP_WORDS[COMP_CWORD]}"
326
+ prev="${D}{COMP_WORDS[COMP_CWORD-1]}"
327
+ words=("${D}{COMP_WORDS[@]}")
328
+ cword=${D}COMP_CWORD
329
+ }
330
+
331
+ local commands="add start stop list ls status remove rm clean console exec qga license disk snapshot snap networks net doctor setup completions version help"
332
+
333
+ if [[ ${D}cword -eq 1 ]]; then
334
+ COMPREPLY=( $(compgen -W "${D}commands" -- "${D}cur") )
335
+ return
336
+ fi
337
+
338
+ local cmd="${D}{words[1]}"
339
+ case "${D}cmd" in
340
+ start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status)
341
+ local machines
342
+ machines=$(quickchr completions --machines 2>/dev/null)
343
+ COMPREPLY=( $(compgen -W "${D}machines" -- "${D}cur") )
344
+ ;;
345
+ stop)
346
+ local machines
347
+ machines=$(quickchr completions --running 2>/dev/null)
348
+ [[ -z "${D}machines" ]] && machines=$(quickchr completions --machines 2>/dev/null)
349
+ COMPREPLY=( $(compgen -W "${D}machines" -- "${D}cur") )
350
+ ;;
351
+ completions)
352
+ local opts="--install --uninstall --status --dry-run --shell"
353
+ COMPREPLY=( $(compgen -W "${D}opts" -- "${D}cur") )
354
+ ;;
355
+ help)
356
+ COMPREPLY=( $(compgen -W "${D}commands" -- "${D}cur") )
357
+ ;;
358
+ esac
359
+ }
360
+
361
+ complete -F _quickchr quickchr
362
+ `;
363
+ }
364
+
365
+ function generateZshCompletion(): string {
366
+ // Use $ as a variable to embed ${...} patterns without triggering JS template interpolation.
367
+ const D = "$";
368
+ return `#compdef quickchr
369
+ # quickchr zsh completion
370
+ # Generated by quickchr.
371
+
372
+ _quickchr() {
373
+ local state
374
+
375
+ _arguments \\
376
+ '(- *)'{-h,--help}'[Show help]' \\
377
+ '(- *)'{-v,--version}'[Show version]' \\
378
+ '1: :->command' \\
379
+ '*: :->args'
380
+
381
+ case ${D}state in
382
+ command)
383
+ local commands=(
384
+ 'add:Create a new CHR machine (without starting)'
385
+ 'start:Start or restart a CHR instance'
386
+ 'stop:Stop instance(s)'
387
+ 'list:List all instances'
388
+ 'ls:List all instances (alias)'
389
+ 'status:Detailed status'
390
+ 'remove:Remove instance(s) and disk'
391
+ 'rm:Remove instance(s) (alias)'
392
+ 'clean:Reset instance disk to fresh image'
393
+ 'console:Attach to serial console'
394
+ 'exec:Run a RouterOS CLI command'
395
+ 'qga:Guest agent command'
396
+ 'license:Apply/renew CHR trial license'
397
+ 'disk:Show disk details'
398
+ 'snapshot:Manage snapshots'
399
+ 'snap:Manage snapshots (alias)'
400
+ 'networks:Network discovery and socket management'
401
+ 'net:Network discovery (alias)'
402
+ 'doctor:Check prerequisites'
403
+ 'setup:Interactive setup wizard'
404
+ 'completions:Manage shell completions'
405
+ 'version:Show version info'
406
+ 'help:Show help'
407
+ )
408
+ _describe 'command' commands
409
+ ;;
410
+ args)
411
+ local cmd="${D}{words[2]}"
412
+ case ${D}cmd in
413
+ start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status)
414
+ local machines
415
+ machines=(${D}{(f)"${D}(quickchr completions --machines 2>/dev/null)"})
416
+ _describe 'machine' machines
417
+ ;;
418
+ stop)
419
+ local machines
420
+ machines=(${D}{(f)"${D}(quickchr completions --running 2>/dev/null)"})
421
+ (( ${D}{#machines} == 0 )) && machines=(${D}{(f)"${D}(quickchr completions --machines 2>/dev/null)"})
422
+ _describe 'machine' machines
423
+ ;;
424
+ completions)
425
+ local opts=(
426
+ '--install:Install completions for the current shell'
427
+ '--uninstall:Remove completions'
428
+ '--status:Show install status for all shells'
429
+ '--dry-run:Show what would happen without writing'
430
+ '--shell:Specify shell (bash, zsh, fish)'
431
+ )
432
+ _describe 'option' opts
433
+ ;;
434
+ help)
435
+ local commands=(add start stop list status remove clean console exec qga license disk snapshot networks doctor setup completions version)
436
+ _describe 'command' commands
437
+ ;;
438
+ esac
439
+ ;;
440
+ esac
441
+ }
442
+
443
+ _quickchr "${D}@"
444
+ `;
445
+ }
446
+
447
+ function generateFishCompletion(): string {
448
+ return `# quickchr fish completion
449
+ # Generated by quickchr. Fish auto-loads files from ~/.config/fish/completions/.
450
+
451
+ set -l quickchr_commands add start stop list ls status remove rm clean console exec qga license disk snapshot snap networks net doctor setup completions version help
452
+
453
+ # Disable file completions globally for quickchr
454
+ complete -c quickchr -f
455
+
456
+ # Subcommands
457
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
458
+ -a "add" -d "Create a new CHR machine"
459
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
460
+ -a "start" -d "Start or restart a CHR instance"
461
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
462
+ -a "stop" -d "Stop instance(s)"
463
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
464
+ -a "list ls" -d "List all instances"
465
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
466
+ -a "status" -d "Detailed status"
467
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
468
+ -a "remove rm" -d "Remove instance(s) and disk"
469
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
470
+ -a "clean" -d "Reset instance disk to fresh image"
471
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
472
+ -a "console" -d "Attach to serial console"
473
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
474
+ -a "exec" -d "Run a RouterOS CLI command"
475
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
476
+ -a "qga" -d "Guest agent command"
477
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
478
+ -a "license" -d "Apply/renew CHR trial license"
479
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
480
+ -a "disk" -d "Show disk details"
481
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
482
+ -a "snapshot snap" -d "Manage snapshots"
483
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
484
+ -a "networks net" -d "Network discovery and socket management"
485
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
486
+ -a "doctor" -d "Check prerequisites"
487
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
488
+ -a "setup" -d "Interactive setup wizard"
489
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
490
+ -a "completions" -d "Manage shell completions"
491
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
492
+ -a "version" -d "Show version info"
493
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
494
+ -a "help" -d "Show help"
495
+
496
+ # Machine name completions for commands that take a running machine
497
+ complete -c quickchr -n "__fish_seen_subcommand_from stop" \\
498
+ -a "(quickchr completions --running 2>/dev/null; or quickchr completions --machines 2>/dev/null)" \\
499
+ -d "machine"
500
+
501
+ # Machine name completions for commands that take any machine
502
+ complete -c quickchr -n "__fish_seen_subcommand_from start remove rm clean console exec qga license disk snapshot snap status" \\
503
+ -a "(quickchr completions --machines 2>/dev/null)" \\
504
+ -d "machine"
505
+
506
+ # completions subcommand options
507
+ complete -c quickchr -n "__fish_seen_subcommand_from completions" -l install -d "Install completions"
508
+ complete -c quickchr -n "__fish_seen_subcommand_from completions" -l uninstall -d "Remove completions"
509
+ complete -c quickchr -n "__fish_seen_subcommand_from completions" -l status -d "Show install status"
510
+ complete -c quickchr -n "__fish_seen_subcommand_from completions" -l dry-run -d "Show what would happen"
511
+ complete -c quickchr -n "__fish_seen_subcommand_from completions" -l shell -d "Specify shell" -a "bash zsh fish"
512
+ `;
513
+ }