diff --git a/README.md b/README.md index 1b59c0d..60de2cd 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,36 @@ You also need a ChurchTools **personal login token** (ChurchTools → your user settings). Each release additionally attaches an `INSTALL.md` with the exact commands. +### Tab completion + +`ct completion ` prints a small hook that calls `ct` back on every Tab, so the +candidates always match the binary you actually have — including things a generated +script could not know: the environments in your `ct.envs.json`, the keys in your state +file, and paths for options like `--config`. It only ever reads local files: completion +never contacts ChurchTools and never touches your token. + +Add the hook to your shell startup file: + +```zsh +# zsh — after `compinit` has run +echo '. <(ct completion zsh)' >> ~/.zshrc +``` + +```bash +# bash +echo '. <(ct completion bash)' >> ~/.bashrc # macOS: ~/.bash_profile +``` + +The bash hook runs on the bash macOS ships (3.2); the `bash-completion` package is +not required. + +```fish +# fish +ct completion fish > ~/.config/fish/completions/ct.fish +``` + +Open a new shell, then try `ct sta`, `ct state rm ` or `ct plan --env `. + ## First run ```bash diff --git a/package-lock.json b/package-lock.json index e1a0d42..cede731 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,17 @@ { - "name": "ct-cli", - "version": "0.0.0", + "name": "@eqrm/ct-cli", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ct-cli", - "version": "0.0.0", + "name": "@eqrm/ct-cli", + "version": "0.1.0", + "license": "MIT", "dependencies": { "commander": "^12.1.0", "jiti": "^2.4.0", + "omelette": "^0.4.17", "openapi-fetch": "^0.13.0", "picocolors": "^1.1.0" }, @@ -19,6 +21,7 @@ "devDependencies": { "@eslint/js": "^9.0.0", "@types/node": "^22.0.0", + "@types/omelette": "^0.4.5", "eslint": "^9.0.0", "openapi-typescript": "^7.4.0", "prettier": "^3.3.0", @@ -1221,6 +1224,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/omelette": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@types/omelette/-/omelette-0.4.5.tgz", + "integrity": "sha512-zUCJpVRwfMcZfkxSCGp73mgd3/xesvPz5tQJIORlfP/zkYEyp9KUfF7IP3RRjyZR3DwxkPs96/IFf70GmYZYHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.63.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", @@ -2699,6 +2709,14 @@ "node": ">=0.10.0" } }, + "node_modules/omelette": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/omelette/-/omelette-0.4.17.tgz", + "integrity": "sha512-UlU69G6Bhu0XFjw3tjFZ0qyiMUjAOR+rdzblA1nLQ8xiqFtxOVlkhM39BlgTpLFx9fxkm6rnxNNRsS5GxE/yww==", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/openapi-fetch": { "version": "0.13.8", "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.13.8.tgz", diff --git a/package.json b/package.json index c37f8f7..050e34f 100644 --- a/package.json +++ b/package.json @@ -38,12 +38,14 @@ "dependencies": { "commander": "^12.1.0", "jiti": "^2.4.0", + "omelette": "^0.4.17", "openapi-fetch": "^0.13.0", "picocolors": "^1.1.0" }, "devDependencies": { "@eslint/js": "^9.0.0", "@types/node": "^22.0.0", + "@types/omelette": "^0.4.5", "eslint": "^9.0.0", "openapi-typescript": "^7.4.0", "prettier": "^3.3.0", diff --git a/src/commands/completion.ts b/src/commands/completion.ts new file mode 100644 index 0000000..6bfeae5 --- /dev/null +++ b/src/commands/completion.ts @@ -0,0 +1,13 @@ +import { Argument, Command } from "commander"; +import { COMPLETION_SHELLS, completionScript, type CompletionShell } from "../completion/shell.js"; + +export function completionCommand(): Command { + return new Command("completion") + .description("Print the shell hook that makes Tab complete `ct` (zsh, bash, fish)") + .addArgument(new Argument("", "shell to print the hook for").choices([...COMPLETION_SHELLS])) + .action((shell: CompletionShell, _options: unknown, command: Command) => { + // The hook completes the program it is attached to, so it is generated from the + // root program rather than from this subcommand. + process.stdout.write(completionScript(command.parent ?? command, shell)); + }); +} diff --git a/src/completion/candidates.ts b/src/completion/candidates.ts new file mode 100644 index 0000000..801750a --- /dev/null +++ b/src/completion/candidates.ts @@ -0,0 +1,221 @@ +/** + * What `ct` offers the shell when Tab is pressed (#132). + * + * The shell hook installed by `ct completion ` is a stub: on every Tab it + * hands the command line back to this process, and this module answers. Nothing is + * baked into a generated script, so the candidates are always exactly what the + * Commander tree in this build declares — and they can be *dynamic*, which a static + * script structurally cannot be: real environment names out of `ct.envs.json`, real + * keys out of the state file, real paths out of the working directory. + * + * Everything here is pure command-tree introspection plus the offline sources in + * `./sources.js`; see that module for the never-contact-ChurchTools, never-throw, + * never-hang guarantees. + */ +import type { Argument, Command, Option } from "commander"; +import { defaultEnvStatePath, resolveEnvsPath } from "../env/envs.js"; +import { resolveStatePath } from "../state/state.js"; +import { envNames, envStatePath, paths, resourceTypes, stateKeys } from "./sources.js"; + +/** Where the cursor is: the resolved command plus the words already typed for it. */ +interface Position { + /** The deepest subcommand the typed words resolve to. */ + command: Command; + /** Full path of that command, e.g. `ct state rm`. */ + path: string; + /** Positional words typed for `command` so far. */ + positionals: string[]; + /** Values of the value-taking options typed so far, keyed by long flag. */ + options: Map; +} + +/** A value slot whose candidates come from a local file rather than the command tree. */ +type DynamicSource = (position: Position, partial: string) => Promise | string[]; + +/** + * Positional arguments whose values live in a local file, keyed by ` + * `. Keeping this a table rather than sprinkling special cases through + * the walker keeps the dynamic surface reviewable in one place. + */ +const DYNAMIC_ARGUMENTS: Record = { + "ct adopt type": () => resourceTypes(), + "ct state rm type": () => resourceTypes(), + // `ct state rm ` refuses a key belonging to another type, so the type + // already typed narrows the keys — completing into a guaranteed error helps nobody. + "ct state rm key": async (position) => stateKeys(await statePathFor(position), position.positionals[0]), +}; + +/** + * The state file the typed `--state`/`--env` words point at, with the same precedence + * the commands use (`prepareEnv` → `resolveStatePath`): explicit `--state`, then + * `CT_STATE`, then the env profile's own `state` field, then the `ct-state..json` + * convention. Reading the profile's override matters — a repo that sets it would + * otherwise be offered the keys of a state file it does not use. + */ +async function statePathFor(position: Position): Promise { + const env = position.options.get("--env"); + const declared = env ? await envStatePath(resolveEnvsPath(), env) : undefined; + return resolveStatePath( + position.options.get("--state"), + process.env, + env ? (declared ?? defaultEnvStatePath(env)) : undefined, + ); +} + +function takesValue(option: Option): boolean { + return option.required || option.optional; +} + +function visibleOptions(command: Command): Option[] { + return command.createHelp().visibleOptions(command); +} + +function flagsOf(option: Option): string[] { + return [option.short, option.long].filter((flag): flag is string => Boolean(flag)); +} + +function findOption(command: Command, flag: string): Option | undefined { + return visibleOptions(command).find((option) => flagsOf(option).includes(flag)); +} + +function findSubcommand(command: Command, word: string): Command | undefined { + return command.commands.find((child) => child.name() === word || child.aliases().includes(word)); +} + +function commandPath(command: Command): string { + const names: string[] = []; + for (let node: Command | null = command; node; node = node.parent) names.unshift(node.name()); + return names.join(" "); +} + +/** + * Replay the typed words against the command tree. + * + * Unknown words are positionals, known subcommand names descend (and reset the + * positional count), and a value-taking option swallows the word after it so that + * `ct plan --env dev ` completes a positional rather than treating `dev` as one. + */ +function walk(program: Command, words: string[]): Position { + let command = program; + let positionals: string[] = []; + const options = new Map(); + + for (let i = 0; i < words.length; i++) { + const word = words[i] ?? ""; + if (word.startsWith("-")) { + const split = word.indexOf("="); + const flag = split === -1 ? word : word.slice(0, split); + const option = findOption(command, flag) ?? findOption(program, flag); + if (!option || !takesValue(option)) continue; + const value = split === -1 ? words[++i] : word.slice(split + 1); + if (option.long && value !== undefined) options.set(option.long, value); + continue; + } + const child = findSubcommand(command, word); + if (child) { + command = child; + positionals = []; + } else { + positionals.push(word); + } + } + + return { command, path: commandPath(command), positionals, options }; +} + +/** + * The kind of value an option or argument wants, inferred from its placeholder and + * description. Commander has no "this is a path" metadata, and every path-taking flag + * in this CLI says so in its own help text (`--state `, `--backup-dir `), + * so the help text is the metadata. + */ +function pathKind(name: string, description: string): "file" | "directory" | undefined { + const hint = `${name} ${description}`.toLowerCase(); + if (/\b(dir|directory|folder)\b/.test(hint)) return "directory"; + if (/\b(path|file|filename)\b/.test(hint)) return "file"; + return undefined; +} + +/** The placeholder inside an option's flags, e.g. `path` for `-s, --state `. */ +function placeholder(option: Option): string { + return option.flags.match(/[<[]([^>\]]+)[>\]]/)?.[1]?.replace(/\.{3}$/, "") ?? "value"; +} + +async function optionValues(option: Option, position: Position, partial: string): Promise { + if (option.argChoices?.length) return [...option.argChoices]; + // The one flag every command shares, and the reason runtime completion is worth it: + // these are the environments this config repo actually defines. + if (option.long === "--env") return envNames(resolveEnvsPath()); + const kind = pathKind(placeholder(option), option.description); + return kind ? paths(partial, kind) : []; +} + +async function argumentValues(position: Position, partial: string): Promise { + const args = position.command.registeredArguments; + const argument: Argument | undefined = + args[position.positionals.length] ?? (args.at(-1)?.variadic ? args.at(-1) : undefined); + if (!argument) return []; + if (argument.argChoices?.length) return [...argument.argChoices]; + const dynamic = DYNAMIC_ARGUMENTS[`${position.path} ${argument.name()}`]; + if (dynamic) return dynamic(position, partial); + const kind = pathKind(argument.name(), argument.description); + return kind ? paths(partial, kind) : []; +} + +/** The option that the word before the cursor is still waiting for a value for, if any. */ +function pendingOption(program: Command, position: Position, previous: string): Option | undefined { + if (!previous.startsWith("-") || previous.includes("=")) return undefined; + const option = findOption(position.command, previous) ?? findOption(program, previous); + return option && takesValue(option) ? option : undefined; +} + +/** + * Candidates for the word being typed. + * + * `words` are the words already completed (including the program name); `partial` is + * the word under the cursor, or `""` when the cursor sits after a space. The shells + * do the prefix filtering themselves, so the full candidate set for the position is + * returned — `partial` is only consulted where the candidates depend on it (paths). + */ +export async function completionCandidates( + program: Command, + words: string[], + partial: string, +): Promise { + const position = walk(program, words.slice(1)); + + const pending = pendingOption(program, position, words.at(-1) ?? ""); + if (pending) return optionValues(pending, position, partial); + + if (partial.startsWith("-")) return visibleOptions(position.command).flatMap(flagsOf); + + const subcommands = position.command + .createHelp() + .visibleCommands(position.command) + .map((child) => child.name()); + return [...subcommands, ...(await argumentValues(position, partial))]; +} + +/** + * Split a shell command line into the completed words plus the word under the cursor. + * + * `fragment` is the shell's index of the word under the cursor. It is what locates the + * cursor at all: `line` is the whole buffer, so it also holds whatever the user has + * typed *after* the cursor, and a trailing space in it says nothing about where the + * cursor sits. Splitting at `fragment` is therefore what makes Tab in the middle of a + * line complete the word it is actually on. + * + * The index is trustworthy in zsh and fish. bash's hook derives it from `COMP_CWORD` + * minus a fudge for colons, and `COMP_WORDBREAKS` breaks on more than colons (`=`, `:` + * after the cursor), so it can come in inflated past the end of the line. That case is + * detectable — the index points past the last token — and falls back to the shape of + * the line itself, which is what the previous behaviour did for every line. + */ +export function splitCompletionLine(line: string, fragment: number): { words: string[]; partial: string } { + const tokens = line.split(/\s+/).filter(Boolean); + if (fragment >= 0 && fragment < tokens.length) { + return { words: tokens.slice(0, fragment), partial: tokens[fragment] ?? "" }; + } + if (tokens.length === 0 || /\s$/.test(line)) return { words: tokens, partial: "" }; + return { words: tokens.slice(0, -1), partial: tokens.at(-1) ?? "" }; +} diff --git a/src/completion/shell.ts b/src/completion/shell.ts new file mode 100644 index 0000000..0b975c8 --- /dev/null +++ b/src/completion/shell.ts @@ -0,0 +1,110 @@ +/** + * The shell side of tab completion (#132). + * + * `ct` does not generate a completion script. It installs a ~15-line hook — supplied + * by [omelette](https://github.com/f/omelette), not written here — that calls `ct` + * back on every Tab with the current command line, and answers with the candidates + * from {@link completionCandidates}. Two consequences are the whole point of doing it + * this way: + * + * - The per-shell syntax (zsh `compdef`/`compadd`, bash `complete`/`compgen`, fish + * `complete -a`) lives in the library. The only dialect this repo writes itself is + * {@link BASH_COMPLETION_FALLBACKS}, and only because omelette's bash branch depends + * on a package stock macOS bash does not ship. + * - The candidates are computed live, so they follow the command tree of the binary + * that is actually installed and can include things a static script could never + * know — the environments in *this* `ct.envs.json`, the keys in *this* state file. + * + * omelette was picked over `@pnpm/tabtab` because it has no dependencies and inlines + * its hooks as strings, whereas tabtab reads its templates off disk at runtime: in + * the `bun build --compile` binaries this project releases, that resolves to the + * build machine's `node_modules` and fails with ENOENT on every user's machine. + * + * omelette is effectively frozen — last release 0.4.17 in September 2021, last commit + * January 2022 — which is accepted deliberately. It is ~350 lines of MIT-licensed, + * dependency-free CommonJS whose entire job is emitting three static hook strings, so + * the realistic failure mode is "never gains a feature", not "breaks". If it ever does + * break, vendoring it into this repo is an afternoon's work and the licence allows it. + * The tests here cover the hook shape per shell, so a regression surfaces in CI. + */ +import type { Command } from "commander"; +import omelette from "omelette"; +import { completionCandidates, splitCompletionLine } from "./candidates.js"; + +export const COMPLETION_SHELLS = ["zsh", "bash", "fish"] as const; +export type CompletionShell = (typeof COMPLETION_SHELLS)[number]; + +/** The plumbing flag omelette's hooks pass when the shell is asking for candidates. */ +const COMPGEN_FLAG = "--compgen"; + +/** What omelette hands a `complete` handler. Narrower than `@types/omelette`, which + * does not model the two-argument `complete` event or a promise-returning reply. */ +interface CompletionRequest { + /** The full command line as the shell has it, e.g. `ct state rm ca`. */ + line: string; + /** The shell's index of the word being completed. */ + fragment: number; + reply: (words: string[] | Promise) => void; +} + +interface CompletionHook { + onAsync(event: "complete", handler: (fragment: string, request: CompletionRequest) => void): void; + init(): void; + generateCompletionCode(): string; + generateCompletionCodeFish(): string; +} + +function hook(program: Command): CompletionHook { + return omelette(program.name()) as unknown as CompletionHook; +} + +/** + * The one piece of shell dialect this repo does own. + * + * omelette's `complete`-based branch calls `_get_comp_words_by_ref` and + * `__ltrim_colon_completions`, which ship with the `bash-completion` package rather + * than with bash. Stock macOS bash (3.2, the one `~/.bash_profile` in the README gets + * you) has neither, so without this every single Tab prints two `command not found` + * lines into the command line — exactly the failure mode the rest of this module goes + * out of its way to avoid. These stand-ins do what that one call site asks for and + * nothing more, and they only ever define a name that is not already defined, so a + * machine that does have bash-completion keeps the real ones. + */ +const BASH_COMPLETION_FALLBACKS = `### ct completion fallbacks - begin ### +if ! type compdef >/dev/null 2>&1 && type complete >/dev/null 2>&1; then + if ! declare -F _get_comp_words_by_ref >/dev/null 2>&1; then + _get_comp_words_by_ref() { cur=\${COMP_WORDS[COMP_CWORD]}; prev=\${COMP_WORDS[COMP_CWORD-1]}; } + fi + if ! declare -F __ltrim_colon_completions >/dev/null 2>&1; then + __ltrim_colon_completions() { :; } + fi +fi +### ct completion fallbacks - end ###`; + +/** The hook to paste into a shell startup file. zsh and bash share one (it branches itself). */ +export function completionScript(program: Command, shell: CompletionShell): string { + const instance = hook(program); + if (shell === "fish") return `${instance.generateCompletionCodeFish()}\n`; + return `${BASH_COMPLETION_FALLBACKS}\n${instance.generateCompletionCode()}\n`; +} + +/** True when this invocation is a shell asking for candidates rather than a user running a command. */ +export function isCompletionRequest(argv: readonly string[]): boolean { + return argv.includes(COMPGEN_FLAG); +} + +/** + * Answer one completion request and exit. + * + * A completion that errors is worse than one that offers nothing — a rejected promise + * here would spill a stack trace into the user's command line — so failures collapse + * to an empty candidate list. `reply` writes the candidates and exits the process. + */ +export function serveCompletionRequest(program: Command): void { + const instance = hook(program); + instance.onAsync("complete", (_fragment, request) => { + const { words, partial } = splitCompletionLine(request.line, request.fragment); + request.reply(completionCandidates(program, words, partial).catch(() => [])); + }); + instance.init(); +} diff --git a/src/completion/sources.ts b/src/completion/sources.ts new file mode 100644 index 0000000..75803b0 --- /dev/null +++ b/src/completion/sources.ts @@ -0,0 +1,126 @@ +/** + * Offline data sources for tab completion (#132). + * + * Everything here runs on a Tab keypress, which imposes two hard rules: + * + * - **Nothing contacts ChurchTools and nothing reads a credential.** Completion is + * allowed to look at the local, non-secret config repo (`ct.envs.json`, the state + * file, the working directory) and at nothing else. There is no client, no token + * store and no `prepareEnv` in this module, on purpose. + * - **A failure yields no candidates, never an error and never a hang.** A missing, + * unreadable, malformed or slow file is the normal case while a config repo is + * being edited; the shell must simply offer nothing instead of printing a stack + * trace into the command line. So every read is wrapped in {@link offline}. + * + * That is also why these read the files directly rather than going through + * `loadEnvProfile`/`loadState`: those validate and throw friendly errors, which is + * right for a command and wrong for a keypress. + */ +import { readdir, readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { RESOURCES } from "../resources/registry.js"; + +/** A source slower than this is abandoned — the shell must never wait on `ct`. */ +const SOURCE_TIMEOUT_MS = 150; + +/** Run a source with the two guarantees above: no throw, no hang, `fallback` on failure. */ +async function offline(read: () => Promise, fallback: T): Promise { + let timer: ReturnType | undefined; + const abandon = new Promise((resolve) => { + timer = setTimeout(() => resolve(fallback), SOURCE_TIMEOUT_MS); + timer.unref?.(); + }); + try { + return await Promise.race([read().catch(() => fallback), abandon]); + } catch { + return fallback; + } finally { + if (timer) clearTimeout(timer); + } +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** The object under `field`, or an empty one for anything that is not a JSON object. */ +function objectField(value: unknown, field: string): Record { + if (!isObject(value)) return {}; + const nested = value[field]; + return isObject(nested) ? nested : {}; +} + +/** The environment names declared in the profile file — the values `--env` accepts. */ +export function envNames(path: string): Promise { + return offline( + async () => Object.keys(objectField(JSON.parse(await readFile(path, "utf8")), "environments")), + [], + ); +} + +/** + * The state file a named environment declares, or `undefined` when it leaves the + * `ct-state..json` convention alone. This is the same `state` field the commands + * read through `loadEnvProfile`; completion has to honour it too, or it answers about + * a different state file than the one `ct state rm` will edit. + */ +export function envStatePath(path: string, name: string): Promise { + return offline(async () => { + const profile = objectField(JSON.parse(await readFile(path, "utf8")), "environments")[name]; + const declared = isObject(profile) ? profile.state : undefined; + return typeof declared === "string" && declared !== "" ? declared : undefined; + }, undefined); +} + +/** + * The logical keys under management in a state file — the values `ct state rm` accepts. + * + * `type` narrows them to the keys of that resource type, because `ct state rm + * ` rejects a key of any other type: offering it would only complete into an error. + */ +export function stateKeys(path: string, type?: string): Promise { + return offline(async () => { + const resources = objectField(JSON.parse(await readFile(path, "utf8")), "resources"); + return Object.entries(resources) + .filter(([, entry]) => type === undefined || (isObject(entry) && entry.type === type)) + .map(([key]) => key); + }, []); +} + +/** The resource types the registry knows, straight from the registry so it cannot drift. */ +export function resourceTypes(): string[] { + return Object.keys(RESOURCES); +} + +/** + * `~` is the shell's notion, not the filesystem's. The installed hooks turn off the + * shell's own filename fallback (`complete -F` without `-o default`, `complete -f`), so + * if `ct` does not resolve the tilde itself, `--backup-dir ~/b` offers nothing at + * all. Only the directory being read is expanded — the candidates keep the `~/` the + * user typed, so the shell's prefix filter still matches them. + */ +function expandTilde(path: string): string { + if (path === "~") return homedir(); + return path.startsWith("~/") ? join(homedir(), path.slice(2)) : path; +} + +/** + * Filesystem candidates for a partially typed path. + * + * The shells filter the returned list against the word being typed, so the entries + * must carry the directory prefix the user already typed. Dot-entries are offered + * only once the user has typed a dot, matching what every shell does by default. + */ +export function paths(partial: string, kind: "file" | "directory"): Promise { + return offline(async () => { + const cut = partial.lastIndexOf("/"); + const prefix = cut === -1 ? "" : partial.slice(0, cut + 1); + const base = partial.slice(cut + 1); + const entries = await readdir(expandTilde(prefix === "" ? "." : prefix), { withFileTypes: true }); + return entries + .filter((entry) => base.startsWith(".") || !entry.name.startsWith(".")) + .filter((entry) => kind === "file" || entry.isDirectory()) + .map((entry) => `${prefix}${entry.name}${entry.isDirectory() ? "/" : ""}`); + }, []); +} diff --git a/src/index.ts b/src/index.ts index eab581c..4b11694 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,9 @@ import { refreshCommand } from "./commands/refresh.js"; import { planCommand } from "./commands/plan.js"; import { applyCommand } from "./commands/apply.js"; import { destroyCommand } from "./commands/destroy.js"; +import { completionCommand } from "./commands/completion.js"; import { plannedCommands } from "./commands/placeholders.js"; +import { isCompletionRequest, serveCompletionRequest } from "./completion/shell.js"; import { isMainModule } from "./isMain.js"; import { error, formatError } from "./ui.js"; @@ -34,6 +36,7 @@ export function buildProgram(): Command { program.addCommand(planCommand()); program.addCommand(applyCommand()); program.addCommand(destroyCommand()); + program.addCommand(completionCommand()); for (const cmd of plannedCommands()) { program.addCommand(cmd); } @@ -43,6 +46,12 @@ export function buildProgram(): Command { async function main(): Promise { const program = buildProgram(); + // A Tab keypress re-enters `ct` with the shell hook's plumbing flags (#132). Answer + // from the command tree and exit before Commander ever sees them. + if (isCompletionRequest(process.argv)) { + serveCompletionRequest(program); + return; + } try { await program.parseAsync(process.argv); } catch (err) { diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 7917e96..79dc4eb 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -4,7 +4,9 @@ import { buildProgram } from "../src/index.js"; describe("ct program", () => { it("registers the core command surface", () => { const names = buildProgram().commands.map((c) => c.name()); - expect(names).toEqual(expect.arrayContaining(["auth", "get", "adopt", "plan", "apply", "destroy"])); + expect(names).toEqual( + expect.arrayContaining(["auth", "get", "adopt", "plan", "apply", "destroy", "completion"]), + ); }); it("exposes auth subcommands", () => { diff --git a/tests/completion.test.ts b/tests/completion.test.ts new file mode 100644 index 0000000..baf36c4 --- /dev/null +++ b/tests/completion.test.ts @@ -0,0 +1,287 @@ +/** + * Shell completion (#132). + * + * `ct` installs a shell hook that calls back into `ct` on every Tab, so the thing + * worth testing is not a generated script but the answer this process gives for a + * command line. Two properties are load-bearing: + * + * - **Completion is offline.** It may read the local config repo and nothing else — + * no ChurchTools call, no credential, no `prepareEnv`. The session module is mocked + * to throw so that any accidental route to the network fails the suite. + * - **Completion never errors and never hangs.** A missing or malformed file is the + * normal state of a config repo mid-edit; it must degrade to no candidates. + */ +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Command, Option } from "commander"; + +const authedSession = vi.fn(async () => { + throw new Error("completion must never contact ChurchTools"); +}); +vi.mock("../src/api/session.js", () => ({ authedSession })); + +const { completionCandidates, splitCompletionLine } = await import("../src/completion/candidates.js"); +const { COMPLETION_SHELLS, completionScript, isCompletionRequest } = + await import("../src/completion/shell.js"); +const { buildProgram } = await import("../src/index.js"); + +/** A stand-in for the real tree, so the structural assertions do not move with the CLI. */ +function representativeProgram(): Command { + const permissions = new Command("permissions") + .description("Render permission reports") + .option("--by-subject", "group permissions by subject") + .option("-o, --output ", "write the report to a file") + .addOption(new Option("--format ", "output format").choices(["json", "markdown"])); + const program = new Command("ct").option("-e, --env ", "environment profile from ct.envs.json"); + program.addCommand(new Command("report").description("Generate reports").addCommand(permissions)); + return program; +} + +/** Complete the given command line, splitting it the way a shell hook would. */ +async function complete(program: Command, line: string): Promise { + const tokens = line.split(/\s+/).filter(Boolean); + // The shells report the index of the word under the cursor; a trailing space means + // a new, empty word, which is one index past the last typed token. + const fragment = /\s$/.test(line) ? tokens.length : tokens.length - 1; + const { words, partial } = splitCompletionLine(line, fragment); + return completionCandidates(program, words, partial); +} + +describe("completion candidates", () => { + it("offers top-level commands", async () => { + expect(await complete(representativeProgram(), "ct ")).toEqual(["report", "help"]); + }); + + it("descends into nested subcommands", async () => { + expect(await complete(representativeProgram(), "ct report ")).toContain("permissions"); + }); + + it("offers a command's options once a dash is typed", async () => { + const candidates = await complete(representativeProgram(), "ct report permissions -"); + expect(candidates).toEqual(expect.arrayContaining(["--by-subject", "-o", "--output", "--format"])); + }); + + it("offers the enumerated values of an option", async () => { + const candidates = await complete(representativeProgram(), "ct report permissions --format "); + expect(candidates).toEqual(["json", "markdown"]); + }); + + it("offers the enumerated values of an argument", async () => { + expect(await complete(buildProgram(), "ct completion ")).toEqual([...COMPLETION_SHELLS]); + }); + + it("does not mistake an option value for a positional word", async () => { + // `markdown` must be consumed as --format's value, so this still completes the + // command's own arguments rather than treating it as one. + const candidates = await complete(representativeProgram(), "ct report permissions --format markdown "); + expect(candidates).toEqual([]); + }); + + it("reflects the real command tree, nested commands included", async () => { + const program = buildProgram(); + expect(await complete(program, "ct ")).toEqual( + expect.arrayContaining(["auth", "state", "plan", "apply", "destroy", "completion"]), + ); + expect(await complete(program, "ct auth ")).toEqual(expect.arrayContaining(["login", "logout"])); + expect(await complete(program, "ct state ")).toEqual(expect.arrayContaining(["list", "rm"])); + expect(await complete(program, "ct apply -")).toEqual(expect.arrayContaining(["--auto-approve"])); + }); +}); + +describe("dynamic completion", () => { + let dir: string; + const originalEnvs = process.env.CT_ENVS; + const originalState = process.env.CT_STATE; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "ct-completion-")); + delete process.env.CT_ENVS; + delete process.env.CT_STATE; + }); + + afterEach(async () => { + process.env.CT_ENVS = originalEnvs; + process.env.CT_STATE = originalState; + if (originalEnvs === undefined) delete process.env.CT_ENVS; + if (originalState === undefined) delete process.env.CT_STATE; + await rm(dir, { recursive: true, force: true }); + }); + + it("completes --env with the environments this config repo actually declares", async () => { + process.env.CT_ENVS = join(dir, "ct.envs.json"); + await writeFile( + process.env.CT_ENVS, + JSON.stringify({ + environments: { + dev: { host: "https://x-dev.church.tools" }, + prod: { host: "https://x.church.tools" }, + }, + }), + ); + expect(await complete(buildProgram(), "ct plan --env ")).toEqual(["dev", "prod"]); + }); + + it("completes `state rm` with the keys actually under management, of the typed type", async () => { + process.env.CT_STATE = join(dir, "ct-state.json"); + await writeFile( + process.env.CT_STATE, + JSON.stringify({ + version: 1, + host: "https://x.church.tools", + resources: { mainz: { type: "campus", id: 1 }, youth: { type: "group", id: 2 } }, + }), + ); + // `youth` is a group; `state rm campus youth` is refused, so it is not offered. + expect(await complete(buildProgram(), "ct state rm campus ")).toEqual(["mainz"]); + expect(await complete(buildProgram(), "ct state rm group ")).toEqual(["youth"]); + }); + + it("completes `state rm` from the state file the env profile declares", async () => { + // The profile's own `state` field is what the command will edit, so it is what + // completion has to read — not the `ct-state..json` convention it overrides. + process.env.CT_ENVS = join(dir, "ct.envs.json"); + await writeFile( + process.env.CT_ENVS, + JSON.stringify({ + environments: { dev: { host: "https://x-dev.church.tools", state: "declared.json" } }, + }), + ); + await writeFile( + join(dir, "declared.json"), + JSON.stringify({ + version: 1, + host: "https://x-dev.church.tools", + resources: { mainz: { type: "campus", id: 1 } }, + }), + ); + const cwd = process.cwd(); + process.chdir(dir); + try { + expect(await complete(buildProgram(), "ct state rm --env dev campus ")).toEqual(["mainz"]); + } finally { + process.chdir(cwd); + } + }); + + it("completes `adopt` with the resource types the registry knows", async () => { + expect(await complete(buildProgram(), "ct adopt ")).toEqual( + expect.arrayContaining(["campus", "group", "group-role"]), + ); + }); + + it("completes a path option under the home directory", async () => { + // The hooks turn off the shell's own filename fallback, so `ct` must expand `~` + // itself or a tilde path completes to nothing at all. + const candidates = await complete(buildProgram(), "ct apply --backup-dir ~/"); + expect(candidates.length).toBeGreaterThan(0); + for (const candidate of candidates) expect(candidate.startsWith("~/")).toBe(true); + }); + + it("completes `state rm` with the resource types the registry knows", async () => { + expect(await complete(buildProgram(), "ct state rm ")).toEqual( + expect.arrayContaining(["campus", "group", "group-role"]), + ); + }); + + it("completes a path option from the filesystem", async () => { + await writeFile(join(dir, "ct.config.ts"), ""); + const candidates = await complete(buildProgram(), `ct plan --config ${dir}/ct`); + expect(candidates).toContain(join(dir, "ct.config.ts")); + }); + + it("degrades to no candidates when a source file is missing or malformed", async () => { + process.env.CT_ENVS = join(dir, "absent.json"); + expect(await complete(buildProgram(), "ct plan --env ")).toEqual([]); + + process.env.CT_ENVS = join(dir, "broken.json"); + await writeFile(process.env.CT_ENVS, "{ not json"); + expect(await complete(buildProgram(), "ct plan --env ")).toEqual([]); + }); + + it("never contacts ChurchTools", () => { + expect(authedSession).not.toHaveBeenCalled(); + }); +}); + +describe("splitCompletionLine", () => { + it("treats the last word as partial when the cursor sits on it", () => { + expect(splitCompletionLine("ct sta", 1)).toEqual({ words: ["ct"], partial: "sta" }); + }); + + it("treats the cursor after a space as a new, empty word", () => { + expect(splitCompletionLine("ct state ", 2)).toEqual({ words: ["ct", "state"], partial: "" }); + }); + + it("splits at the cursor, not at the end of the line", () => { + // The line the shells hand over is the whole buffer, including what is typed after + // the cursor; only `fragment` says which word Tab was pressed on. + expect(splitCompletionLine("ct state rm campus mainz", 3)).toEqual({ + words: ["ct", "state", "rm"], + partial: "campus", + }); + }); + + it("falls back to the shape of the line when the index overshoots it", () => { + // bash derives the index from COMP_CWORD, which breaks on more characters than the + // hook's colon fudge accounts for, so it can arrive past the end of the line. + expect(splitCompletionLine("ct plan --config a", 9)).toEqual({ + words: ["ct", "plan", "--config"], + partial: "a", + }); + expect(splitCompletionLine("ct plan --config a ", 9)).toEqual({ + words: ["ct", "plan", "--config", "a"], + partial: "", + }); + }); +}); + +describe("the installed shell hook", () => { + it("delegates back to ct rather than baking in a generated script", () => { + for (const shell of COMPLETION_SHELLS) { + const script = completionScript(buildProgram(), shell); + expect(script).toContain("--compgen"); + expect(script).toContain("ct"); + // The whole hook stays small because the shell dialect lives in the library — + // apart from the handful of bash-completion stand-ins prepended for stock bash. + expect(script.split("\n").length).toBeLessThan(60); + } + }); + + it("uses each shell's own registration syntax", () => { + expect(completionScript(buildProgram(), "zsh")).toContain("compdef"); + expect(completionScript(buildProgram(), "bash")).toContain("complete -F"); + expect(completionScript(buildProgram(), "fish")).toContain("complete -f -c ct"); + }); + + it("does not depend on the bash-completion package being installed", () => { + // Stock macOS bash has neither helper; without stand-ins every Tab would print + // two "command not found" lines into the command line. + const script = completionScript(buildProgram(), "bash"); + for (const helper of ["_get_comp_words_by_ref", "__ltrim_colon_completions"]) { + // Defined only when absent, so a machine with bash-completion keeps the real one. + expect(script).toContain(`if ! declare -F ${helper} >/dev/null 2>&1; then`); + } + }); + + it("recognises a Tab keypress by the hook's plumbing flag", () => { + expect(isCompletionRequest(["node", "ct", "--compbash", "--compgen", "1", "ct", "ct "])).toBe(true); + expect(isCompletionRequest(["node", "ct", "plan"])).toBe(false); + }); + + it("prints the hook without credentials or a ChurchTools request", async () => { + let output = ""; + const write = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output += String(chunk); + return true; + }); + try { + await buildProgram().parseAsync(["completion", "bash"], { from: "user" }); + } finally { + write.mockRestore(); + } + expect(output).toContain("complete -F _ct_completion ct"); + expect(authedSession).not.toHaveBeenCalled(); + }); +});