diff --git a/README.md b/README.md index 97a28a1..2736f1f 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,74 @@ my-cli complete zsh > ~/.my-cli-completion.zsh echo 'source ~/.my-cli-completion.zsh' >> ~/.zshrc ``` +## Installing Completions for Users + +Asking users to copy-paste `source <(my-cli complete zsh)` into their shellrc is a friction point. tab ships an installer that detects the user's shell + environment and writes the completion file (or appends to their PowerShell profile) in the right place — no shellrc edits required for most setups. + +Use it from a dedicated subcommand, an `init` flow, or a `postinstall` hook: + +```typescript +import { installShellCompletions } from '@bomb.sh/tab/install'; + +// in your `my-cli completions install` handler: +await installShellCompletions({ + name: 'my-cli', // optional — auto-detected from argv/package.json + executable: 'my-cli', // optional — defaults to `name` + shell: 'auto', // 'zsh' | 'bash' | 'fish' | 'powershell' | 'auto' +}); +``` + +The installer is **opt-in, idempotent, and never touches `.zshrc` / `.bashrc` on its own.** When it can't complete the install cleanly (e.g. the CLI isn't on PATH, the user's zsh has no `compinit`, macOS bash without `bash-completion`), it returns a structured `needs-user-action` or `blocked` result with concrete remediation steps. + +```typescript +const result = await installShellCompletions({ name: 'my-cli', dryRun: true }); + +result.status; // 'installed' | 'already-installed' | 'updated' + // | 'needs-user-action' | 'blocked' | 'failed' +result.actions; // files we wrote / would write (with `performed` flag) +result.userInstructions;// numbered next-steps the user must take, if any +result.warnings; // e.g. detected a conflicting Homebrew completion +result.detected; // PATH reachability, install method, shell env probe +``` + +**Options** + +| Option | Default | Description | +| --- | --- | --- | +| `name` | auto-detected | Command name (drives filenames: `_my-cli`, `my-cli.fish`, …) | +| `executable` | `name` | How to invoke the CLI from the generated completion script | +| `shell` | `'auto'` | Target shell, or `'auto'` to detect from the current process | +| `dryRun` | `false` | Compute the plan without writing anything | +| `force` | `false` | Overwrite an existing completion file we did not manage | +| `print` | `'on-error'` | Print a summary to stderr — `true`, `false`, or `'on-error'` | +| `verbose` | `false` | Log detection steps to stderr | + +**What the installer covers per shell** + +| Shell | Target | When the user has to do something | +| --- | --- | --- | +| fish | `~/.config/fish/completions/.fish` | never | +| zsh | first writable `$fpath` dir, else Homebrew `site-functions`, else `~/.zsh/completions` | only if `compinit` is missing or the target dir isn't in `$fpath` (clear instructions are returned) | +| bash | `$XDG_DATA_HOME/bash-completion/completions/` | only if `bash-completion` isn't installed (macOS default bash) — install hint is returned | +| powershell | sentinel-wrapped block in `$PROFILE.CurrentUserAllHosts` | only if execution policy is `Restricted` | + +The installer always returns its result — you can render your own UI, print the structured plan, or chain it into a larger `init` flow. + +### Uninstalling + +A matching `uninstallShellCompletions` removes whatever the installer wrote. It only touches files that carry our `managed-by=tab` marker (or sentinel-wrapped blocks in PowerShell profiles), so it won't clobber a user's hand-written or Homebrew-installed completion. + +```typescript +import { uninstallShellCompletions } from '@bomb.sh/tab/install'; + +await uninstallShellCompletions({ + name: 'my-cli', + shell: 'auto', +}); +``` + +For zsh, the uninstaller walks every dir we might have written to (current `$fpath`, Homebrew `site-functions`, `~/.zsh/completions`) so it cleans up even if the user's environment has changed since install. `dryRun`, `force`, `print`, and `verbose` work the same way as on the installer. + ## Package Manager Completions As mentioned earlier, tab provides completions for package managers as well: diff --git a/package.json b/package.json index ce91b59..42d77fd 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "./cac": "./dist/cac.mjs", "./citty": "./dist/citty.mjs", "./commander": "./dist/commander.mjs", + "./install": "./dist/install.mjs", "./package.json": "./package.json" }, "packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b" diff --git a/src/install/bash.ts b/src/install/bash.ts new file mode 100644 index 0000000..7749910 --- /dev/null +++ b/src/install/bash.ts @@ -0,0 +1,117 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir, platform } from 'node:os'; +import { dirname, join } from 'node:path'; +import * as bash from '../bash'; +import type { ShellInstaller, ShellUninstaller } from './context'; +import { detectBashCompletion } from './detect'; +import { inspectFile, makeFileMarker } from './markers'; + +function bashTarget(name: string): string { + const xdg = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'); + return join(xdg, 'bash-completion', 'completions', name); +} + +function bashCompletionInstallHint(): string { + if (platform() === 'darwin') { + return [ + 'Install bash-completion (macOS default bash has none):', + ' brew install bash-completion@2', + 'Then add to your ~/.bash_profile:', + ' [[ -r "$(brew --prefix)/etc/profile.d/bash_completion.sh" ]] && . "$(brew --prefix)/etc/profile.d/bash_completion.sh"', + ].join('\n '); + } + return [ + 'Install bash-completion via your package manager:', + ' apt install bash-completion # Debian/Ubuntu', + ' dnf install bash-completion # Fedora', + ' pacman -S bash-completion # Arch', + 'Then start a new shell.', + ].join('\n '); +} + +export const installBash: ShellInstaller = (ctx) => { + const result = ctx.startResult('bash'); + const target = bashTarget(ctx.name); + + const bc = detectBashCompletion(); + ctx.log(`bash-completion present: ${bc.present}`); + + result.detected.shellEnv = { + bashCompletionPresent: bc.present, + bashCompletionLoader: bc.loaderPath, + targetDir: dirname(target), + }; + + const existing = inspectFile(target); + if (existing.managedByTab && existing.version === ctx.version && bc.present) { + result.status = 'already-installed'; + result.actions.push({ type: 'write-file', path: target, performed: false }); + return result; + } + if (!existing.managedByTab && ctx.fileExists(target) && !ctx.force) { + result.status = 'blocked'; + result.explanation = `An unmanaged completion file already exists at ${target}.`; + result.userInstructions.push( + `Remove ${target} and re-run, or pass { force: true } to overwrite.` + ); + return result; + } + + const marker = makeFileMarker(ctx.name, ctx.version, '#'); + const script = `${marker}\n${bash.generate(ctx.name, ctx.executable)}`; + + if (!ctx.dryRun) { + try { + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, script); + } catch (err) { + result.status = 'blocked'; + result.explanation = `Failed to write completion file: ${(err as Error).message}`; + return result; + } + } + result.actions.push({ + type: 'write-file', + path: target, + performed: !ctx.dryRun, + }); + + if (!bc.present) { + result.status = 'needs-user-action'; + result.userInstructions.push(bashCompletionInstallHint()); + return result; + } + + result.status = existing.managedByTab ? 'updated' : 'installed'; + result.userInstructions.push( + 'Restart your shell or run `exec bash` to load the new completions.' + ); + return result; +}; + +export const uninstallBash: ShellUninstaller = (ctx) => { + const result = ctx.startResult('bash'); + const target = bashTarget(ctx.name); + + if (!ctx.fileExists(target)) return result; + + const info = inspectFile(target); + if (!info.managedByTab && !ctx.force) { + result.status = 'blocked'; + result.explanation = `${target} exists but was not written by tab; refusing to remove.`; + return result; + } + + if (!ctx.dryRun) { + try { + rmSync(target); + } catch (err) { + result.status = 'failed'; + result.explanation = (err as Error).message; + return result; + } + } + result.actions.push({ type: 'remove-file', path: target, performed: !ctx.dryRun }); + result.status = 'uninstalled'; + return result; +}; diff --git a/src/install/context.ts b/src/install/context.ts new file mode 100644 index 0000000..cbc68a1 --- /dev/null +++ b/src/install/context.ts @@ -0,0 +1,94 @@ +import { existsSync } from 'node:fs'; +import type { + InstallMethod, + InstallResult, + SupportedShell, + UninstallResult, +} from './types'; + +export type InstallContext = { + name: string; + executable: string; + version: string; + dryRun: boolean; + force: boolean; + verbose: boolean; + detected: { + pathReachable: boolean; + resolvedPath?: string; + installMethod: InstallMethod; + }; + startResult: (shell: SupportedShell) => InstallResult; + fileExists: (path: string) => boolean; + log: (msg: string) => void; +}; + +export type ShellInstaller = (ctx: InstallContext) => InstallResult; + +export type UninstallContext = { + name: string; + dryRun: boolean; + force: boolean; + verbose: boolean; + startResult: (shell: SupportedShell) => UninstallResult; + fileExists: (path: string) => boolean; + log: (msg: string) => void; +}; + +export type ShellUninstaller = (ctx: UninstallContext) => UninstallResult; + +export function makeUninstallContext(input: { + name: string; + dryRun: boolean; + force: boolean; + verbose: boolean; +}): UninstallContext { + return { + ...input, + startResult: (shell) => ({ + shell, + status: 'not-installed', + actions: [], + warnings: [], + }), + fileExists: (p) => existsSync(p), + log: (msg) => { + if (input.verbose) { + console.error(`[tab/uninstall] ${msg}`); + } + }, + }; +} + +export function makeContext(input: { + name: string; + executable: string; + version: string; + dryRun: boolean; + force: boolean; + verbose: boolean; + detected: InstallContext['detected']; +}): InstallContext { + return { + ...input, + startResult: (shell) => ({ + shell, + status: 'installed', + detected: { + pathReachable: input.detected.pathReachable, + resolvedPath: input.detected.resolvedPath, + installMethod: input.detected.installMethod, + shellEnv: {}, + }, + actions: [], + userInstructions: [], + warnings: [], + }), + fileExists: (p) => existsSync(p), + log: (msg) => { + if (input.verbose) { + console.error(`[tab/install] ${msg}`); + } + }, + }; +} diff --git a/src/install/detect.ts b/src/install/detect.ts new file mode 100644 index 0000000..976c44d --- /dev/null +++ b/src/install/detect.ts @@ -0,0 +1,301 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, accessSync, constants } from 'node:fs'; +import { homedir, platform } from 'node:os'; +import { basename, dirname, join, resolve, sep } from 'node:path'; +import type { InstallMethod, SupportedShell } from './types'; + +const IS_WINDOWS = platform() === 'win32'; + +function safeExec(cmd: string, args: string[]): string | undefined { + try { + return execFileSync(cmd, args, { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf-8', + timeout: 2000, + }).trim(); + } catch { + return undefined; + } +} + +/** + * Detect the active shell. Tries, in order: + * 1. Windows: PSModulePath → powershell + * 2. parent process inspection (ppid → comm) + * 3. $SHELL env var (this is the *login* shell, not necessarily the running one) + */ +export function detectShell(): SupportedShell | undefined { + if (IS_WINDOWS && process.env.PSModulePath) { + return 'powershell'; + } + + const parent = detectParentShell(); + if (parent) return parent; + + const shellEnv = process.env.SHELL; + if (shellEnv) { + const name = basename(shellEnv).toLowerCase(); + if (name.includes('zsh')) return 'zsh'; + if (name.includes('bash')) return 'bash'; + if (name.includes('fish')) return 'fish'; + if (name === 'pwsh' || name === 'powershell') return 'powershell'; + } + + return undefined; +} + +function detectParentShell(): SupportedShell | undefined { + const ppid = process.ppid; + if (!ppid) return undefined; + + let comm: string | undefined; + if (platform() === 'linux') { + try { + comm = readFileSync(`/proc/${ppid}/comm`, 'utf-8').trim(); + } catch { + // ignore + } + } else { + comm = safeExec('ps', ['-p', String(ppid), '-o', 'comm=']); + if (comm) comm = basename(comm); + } + + if (!comm) return undefined; + const name = comm.toLowerCase(); + if (name.includes('zsh')) return 'zsh'; + if (name.includes('bash')) return 'bash'; + if (name.includes('fish')) return 'fish'; + if (name === 'pwsh' || name === 'powershell') return 'powershell'; + return undefined; +} + +/** + * Try to determine the CLI's command name from `process.argv[1]` or the nearest + * package.json. Callers may always override with `options.name`. + */ +export function detectName(): string | undefined { + const argv1 = process.argv[1]; + if (argv1) { + const base = basename(argv1).replace(/\.(c?js|mjs|ts)$/, ''); + if (base && base !== 'index') return base; + } + + let dir = argv1 ? dirname(argv1) : process.cwd(); + for (let i = 0; i < 8; i++) { + const pkgPath = join(dir, 'package.json'); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + if (typeof pkg.bin === 'string') return pkg.name; + if (pkg.bin && typeof pkg.bin === 'object') { + const names = Object.keys(pkg.bin); + if (names.length > 0) return names[0]; + } + if (typeof pkg.name === 'string') { + const scoped = pkg.name.split('/').pop(); + if (scoped) return scoped; + } + } catch { + // ignore + } + break; + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return undefined; +} + +/** + * Build a PATH list with npm's project-local prepends removed, so we can ask + * "is this binary reachable from a fresh shell?" When npm/pnpm/yarn runs a + * lifecycle script or `exec` command, they prepend `/node_modules/.bin` + * (and ancestors) onto PATH. Those entries make `which mycli` succeed even + * though the binary won't be reachable from a regular shell. + */ +function stripNodeModulesFromPath(pathStr: string, cwd: string): string { + const delim = IS_WINDOWS ? ';' : ':'; + const parts = pathStr.split(delim); + let cur = cwd; + const ancestorBins = new Set(); + for (let i = 0; i < 32; i++) { + ancestorBins.add(join(cur, 'node_modules', '.bin')); + const parent = dirname(cur); + if (parent === cur) break; + cur = parent; + } + return parts + .filter((p) => { + const norm = resolve(p); + if (ancestorBins.has(norm)) return false; + // also drop any path component containing /node_modules/.bin + if (norm.includes(`${sep}node_modules${sep}.bin`)) return false; + return true; + }) + .join(delim); +} + +export type PathProbe = { + reachable: boolean; + resolvedPath?: string; + installMethod: InstallMethod; +}; + +/** + * Look up `name` on PATH with `node_modules/.bin` segments removed. If found, + * also classify the install method (brew / npm-global / standalone). + */ +export function probePath(name: string): PathProbe { + const cwd = process.cwd(); + const cleanedPath = stripNodeModulesFromPath(process.env.PATH || '', cwd); + + const delim = IS_WINDOWS ? ';' : ':'; + const exts = IS_WINDOWS + ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';') + : ['']; + + const candidates: string[] = []; + for (const dir of cleanedPath.split(delim)) { + if (!dir) continue; + for (const ext of exts) { + candidates.push(join(dir, name + ext)); + } + } + + let resolved: string | undefined; + for (const candidate of candidates) { + try { + accessSync(candidate, constants.X_OK); + resolved = candidate; + break; + } catch { + // not executable / missing + } + } + + if (!resolved) { + return { reachable: false, installMethod: 'unknown' }; + } + + return { + reachable: true, + resolvedPath: resolved, + installMethod: classifyInstallMethod(resolved), + }; +} + +function classifyInstallMethod(resolvedPath: string): InstallMethod { + const norm = resolvedPath.replace(/\\/g, '/'); + if (norm.includes('/node_modules/')) return 'node-modules'; + + // Homebrew: /usr/local/Cellar, /opt/homebrew, /home/linuxbrew/... + if ( + norm.includes('/Cellar/') || + norm.startsWith('/opt/homebrew/') || + norm.startsWith('/home/linuxbrew/') + ) { + return 'brew'; + } + + // npm global: try to ask npm + const npmPrefix = safeExec('npm', ['prefix', '-g']); + if (npmPrefix && norm.startsWith(npmPrefix.replace(/\\/g, '/'))) { + return 'npm-global'; + } + + return 'standalone'; +} + +/** Get $(brew --prefix) if available, with caching. */ +let brewPrefixCache: string | null | undefined; +export function brewPrefix(): string | undefined { + if (brewPrefixCache !== undefined) return brewPrefixCache ?? undefined; + const out = safeExec('brew', ['--prefix']); + brewPrefixCache = out || null; + return out; +} + +/** + * Run `zsh -ic 'print -l -- $fpath'` to enumerate the user's actual fpath. + * Filters out entries that don't exist. + */ +export function detectZshFpath(): string[] { + const out = safeExec('zsh', ['-ic', 'print -l -- $fpath']); + if (!out) return []; + return out + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0 && existsSync(l)); +} + +/** Returns true if `~/.zshrc` references `compinit`. */ +export function zshrcHasCompinit(): boolean { + const rc = join(homedir(), '.zshrc'); + if (!existsSync(rc)) return false; + try { + const content = readFileSync(rc, 'utf-8'); + return /^[^#\n]*\bcompinit\b/m.test(content); + } catch { + return false; + } +} + +/** + * Detect whether bash-completion is installed and sourced. We probe in this order: + * 1. Spawn `bash -ic 'echo "${BASH_COMPLETION_VERSINFO[@]}"'` — only set if sourced. + * 2. Look for the loader file at known system/Homebrew paths. + */ +export function detectBashCompletion(): { present: boolean; loaderPath?: string } { + const versinfo = safeExec('bash', [ + '-ic', + 'echo "${BASH_COMPLETION_VERSINFO[@]}"', + ]); + if (versinfo && versinfo.length > 0) { + return { present: true }; + } + + const candidates = [ + '/usr/share/bash-completion/bash_completion', + '/etc/bash_completion', + '/etc/profile.d/bash_completion.sh', + ]; + const prefix = brewPrefix(); + if (prefix) { + candidates.push(`${prefix}/etc/profile.d/bash_completion.sh`); + candidates.push(`${prefix}/share/bash-completion/bash_completion`); + } + + for (const c of candidates) { + if (existsSync(c)) return { present: true, loaderPath: c }; + } + return { present: false }; +} + +/** Run `pwsh` to read `$PROFILE.CurrentUserAllHosts` (works on macOS/Linux/Windows). */ +export function powershellProfilePath(): string | undefined { + const candidates = ['pwsh', 'powershell']; + for (const exe of candidates) { + const out = safeExec(exe, [ + '-NoProfile', + '-Command', + 'Write-Output $PROFILE.CurrentUserAllHosts', + ]); + if (out) return out; + } + return undefined; +} + +/** Reads the current-user execution policy. Restricted blocks profile loading. */ +export function powershellExecutionPolicy(): string | undefined { + const candidates = ['pwsh', 'powershell']; + for (const exe of candidates) { + const out = safeExec(exe, [ + '-NoProfile', + '-Command', + 'Get-ExecutionPolicy -Scope CurrentUser', + ]); + if (out) return out; + } + return undefined; +} diff --git a/src/install/fish.ts b/src/install/fish.ts new file mode 100644 index 0000000..37d6a03 --- /dev/null +++ b/src/install/fish.ts @@ -0,0 +1,71 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import * as fish from '../fish'; +import type { ShellInstaller, ShellUninstaller } from './context'; +import { inspectFile, makeFileMarker } from './markers'; + +function fishTarget(name: string): string { + const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config'); + return join(xdg, 'fish', 'completions', `${name}.fish`); +} + +export const installFish: ShellInstaller = (ctx) => { + const result = ctx.startResult('fish'); + const target = fishTarget(ctx.name); + + const existing = inspectFile(target); + if (existing.managedByTab && existing.version === ctx.version) { + result.status = 'already-installed'; + result.actions.push({ type: 'write-file', path: target, performed: false }); + return result; + } + if (!existing.managedByTab && ctx.fileExists(target) && !ctx.force) { + result.status = 'blocked'; + result.explanation = `An unmanaged completion file already exists at ${target}.`; + result.userInstructions.push( + `Remove ${target} and re-run, or pass { force: true } to overwrite.` + ); + return result; + } + + const marker = makeFileMarker(ctx.name, ctx.version, '#'); + const script = `${marker}\n${fish.generate(ctx.name, ctx.executable)}`; + + if (!ctx.dryRun) { + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, script); + } + result.actions.push({ type: 'write-file', path: target, performed: !ctx.dryRun }); + result.status = existing.managedByTab ? 'updated' : 'installed'; + return result; +}; + +export const uninstallFish: ShellUninstaller = (ctx) => { + const result = ctx.startResult('fish'); + const target = fishTarget(ctx.name); + + if (!ctx.fileExists(target)) { + return result; + } + + const info = inspectFile(target); + if (!info.managedByTab && !ctx.force) { + result.status = 'blocked'; + result.explanation = `${target} exists but was not written by tab; refusing to remove.`; + return result; + } + + if (!ctx.dryRun) { + try { + rmSync(target); + } catch (err) { + result.status = 'failed'; + result.explanation = (err as Error).message; + return result; + } + } + result.actions.push({ type: 'remove-file', path: target, performed: !ctx.dryRun }); + result.status = 'uninstalled'; + return result; +}; diff --git a/src/install/index.ts b/src/install/index.ts new file mode 100644 index 0000000..5e9ace4 --- /dev/null +++ b/src/install/index.ts @@ -0,0 +1,207 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { installBash, uninstallBash } from './bash'; +import { + makeContext, + makeUninstallContext, + type ShellInstaller, + type ShellUninstaller, +} from './context'; +import { detectName, detectShell, probePath } from './detect'; +import { installFish, uninstallFish } from './fish'; +import { installPowershell, uninstallPowershell } from './powershell'; +import { formatResult, formatUninstallResult, shouldPrint, shouldPrintUninstall } from './print'; +import type { + InstallOptions, + InstallResult, + SupportedShell, + UninstallOptions, + UninstallResult, +} from './types'; +import { installZsh, uninstallZsh } from './zsh'; + +export type { + InstallOptions, + InstallResult, + UninstallOptions, + UninstallResult, +} from './types'; +export { formatResult, formatUninstallResult } from './print'; + +const INSTALLERS: Record = { + zsh: installZsh, + bash: installBash, + fish: installFish, + powershell: installPowershell, +}; + +const UNINSTALLERS: Record = { + zsh: uninstallZsh, + bash: uninstallBash, + fish: uninstallFish, + powershell: uninstallPowershell, +}; + +let cachedVersion: string | undefined; +function getTabVersion(): string { + if (cachedVersion) return cachedVersion; + try { + const here = dirname(fileURLToPath(import.meta.url)); + // walk up looking for our package.json + let dir = here; + for (let i = 0; i < 6; i++) { + try { + const pkg = JSON.parse( + readFileSync(join(dir, 'package.json'), 'utf-8') + ); + if (pkg.name === '@bomb.sh/tab' && typeof pkg.version === 'string') { + cachedVersion = pkg.version as string; + return cachedVersion; + } + } catch { + // keep walking + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + } catch { + // ignore + } + cachedVersion = '0.0.0'; + return cachedVersion; +} + +export async function installShellCompletions( + options: InstallOptions = {} +): Promise { + const name = options.name ?? detectName(); + if (!name) { + throw new Error( + 'installShellCompletions: could not detect CLI name; pass { name } explicitly.' + ); + } + const executable = options.executable ?? name; + const dryRun = options.dryRun ?? false; + const force = options.force ?? false; + const verbose = options.verbose ?? false; + const printSetting = options.print ?? 'on-error'; + + const shell = + options.shell && options.shell !== 'auto' ? options.shell : detectShell(); + + if (!shell) { + const result: InstallResult = { + shell: 'bash', + status: 'blocked', + detected: { + pathReachable: false, + installMethod: 'unknown', + shellEnv: {}, + }, + actions: [], + userInstructions: [ + 'Could not detect your shell. Pass `{ shell: "zsh" | "bash" | "fish" | "powershell" }` explicitly.', + ], + warnings: [], + explanation: 'Shell detection failed.', + }; + if (shouldPrint(result, printSetting)) { + console.error(formatResult(result)); + } + return result; + } + + const path = probePath(name); + + const ctx = makeContext({ + name, + executable, + version: getTabVersion(), + dryRun, + force, + verbose, + detected: { + pathReachable: path.reachable, + resolvedPath: path.resolvedPath, + installMethod: path.installMethod, + }, + }); + + let result: InstallResult; + + if (!path.reachable) { + result = ctx.startResult(shell); + result.status = 'blocked'; + result.explanation = `\`${name}\` is not on PATH from a fresh shell. Completions only work for globally-reachable commands.`; + result.userInstructions.push( + 'Install the CLI globally (e.g. `npm i -g`, `brew install`, or place a standalone binary on PATH), then re-run.' + ); + } else { + const installer = INSTALLERS[shell]; + try { + result = installer(ctx); + } catch (err) { + result = ctx.startResult(shell); + result.status = 'failed'; + result.explanation = (err as Error).message; + } + } + + if (shouldPrint(result, printSetting)) { + console.error(formatResult(result)); + } + + return result; +} + +export async function uninstallShellCompletions( + options: UninstallOptions = {} +): Promise { + const name = options.name ?? detectName(); + if (!name) { + throw new Error( + 'uninstallShellCompletions: could not detect CLI name; pass { name } explicitly.' + ); + } + const dryRun = options.dryRun ?? false; + const force = options.force ?? false; + const verbose = options.verbose ?? false; + const printSetting = options.print ?? 'on-error'; + + const shell = + options.shell && options.shell !== 'auto' ? options.shell : detectShell(); + + if (!shell) { + const result: UninstallResult = { + shell: 'bash', + status: 'blocked', + actions: [], + warnings: [], + explanation: + 'Could not detect your shell. Pass `{ shell: "zsh" | "bash" | "fish" | "powershell" }` explicitly.', + }; + if (shouldPrintUninstall(result, printSetting)) { + console.error(formatUninstallResult(result)); + } + return result; + } + + const ctx = makeUninstallContext({ name, dryRun, force, verbose }); + const uninstaller = UNINSTALLERS[shell]; + let result: UninstallResult; + try { + result = uninstaller(ctx); + } catch (err) { + result = ctx.startResult(shell); + result.status = 'failed'; + result.explanation = (err as Error).message; + } + + if (shouldPrintUninstall(result, printSetting)) { + console.error(formatUninstallResult(result)); + } + + return result; +} diff --git a/src/install/markers.ts b/src/install/markers.ts new file mode 100644 index 0000000..5b09b69 --- /dev/null +++ b/src/install/markers.ts @@ -0,0 +1,126 @@ +/** + * Idempotency markers. Every file/block we write starts with a marker line so + * we can safely detect on re-run whether the existing content is ours. + */ + +import { existsSync, readFileSync } from 'node:fs'; + +export type MarkerKind = 'file' | 'block'; +export type CommentSyntax = '#' | '//' | '<#'; + +export type MarkerInfo = { + managedByTab: boolean; + name?: string; + version?: string; +}; + +export function makeFileMarker( + name: string, + version: string, + comment: CommentSyntax = '#' +): string { + return `${comment} tab-completion managed-by=tab name=${name} version=${version}`; +} + +export function makeBlockStart(name: string, comment: CommentSyntax = '#'): string { + return `${comment} >>> tab:${name} >>>`; +} + +export function makeBlockEnd(name: string, comment: CommentSyntax = '#'): string { + return `${comment} <<< tab:${name} <<<`; +} + +/** Inspect existing file for our marker (any version). */ +export function inspectFile(path: string): MarkerInfo { + if (!existsSync(path)) return { managedByTab: false }; + let content: string; + try { + content = readFileSync(path, 'utf-8'); + } catch { + return { managedByTab: false }; + } + const match = content.match( + /(?:^|\n)[^\n]*tab-completion managed-by=tab name=(\S+) version=(\S+)/ + ); + if (!match) return { managedByTab: false }; + return { managedByTab: true, name: match[1], version: match[2] }; +} + +/** + * Wrap a multi-line block in start/end sentinels for idempotent append to + * shellrc/profile files. + */ +export function wrapBlock( + name: string, + body: string, + comment: CommentSyntax = '#' +): string { + return `${makeBlockStart(name, comment)}\n${body.trim()}\n${makeBlockEnd(name, comment)}\n`; +} + +/** True if `content` contains an unmodified wrapped block for this name. */ +export function fileContainsBlock( + filePath: string, + name: string, + comment: CommentSyntax = '#' +): boolean { + if (!existsSync(filePath)) return false; + try { + const content = readFileSync(filePath, 'utf-8'); + return ( + content.includes(makeBlockStart(name, comment)) && + content.includes(makeBlockEnd(name, comment)) + ); + } catch { + return false; + } +} + +/** + * Remove the wrapped block from `content`. Returns the original content if no + * block is present. + */ +export function removeBlock( + content: string, + name: string, + comment: CommentSyntax = '#' +): string { + const start = makeBlockStart(name, comment); + const end = makeBlockEnd(name, comment); + const startIdx = content.indexOf(start); + if (startIdx === -1) return content; + const endIdx = content.indexOf(end, startIdx); + if (endIdx === -1) return content; + const before = content.slice(0, startIdx).replace(/\n+$/, ''); + const after = content.slice(endIdx + end.length).replace(/^\n+/, ''); + return [before, after].filter((s) => s.length > 0).join('\n') + (after ? '\n' : ''); +} + +/** + * Replace the existing wrapped block (if present) with `newBlock`, otherwise + * return `content + '\n' + newBlock`. + */ +export function upsertBlock( + content: string, + name: string, + newBlock: string, + comment: CommentSyntax = '#' +): string { + const start = makeBlockStart(name, comment); + const end = makeBlockEnd(name, comment); + const startIdx = content.indexOf(start); + if (startIdx === -1) { + const sep = content.length > 0 && !content.endsWith('\n') ? '\n' : ''; + return content + sep + newBlock; + } + const endIdx = content.indexOf(end, startIdx); + if (endIdx === -1) { + return content + '\n' + newBlock; + } + return ( + content.slice(0, startIdx) + + newBlock.trimEnd() + + '\n' + + content.slice(endIdx + end.length).replace(/^\n/, '') + ); +} diff --git a/src/install/powershell.ts b/src/install/powershell.ts new file mode 100644 index 0000000..b9388ef --- /dev/null +++ b/src/install/powershell.ts @@ -0,0 +1,146 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import * as powershell from '../powershell'; +import type { ShellInstaller, ShellUninstaller } from './context'; +import { + powershellExecutionPolicy, + powershellProfilePath, +} from './detect'; +import { + fileContainsBlock, + inspectFile, + makeFileMarker, + removeBlock, + upsertBlock, + wrapBlock, +} from './markers'; + +export const installPowershell: ShellInstaller = (ctx) => { + const result = ctx.startResult('powershell'); + const profile = powershellProfilePath(); + + if (!profile) { + result.status = 'blocked'; + result.explanation = 'Could not locate PowerShell. Is pwsh installed and on PATH?'; + return result; + } + + const policy = powershellExecutionPolicy(); + ctx.log(`pwsh profile: ${profile}`); + ctx.log(`pwsh execution policy: ${policy}`); + + result.detected.shellEnv = { + profilePath: profile, + executionPolicy: policy, + }; + + // The completion script itself; we wrap it in a sentinel block since we're + // appending to a shared profile file. + const block = wrapBlock( + ctx.name, + `${makeFileMarker(ctx.name, ctx.version, '#')}\n${powershell.generate(ctx.name, ctx.executable)}`, + '#' + ); + + let existingContent = ''; + if (ctx.fileExists(profile)) { + try { + existingContent = readFileSync(profile, 'utf-8'); + } catch (err) { + result.status = 'blocked'; + result.explanation = `Failed to read ${profile}: ${(err as Error).message}`; + return result; + } + } + + const alreadyHasBlock = fileContainsBlock(profile, ctx.name, '#'); + const markerInfo = inspectFile(profile); + + if ( + alreadyHasBlock && + markerInfo.managedByTab && + markerInfo.version === ctx.version + ) { + result.status = 'already-installed'; + result.actions.push({ + type: 'append-file', + path: profile, + performed: false, + }); + } else { + const newContent = upsertBlock(existingContent, ctx.name, block, '#'); + + if (!ctx.dryRun) { + try { + mkdirSync(dirname(profile), { recursive: true }); + writeFileSync(profile, newContent); + } catch (err) { + result.status = 'blocked'; + result.explanation = `Failed to write profile: ${(err as Error).message}`; + return result; + } + } + result.actions.push({ + type: alreadyHasBlock ? 'append-file' : ctx.fileExists(profile) ? 'append-file' : 'write-file', + path: profile, + performed: !ctx.dryRun, + }); + result.status = alreadyHasBlock ? 'updated' : 'installed'; + } + + if (policy && /^Restricted$/i.test(policy)) { + result.status = 'needs-user-action'; + result.userInstructions.push( + 'Your PowerShell execution policy is Restricted, which blocks profile scripts.', + 'Run this once in an admin or user PowerShell session:', + ' Set-ExecutionPolicy -Scope CurrentUser RemoteSigned' + ); + } else if (result.status === 'installed' || result.status === 'updated') { + result.userInstructions.push( + 'Start a new PowerShell session to load the completions.' + ); + } + + return result; +}; + +export const uninstallPowershell: ShellUninstaller = (ctx) => { + const result = ctx.startResult('powershell'); + const profile = powershellProfilePath(); + + if (!profile || !ctx.fileExists(profile)) { + return result; + } + + if (!fileContainsBlock(profile, ctx.name, '#')) { + return result; + } + + let content: string; + try { + content = readFileSync(profile, 'utf-8'); + } catch (err) { + result.status = 'failed'; + result.explanation = (err as Error).message; + return result; + } + + const updated = removeBlock(content, ctx.name, '#'); + + if (!ctx.dryRun) { + try { + writeFileSync(profile, updated); + } catch (err) { + result.status = 'failed'; + result.explanation = (err as Error).message; + return result; + } + } + result.actions.push({ + type: 'remove-block', + path: profile, + performed: !ctx.dryRun, + }); + result.status = 'uninstalled'; + return result; +}; diff --git a/src/install/print.ts b/src/install/print.ts new file mode 100644 index 0000000..e04c9be --- /dev/null +++ b/src/install/print.ts @@ -0,0 +1,133 @@ +import type { + InstallResult, + InstallStatus, + UninstallResult, + UninstallStatus, +} from './types'; + +const STATUS_GLYPH: Record = { + installed: 'OK', + 'already-installed': 'OK', + updated: 'OK', + 'needs-user-action': '!', + blocked: 'X', + failed: 'X', +}; + +const STATUS_LABEL: Record = { + installed: 'installed', + 'already-installed': 'already installed', + updated: 'updated', + 'needs-user-action': 'needs user action', + blocked: 'blocked', + failed: 'failed', +}; + +export function formatResult(result: InstallResult): string { + const lines: string[] = []; + lines.push( + `[${STATUS_GLYPH[result.status]}] tab completions for ${result.shell}: ${STATUS_LABEL[result.status]}` + ); + if (result.explanation) { + lines.push(` ${result.explanation}`); + } + for (const action of result.actions) { + const verb = action.performed + ? action.type === 'write-file' + ? 'wrote' + : action.type === 'append-file' + ? 'appended to' + : 'created' + : action.type === 'write-file' + ? 'would write' + : action.type === 'append-file' + ? 'would append to' + : 'would create'; + lines.push(` ${verb} ${action.path}`); + } + if (result.userInstructions.length > 0) { + lines.push(''); + lines.push(' Next steps:'); + for (let i = 0; i < result.userInstructions.length; i++) { + const step = result.userInstructions[i]; + lines.push(` ${i + 1}. ${step.replace(/\n/g, '\n ')}`); + } + } + if (result.warnings.length > 0) { + lines.push(''); + lines.push(' Warnings:'); + for (const w of result.warnings) { + lines.push(` - ${w}`); + } + } + return lines.join('\n'); +} + +export function shouldPrint( + result: InstallResult, + setting: boolean | 'on-error' +): boolean { + if (setting === true) return true; + if (setting === false) return false; + return ( + result.status === 'needs-user-action' || + result.status === 'blocked' || + result.status === 'failed' || + result.warnings.length > 0 + ); +} + +const UNINSTALL_GLYPH: Record = { + uninstalled: 'OK', + 'not-installed': 'OK', + blocked: 'X', + failed: 'X', +}; + +const UNINSTALL_LABEL: Record = { + uninstalled: 'uninstalled', + 'not-installed': 'nothing to remove', + blocked: 'blocked', + failed: 'failed', +}; + +export function formatUninstallResult(result: UninstallResult): string { + const lines: string[] = []; + lines.push( + `[${UNINSTALL_GLYPH[result.status]}] tab completions for ${result.shell}: ${UNINSTALL_LABEL[result.status]}` + ); + if (result.explanation) { + lines.push(` ${result.explanation}`); + } + for (const action of result.actions) { + const verb = action.performed + ? action.type === 'remove-file' + ? 'removed' + : 'removed block from' + : action.type === 'remove-file' + ? 'would remove' + : 'would remove block from'; + lines.push(` ${verb} ${action.path}`); + } + if (result.warnings.length > 0) { + lines.push(''); + lines.push(' Warnings:'); + for (const w of result.warnings) { + lines.push(` - ${w}`); + } + } + return lines.join('\n'); +} + +export function shouldPrintUninstall( + result: UninstallResult, + setting: boolean | 'on-error' +): boolean { + if (setting === true) return true; + if (setting === false) return false; + return ( + result.status === 'blocked' || + result.status === 'failed' || + result.warnings.length > 0 + ); +} diff --git a/src/install/types.ts b/src/install/types.ts new file mode 100644 index 0000000..ed0b3e1 --- /dev/null +++ b/src/install/types.ts @@ -0,0 +1,96 @@ +export type SupportedShell = 'zsh' | 'bash' | 'fish' | 'powershell'; + +export type InstallMethod = + | 'npm-global' + | 'brew' + | 'standalone' + | 'node-modules' + | 'unknown'; + +export type InstallStatus = + | 'installed' + | 'already-installed' + | 'updated' + | 'needs-user-action' + | 'blocked' + | 'failed'; + +export type InstallAction = { + type: 'write-file' | 'append-file' | 'create-dir'; + path: string; + performed: boolean; +}; + +export type ShellEnvProbe = Record; + +export type InstallResult = { + shell: SupportedShell; + status: InstallStatus; + detected: { + pathReachable: boolean; + resolvedPath?: string; + installMethod: InstallMethod; + shellEnv: ShellEnvProbe; + }; + actions: InstallAction[]; + userInstructions: string[]; + warnings: string[]; + explanation?: string; +}; + +export type UninstallStatus = + | 'uninstalled' + | 'not-installed' + | 'blocked' + | 'failed'; + +export type UninstallAction = { + type: 'remove-file' | 'remove-block'; + path: string; + performed: boolean; +}; + +export type UninstallResult = { + shell: SupportedShell; + status: UninstallStatus; + actions: UninstallAction[]; + warnings: string[]; + explanation?: string; +}; + +export type UninstallOptions = { + /** The command name. Auto-detected from argv/package.json if omitted. */ + name?: string; + /** Which shell to uninstall from. 'auto' detects the current shell. */ + shell?: SupportedShell | 'auto'; + /** Compute the plan without touching disk. */ + dryRun?: boolean; + /** Remove completion files even if they don't have our marker. */ + force?: boolean; + /** Print a human-readable summary to stderr. */ + print?: boolean | 'on-error'; + /** Emit detection-step logs to stderr. */ + verbose?: boolean; +}; + +export type InstallOptions = { + /** The command name (e.g. 'my-cli'). Auto-detected from argv/package.json if omitted. */ + name?: string; + /** How to invoke the CLI from a completion script (e.g. 'my-cli'). Defaults to `name`. */ + executable?: string; + /** Which shell to install for. 'auto' detects the current shell. */ + shell?: SupportedShell | 'auto'; + /** Compute the install plan without touching disk. */ + dryRun?: boolean; + /** Overwrite existing completion files that we did not manage. */ + force?: boolean; + /** + * Print a human-readable summary to stderr. + * - `true`: always print + * - `false`: never print + * - `'on-error'` (default): print only when the result needs user attention + */ + print?: boolean | 'on-error'; + /** Emit detection-step logs to stderr for debugging. */ + verbose?: boolean; +}; diff --git a/src/install/zsh.ts b/src/install/zsh.ts new file mode 100644 index 0000000..96bee05 --- /dev/null +++ b/src/install/zsh.ts @@ -0,0 +1,196 @@ +import { accessSync, constants, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import * as zsh from '../zsh'; +import type { ShellInstaller, ShellUninstaller } from './context'; +import { + brewPrefix, + detectZshFpath, + zshrcHasCompinit, +} from './detect'; +import { inspectFile, makeFileMarker } from './markers'; + +function isWritableDir(p: string): boolean { + try { + accessSync(p, constants.W_OK); + return true; + } catch { + return false; + } +} + +type ZshTarget = { + dir: string; + /** True if the dir is already in the user's $fpath. */ + inFpath: boolean; +}; + +function pickZshTargetDir(fpath: string[]): ZshTarget { + const writableFromFpath = fpath.find(isWritableDir); + if (writableFromFpath) { + return { dir: writableFromFpath, inFpath: true }; + } + + const brew = brewPrefix(); + if (brew) { + const brewSiteFunctions = join(brew, 'share', 'zsh', 'site-functions'); + if (isWritableDir(brewSiteFunctions) || fpath.includes(brewSiteFunctions)) { + return { dir: brewSiteFunctions, inFpath: fpath.includes(brewSiteFunctions) }; + } + } + + return { dir: join(homedir(), '.zsh', 'completions'), inFpath: false }; +} + +export const installZsh: ShellInstaller = (ctx) => { + const result = ctx.startResult('zsh'); + const fpath = detectZshFpath(); + const hasCompinit = zshrcHasCompinit() || fpath.length > 0; + const target = pickZshTargetDir(fpath); + const filePath = join(target.dir, `_${ctx.name}`); + + ctx.log(`zsh fpath has ${fpath.length} entries`); + ctx.log(`zsh target dir: ${target.dir} (in fpath: ${target.inFpath})`); + + result.detected.shellEnv = { + fpathDirCount: fpath.length, + targetDir: target.dir, + targetInFpath: target.inFpath, + hasCompinit, + }; + + const existing = inspectFile(filePath); + if (existing.managedByTab && existing.version === ctx.version && target.inFpath && hasCompinit) { + result.status = 'already-installed'; + result.actions.push({ type: 'write-file', path: filePath, performed: false }); + return result; + } + if (!existing.managedByTab && ctx.fileExists(filePath) && !ctx.force) { + result.status = 'blocked'; + result.explanation = `An unmanaged completion file already exists at ${filePath}.`; + result.userInstructions.push( + `Remove ${filePath} and re-run, or pass { force: true } to overwrite.` + ); + return result; + } + + const marker = makeFileMarker(ctx.name, ctx.version, '#'); + const script = `${marker}\n${zsh.generate(ctx.name, ctx.executable)}`; + + if (!ctx.dryRun) { + try { + mkdirSync(target.dir, { recursive: true }); + writeFileSync(filePath, script); + } catch (err) { + result.status = 'blocked'; + result.explanation = `Failed to write completion file: ${(err as Error).message}`; + result.userInstructions.push( + `The target directory ${target.dir} is not writable. Try running with sudo, or pick a user-writable fpath dir.` + ); + return result; + } + } + result.actions.push({ + type: 'write-file', + path: filePath, + performed: !ctx.dryRun, + }); + + const needsActions: string[] = []; + if (!target.inFpath) { + needsActions.push( + `Add this line to your ~/.zshrc (before \`compinit\`):\n fpath=(${target.dir} $fpath)` + ); + } + if (!hasCompinit) { + needsActions.push( + `Add this line to your ~/.zshrc:\n autoload -U compinit && compinit` + ); + } + + if (needsActions.length > 0) { + result.status = 'needs-user-action'; + result.userInstructions.push(...needsActions); + result.userInstructions.push('Then restart your shell or run `exec zsh`.'); + } else { + result.status = existing.managedByTab ? 'updated' : 'installed'; + result.userInstructions.push( + 'Restart your shell or run `exec zsh` to load the new completions.' + ); + } + + // Warn on the well-known Homebrew double-install case. + const brew = brewPrefix(); + if (brew) { + const brewFile = join(brew, 'share', 'zsh', 'site-functions', `_${ctx.name}`); + if (brewFile !== filePath && ctx.fileExists(brewFile)) { + result.warnings.push( + `A Homebrew-installed completion exists at ${brewFile} and may shadow this one depending on fpath order.` + ); + } + } + + return result; +}; + +/** + * Enumerate every place we might have installed a zsh completion for `name`. + * Returns absolute paths to a `_` file in each candidate dir, regardless + * of whether the file actually exists. + */ +function zshCandidatePaths(name: string): string[] { + const candidates: string[] = []; + for (const dir of detectZshFpath()) { + candidates.push(join(dir, `_${name}`)); + } + const brew = brewPrefix(); + if (brew) { + candidates.push(join(brew, 'share', 'zsh', 'site-functions', `_${name}`)); + } + candidates.push(join(homedir(), '.zsh', 'completions', `_${name}`)); + return Array.from(new Set(candidates)); +} + +export const uninstallZsh: ShellUninstaller = (ctx) => { + const result = ctx.startResult('zsh'); + const candidates = zshCandidatePaths(ctx.name); + ctx.log(`zsh candidate paths: ${candidates.length}`); + + let removedAny = false; + for (const filePath of candidates) { + if (!ctx.fileExists(filePath)) continue; + + const info = inspectFile(filePath); + if (!info.managedByTab && !ctx.force) { + result.warnings.push( + `Skipping ${filePath} — not written by tab. Pass { force: true } to remove anyway.` + ); + continue; + } + + if (!ctx.dryRun) { + try { + rmSync(filePath); + } catch (err) { + result.warnings.push( + `Failed to remove ${filePath}: ${(err as Error).message}` + ); + continue; + } + } + result.actions.push({ + type: 'remove-file', + path: filePath, + performed: !ctx.dryRun, + }); + removedAny = true; + } + + if (removedAny) { + result.status = 'uninstalled'; + } else if (result.warnings.length > 0) { + result.status = 'blocked'; + result.explanation = 'Found completion files we did not manage. See warnings.'; + } + return result; +}; diff --git a/tests/install.test.ts b/tests/install.test.ts new file mode 100644 index 0000000..3a7ee9a --- /dev/null +++ b/tests/install.test.ts @@ -0,0 +1,290 @@ +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { makeContext, makeUninstallContext } from '../src/install/context'; +import { installFish, uninstallFish } from '../src/install/fish'; +import { installBash, uninstallBash } from '../src/install/bash'; +import { + makeFileMarker, + inspectFile, + wrapBlock, + upsertBlock, + fileContainsBlock, + removeBlock, +} from '../src/install/markers'; + +function newTmpDir() { + return mkdtempSync(join(tmpdir(), 'tab-install-')); +} + +function baseUninstallCtx( + overrides: Partial[0]> = {} +) { + return makeUninstallContext({ + name: 'test-cli', + dryRun: false, + force: false, + verbose: false, + ...overrides, + }); +} + +function baseCtx(overrides: Partial[0]> = {}) { + return makeContext({ + name: 'test-cli', + executable: 'test-cli', + version: '9.9.9', + dryRun: false, + force: false, + verbose: false, + detected: { + pathReachable: true, + resolvedPath: '/usr/local/bin/test-cli', + installMethod: 'standalone', + }, + ...overrides, + }); +} + +describe('markers', () => { + it('inspectFile returns managedByTab=true with version when marker present', () => { + const tmp = newTmpDir(); + const file = join(tmp, 'sample'); + writeFileSync( + file, + `${makeFileMarker('test-cli', '1.2.3', '#')}\n# rest of file\n` + ); + expect(inspectFile(file)).toEqual({ + managedByTab: true, + name: 'test-cli', + version: '1.2.3', + }); + }); + + it('inspectFile returns managedByTab=false for unmanaged files', () => { + const tmp = newTmpDir(); + const file = join(tmp, 'sample'); + writeFileSync(file, '# someone elses completion file\n'); + expect(inspectFile(file).managedByTab).toBe(false); + }); + + it('upsertBlock inserts a new block when none exists', () => { + const block = wrapBlock('test-cli', 'echo hello', '#'); + const result = upsertBlock('existing content\n', 'test-cli', block, '#'); + expect(result).toContain('# >>> tab:test-cli >>>'); + expect(result).toContain('echo hello'); + expect(result).toContain('# <<< tab:test-cli <<<'); + expect(result).toContain('existing content'); + }); + + it('upsertBlock replaces an existing block in place (idempotent)', () => { + const first = wrapBlock('test-cli', 'old body', '#'); + const second = wrapBlock('test-cli', 'new body', '#'); + let content = upsertBlock('# header\n', 'test-cli', first, '#'); + content = upsertBlock(content, 'test-cli', second, '#'); + expect(content).toContain('new body'); + expect(content).not.toContain('old body'); + // sentinel pair should appear exactly once + expect(content.match(/>>> tab:test-cli >>>/g)?.length).toBe(1); + }); + + it('fileContainsBlock detects the sentinel pair', () => { + const tmp = newTmpDir(); + const file = join(tmp, 'profile'); + writeFileSync(file, wrapBlock('test-cli', 'body', '#')); + expect(fileContainsBlock(file, 'test-cli', '#')).toBe(true); + expect(fileContainsBlock(file, 'other-cli', '#')).toBe(false); + }); +}); + +describe('installFish', () => { + let origXdg: string | undefined; + let tmp: string; + + beforeEach(() => { + tmp = newTmpDir(); + origXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = tmp; + }); + afterEach(() => { + if (origXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = origXdg; + }); + + it('writes a marker-tagged completion file', () => { + const result = installFish(baseCtx()); + expect(result.status).toBe('installed'); + const file = join(tmp, 'fish', 'completions', 'test-cli.fish'); + expect(existsSync(file)).toBe(true); + expect(readFileSync(file, 'utf-8')).toContain( + 'tab-completion managed-by=tab name=test-cli version=9.9.9' + ); + }); + + it('returns already-installed on identical re-run', () => { + installFish(baseCtx()); + const result = installFish(baseCtx()); + expect(result.status).toBe('already-installed'); + expect(result.actions[0].performed).toBe(false); + }); + + it('returns updated when version differs', () => { + installFish(baseCtx({ version: '1.0.0' })); + const result = installFish(baseCtx({ version: '2.0.0' })); + expect(result.status).toBe('updated'); + }); + + it('blocks when an unmanaged file exists', () => { + const dir = join(tmp, 'fish', 'completions'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'test-cli.fish'), '# my own completion\n'); + const result = installFish(baseCtx()); + expect(result.status).toBe('blocked'); + expect(result.userInstructions[0]).toMatch(/Remove|force/); + }); + + it('overwrites unmanaged file with force', () => { + const dir = join(tmp, 'fish', 'completions'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'test-cli.fish'), '# my own completion\n'); + const result = installFish(baseCtx({ force: true })); + expect(result.status).toBe('installed'); + expect(readFileSync(join(dir, 'test-cli.fish'), 'utf-8')).toContain( + 'managed-by=tab' + ); + }); + + it('honors dryRun (no file written)', () => { + const result = installFish(baseCtx({ dryRun: true })); + expect(result.status).toBe('installed'); + expect(result.actions[0].performed).toBe(false); + expect(existsSync(join(tmp, 'fish', 'completions', 'test-cli.fish'))).toBe( + false + ); + }); +}); + +describe('markers.removeBlock', () => { + it('removes a wrapped block leaving surrounding content intact', () => { + const block = wrapBlock('test-cli', 'body', '#'); + const content = `# header line\n${block}# trailing line\n`; + const stripped = removeBlock(content, 'test-cli', '#'); + expect(stripped).toContain('# header line'); + expect(stripped).toContain('# trailing line'); + expect(stripped).not.toContain('>>> tab:test-cli >>>'); + expect(stripped).not.toContain('body'); + }); + + it('is a no-op when the block is not present', () => { + const content = '# unrelated content\n'; + expect(removeBlock(content, 'test-cli', '#')).toBe(content); + }); +}); + +describe('uninstallFish', () => { + let origXdg: string | undefined; + let tmp: string; + + beforeEach(() => { + tmp = newTmpDir(); + origXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = tmp; + }); + afterEach(() => { + if (origXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = origXdg; + }); + + it('returns not-installed when there is no completion file', () => { + const result = uninstallFish(baseUninstallCtx()); + expect(result.status).toBe('not-installed'); + expect(result.actions).toHaveLength(0); + }); + + it('removes a managed completion file', () => { + installFish(baseCtx()); + const file = join(tmp, 'fish', 'completions', 'test-cli.fish'); + expect(existsSync(file)).toBe(true); + const result = uninstallFish(baseUninstallCtx()); + expect(result.status).toBe('uninstalled'); + expect(existsSync(file)).toBe(false); + }); + + it('refuses to remove an unmanaged file without force', () => { + const dir = join(tmp, 'fish', 'completions'); + mkdirSync(dir, { recursive: true }); + const file = join(dir, 'test-cli.fish'); + writeFileSync(file, '# my own file\n'); + const result = uninstallFish(baseUninstallCtx()); + expect(result.status).toBe('blocked'); + expect(existsSync(file)).toBe(true); + }); + + it('removes an unmanaged file with force', () => { + const dir = join(tmp, 'fish', 'completions'); + mkdirSync(dir, { recursive: true }); + const file = join(dir, 'test-cli.fish'); + writeFileSync(file, '# my own file\n'); + const result = uninstallFish(baseUninstallCtx({ force: true })); + expect(result.status).toBe('uninstalled'); + expect(existsSync(file)).toBe(false); + }); + + it('honors dryRun (no file removed)', () => { + installFish(baseCtx()); + const file = join(tmp, 'fish', 'completions', 'test-cli.fish'); + const result = uninstallFish(baseUninstallCtx({ dryRun: true })); + expect(result.status).toBe('uninstalled'); + expect(result.actions[0].performed).toBe(false); + expect(existsSync(file)).toBe(true); + }); +}); + +describe('uninstallBash', () => { + let origXdg: string | undefined; + let tmp: string; + + beforeEach(() => { + tmp = newTmpDir(); + origXdg = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = tmp; + }); + afterEach(() => { + if (origXdg === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = origXdg; + }); + + it('removes a managed completion file', () => { + installBash(baseCtx()); + const file = join(tmp, 'bash-completion', 'completions', 'test-cli'); + expect(existsSync(file)).toBe(true); + const result = uninstallBash(baseUninstallCtx()); + expect(result.status).toBe('uninstalled'); + expect(existsSync(file)).toBe(false); + }); +}); + +describe('installBash', () => { + let origXdg: string | undefined; + let tmp: string; + + beforeEach(() => { + tmp = newTmpDir(); + origXdg = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = tmp; + }); + afterEach(() => { + if (origXdg === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = origXdg; + }); + + it('writes a completion file in the XDG data dir', () => { + const result = installBash(baseCtx()); + const file = join(tmp, 'bash-completion', 'completions', 'test-cli'); + expect(existsSync(file)).toBe(true); + expect(readFileSync(file, 'utf-8')).toContain('managed-by=tab'); + // status depends on whether bash-completion is detected on the host; both are valid outcomes + expect(['installed', 'needs-user-action']).toContain(result.status); + }); +}); diff --git a/tsdown.config.ts b/tsdown.config.ts index a3ad23d..aab5fce 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ citty: 'src/citty.ts', cac: 'src/cac.ts', commander: 'src/commander.ts', + install: 'src/install/index.ts', 'bin/cli': 'bin/cli.ts', }, format: ['esm'],