From d67222271b686a912b10b006d5e7b5dddf1a4533 Mon Sep 17 00:00:00 2001 From: Laura Trotta Date: Tue, 19 May 2026 15:22:18 +0200 Subject: [PATCH 01/10] add completion --- README.md | 58 +++++ src/cli.ts | 33 ++- src/completion/argv-aliases.ts | 33 +++ src/completion/command.ts | 71 ++++++ src/completion/complete.ts | 243 ++++++++++++++++++ src/completion/completers/context-names.ts | 47 ++++ src/completion/enumerate.ts | 227 +++++++++++++++++ src/completion/index.ts | 36 +++ src/completion/registry.ts | 29 +++ src/completion/shells/bash.ts | 66 +++++ src/completion/shells/fish.ts | 53 ++++ src/completion/shells/zsh.ts | 64 +++++ test/completion-cli.test.ts | 162 ++++++++++++ test/completion/argv-aliases.test.ts | 62 +++++ test/completion/command.test.ts | 88 +++++++ test/completion/complete.test.ts | 274 +++++++++++++++++++++ test/completion/context-names.test.ts | 137 +++++++++++ test/completion/enumerate.test.ts | 263 ++++++++++++++++++++ test/completion/registry.test.ts | 28 +++ test/completion/wrappers.test.ts | 111 +++++++++ 20 files changed, 2076 insertions(+), 9 deletions(-) create mode 100644 src/completion/argv-aliases.ts create mode 100644 src/completion/command.ts create mode 100644 src/completion/complete.ts create mode 100644 src/completion/completers/context-names.ts create mode 100644 src/completion/enumerate.ts create mode 100644 src/completion/index.ts create mode 100644 src/completion/registry.ts create mode 100644 src/completion/shells/bash.ts create mode 100644 src/completion/shells/fish.ts create mode 100644 src/completion/shells/zsh.ts create mode 100644 test/completion-cli.test.ts create mode 100644 test/completion/argv-aliases.test.ts create mode 100644 test/completion/command.test.ts create mode 100644 test/completion/complete.test.ts create mode 100644 test/completion/context-names.test.ts create mode 100644 test/completion/enumerate.test.ts create mode 100644 test/completion/registry.test.ts create mode 100644 test/completion/wrappers.test.ts diff --git a/README.md b/README.md index 95dd02e7..06c47525 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,64 @@ npx -y @elastic/cli --help > source ~/.bashrc > ``` +## Shell completion + +`elastic completion ` prints a wrapper script that hooks the CLI into +your shell's tab-completion system. Bash, Zsh, and Fish are supported. + +The wrapper is dynamic: each `` shells out to `elastic` to ask which +candidates apply, so completions stay in sync with the installed CLI version +and include context names from your config file. + +### Bash + +```bash +# System-wide (writable by root): +elastic completion bash | sudo tee /etc/bash_completion.d/elastic > /dev/null + +# Per-user (no sudo required): +mkdir -p ~/.local/share/bash-completion/completions +elastic completion bash > ~/.local/share/bash-completion/completions/elastic +``` + +Then open a new shell. + +### Zsh + +```bash +# Drop the script into the first directory in $fpath: +elastic completion zsh > "${fpath[1]}/_elastic" + +# Make sure compinit is enabled in ~/.zshrc: +autoload -Uz compinit && compinit +``` + +Or, for a one-shot install in the current session: + +```bash +eval "$(elastic completion zsh)" +``` + +### Fish + +```fish +elastic completion fish > ~/.config/fish/completions/elastic.fish +``` + +Fish picks up new completion files automatically. + +### What gets completed + +- Top-level commands and groups (`stack`, `cloud`, `docs`, `config`, …) +- Nested subcommands at every depth +- Long option flags (`--json`, `--use-context`, command-specific options) +- Context names for `--use-context` (read from the active config file) +- The `es` / `kb` aliases work identically to the canonical `stack es` / + `stack kb` forms + +Completion respects the active `commands.allowed` / `commands.blocked` policy: +commands you have restricted yourself out of do not appear as candidates. + ## Configuration The CLI looks for a config file in your home directory. The following file names diff --git a/src/cli.ts b/src/cli.ts index f0577aef..6617be82 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,8 @@ import { loadConfig, type LoadConfigResult } from './config/loader.ts' import { BUILT_IN_PROFILES, type BuiltInProfile } from './config/profiles.ts' import { setResolvedConfig } from './config/store.ts' import { renderLogo } from './lib/logo.ts' +import { rewriteTopLevelAliases } from './completion/argv-aliases.ts' +import { registerCompletionCommands, COMPLETION_COMMAND_NAMES } from './completion/index.ts' // x-release-please-start-version const VERSION = '0.1.1'; @@ -39,6 +41,11 @@ let earlyConfig: LoadConfigResult | undefined program.hook('preAction', async (thisCommand, actionCommand) => { if (actionCommand.name() === 'version') return + // Shell completion commands must not depend on a working config: the user + // installs them before any context exists, and tab-completion errors must + // never poison the shell. They do their own (best-effort) config loading + // inside their handlers when they need context names. + if (COMPLETION_COMMAND_NAMES.includes(actionCommand.name())) return if (actionCommand.parent?.name() === 'docs') return if (actionCommand.parent?.name() === 'sanitize') return // `config` commands author the config file itself — loading it would be @@ -78,6 +85,13 @@ const versionCmd = defineCommand({ }) program.addCommand(versionCmd) +// Shell completion: `elastic completion ` prints a wrapper script and +// the hidden `__complete` command answers tab-completion callbacks from that +// wrapper. Both are config-free; the preAction hook above skips them. +for (const cmd of registerCompletionCommands()) { + program.addCommand(cmd) +} + // Lazily load command trees only when the relevant top-level subcommand is actually // invoked. For all other invocations (including `elastic --help`), a lightweight stub // is registered so the group appears in help text without paying the cost of loading @@ -89,13 +103,10 @@ let firstArg = operands[0] // shortcuts for `elastic stack es ...` and `elastic stack kb ...`. // argv is rewritten before Commander parses so all routing and dot-paths remain // consistent (e.g. policy entries still use `stack.es.*`). -if (firstArg === 'es' || firstArg === 'elasticsearch') { - const idx = process.argv.indexOf(firstArg, 2) - if (idx !== -1) process.argv.splice(idx, 0, 'stack') - operands.splice(0, 0, 'stack') - firstArg = 'stack' -} else if (firstArg === 'kb' || firstArg === 'kibana') { - const idx = process.argv.indexOf(firstArg, 2) +const aliasRewritten = rewriteTopLevelAliases(operands) +if (aliasRewritten.length !== operands.length) { + const original = firstArg! + const idx = process.argv.indexOf(original, 2) if (idx !== -1) process.argv.splice(idx, 0, 'stack') operands.splice(0, 0, 'stack') firstArg = 'stack' @@ -178,10 +189,14 @@ if (firstArg === 'sanitize') { } // Load config early so --help can hide blocked commands. Skip for commands -// that don't need config (e.g. `version`, `sanitize`, or `config` which authors the file) +// that don't need config (e.g. `version`, `sanitize`, `config` which authors +// the file, and the completion subsystem which runs before any context exists) // to avoid unnecessary file I/O and a confusing "no config found" path. // The result is cached in earlyConfig so the preAction hook can reuse it. -if (firstArg !== 'version' && firstArg !== 'config' && firstArg !== 'sanitize') { +const SKIP_EARLY_CONFIG = new Set([ + 'version', 'config', 'sanitize', ...COMPLETION_COMMAND_NAMES, +]) +if (firstArg == null || !SKIP_EARLY_CONFIG.has(firstArg)) { // Parse --profile early (before Commander's full parse) so the early config load // and hideBlockedCommands can apply the correct profile-based allow-list to --help. const profileArgIdx = process.argv.indexOf('--command-profile') diff --git a/src/completion/argv-aliases.ts b/src/completion/argv-aliases.ts new file mode 100644 index 00000000..351cbf39 --- /dev/null +++ b/src/completion/argv-aliases.ts @@ -0,0 +1,33 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Top-level aliases that the CLI rewrites to their `stack`-prefixed form. + * + * `elastic es ...` and `elastic kb ...` (plus the long forms) are user-facing + * shortcuts; internally they route through `stack es` / `stack kb` so policy + * dot-paths (e.g. `stack.es.search`) and routing logic stay uniform. + */ +const TOP_LEVEL_ALIASES = new Set(['es', 'elasticsearch', 'kb', 'kibana']) + +/** + * Returns a new array with `'stack'` prepended when the first word is one of + * the recognised top-level aliases (`es`, `elasticsearch`, `kb`, `kibana`). + * In every other case the input is returned as a shallow copy unchanged. + * + * Pure: never mutates its argument. Use this from both the main CLI entry + * point (to keep argv consistent for `program.parseAsync`) and the + * `__complete` handler (to walk the right subtree when the user types + * `elastic es `). + * + * @param words - command words as they appear after the program name + */ +export function rewriteTopLevelAliases (words: readonly string[]): string[] { + const first = words[0] + if (first != null && TOP_LEVEL_ALIASES.has(first)) { + return ['stack', ...words] + } + return [...words] +} diff --git a/src/completion/command.ts b/src/completion/command.ts new file mode 100644 index 00000000..d9477b5b --- /dev/null +++ b/src/completion/command.ts @@ -0,0 +1,71 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Public `elastic completion ` command: prints the per-shell wrapper + * script that hooks the CLI into the user's shell completion system. + * + * The wrapper is dynamic — every tab press calls back into `elastic + * __complete` to ask for candidates. See `complete.ts` for the callback + * protocol and `shells/*.ts` for the per-shell wrappers themselves. + */ + +import { defineCommand } from '../factory.ts' +import type { OpaqueCommandHandle } from '../factory.ts' +import { bashWrapper } from './shells/bash.ts' +import { zshWrapper } from './shells/zsh.ts' +import { fishWrapper } from './shells/fish.ts' + +/** Maps a shell name to a function that produces its wrapper script. */ +const WRAPPERS: Readonly string>> = Object.freeze({ + bash: bashWrapper, + zsh: zshWrapper, + fish: fishWrapper, +}) + +/** Shells supported by `elastic completion `, in declaration order. */ +export const SUPPORTED_SHELLS: readonly string[] = Object.freeze(Object.keys(WRAPPERS)) + +/** + * Builds the `elastic completion ` command. + * + * Behaviour: + * - `elastic completion bash` → prints the bash wrapper to stdout + * - `elastic completion zsh` → prints the zsh wrapper to stdout + * - `elastic completion fish` → prints the fish wrapper to stdout + * - `elastic completion ` → structured error, exit 1 + * - `elastic completion --json ` → JSON `{shell, script}` on stdout + */ +export function buildCompletionCommand (): OpaqueCommandHandle { + return defineCommand({ + name: 'completion', + description: `Print a shell completion script (${SUPPORTED_SHELLS.join(', ')})`, + positionalArg: { + name: 'shell', + description: `target shell (${SUPPORTED_SHELLS.join(', ')})`, + required: true, + }, + handler: (parsed) => { + // `shell` is a required positional, so Commander rejects missing input + // before the handler runs; the non-null assertion documents this + // invariant and removes a dead `?? ''` branch from coverage. + const shell = parsed.arg! + const wrapper = WRAPPERS[shell] + if (wrapper == null) { + return { + error: { + code: 'unknown_shell', + message: `unknown shell "${shell}". Supported: ${SUPPORTED_SHELLS.join(', ')}`, + }, + } + } + return { shell, script: wrapper() } + }, + formatOutput: (result) => { + const r = result as { shell: string; script: string } + return r.script + }, + }) +} diff --git a/src/completion/complete.ts b/src/completion/complete.ts new file mode 100644 index 00000000..56423744 --- /dev/null +++ b/src/completion/complete.ts @@ -0,0 +1,243 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Hidden `__complete` command: produces shell completion candidates for the + * partially-typed `elastic` command line passed after `--`. + * + * Designed to be invoked by the shell wrappers emitted by + * `elastic completion `. The handler: + * + * 1. Reads `process.argv` directly, slicing from the first `--` so that + * incomplete words like `--use-context` are preserved verbatim (Commander + * would otherwise treat them as options to `__complete` itself). + * 2. Rewrites top-level aliases (`es`/`kb` → `stack es`/`stack kb`) via + * {@link rewriteTopLevelAliases} so completion mirrors the runtime + * routing of `cli.ts`. + * 3. Builds a transient Commander tree containing only the subtree(s) + * implied by the first rewritten word. Untouched groups are registered + * as stubs so they still appear as top-level candidates. + * 4. Best-effort: applies any `commands.blocked` policy from the active + * config so that user-restricted commands do not appear as candidates. + * Configuration failures are swallowed. + * 5. Delegates to {@link enumerate} for the actual matching logic, then + * serialises the result as `candidate\n...\n:DIRECTIVE\n` on stdout. + * + * Exit code is always 0: a broken completion path must never poison the + * shell. Errors trigger an empty candidate set with `DIRECTIVE_NO_FILE_COMP`. + */ + +import { Command } from 'commander' +import { defineGroup, hideBlockedCommands } from '../factory.ts' +import type { OpaqueCommandHandle } from '../factory.ts' +import { rewriteTopLevelAliases } from './argv-aliases.ts' +import { enumerate, DIRECTIVE_NO_FILE_COMP } from './enumerate.ts' +import { defaultRegistry } from './registry.ts' +import { loadConfig } from '../config/loader.ts' + +/** Words recognised as the user-facing form of the `stack es` subtree. */ +const ES_ALIASES = new Set(['es', 'elasticsearch']) +/** Words recognised as the user-facing form of the `stack kb` subtree. */ +const KB_ALIASES = new Set(['kb', 'kibana']) + +/** + * Constructs the `stack` group with conditional loading of the es/kb subtrees. + * + * `secondWord` selects which child to deep-load: + * - exact `es`/`elasticsearch` → load es, kb stays a stub + * - exact `kb`/`kibana` → load kb, es stays a stub + * - anything else (incl. empty) → both remain stubs + * + * Stubs still expose their names + aliases so `elastic stack ` lists + * the expected children without paying the cost of loading any schemas. + */ +async function buildStackGroup (secondWord: string | undefined): Promise { + let esGroup: OpaqueCommandHandle + let kbGroup: OpaqueCommandHandle + + if (secondWord != null && ES_ALIASES.has(secondWord)) { + const { registerEsCommandsLazy } = await import('../es/register.ts') + esGroup = await registerEsCommandsLazy() + esGroup.alias('elasticsearch') + } else { + esGroup = defineGroup({ name: 'es', description: 'Interact with the Elasticsearch API' }) + esGroup.alias('elasticsearch') + } + + if (secondWord != null && KB_ALIASES.has(secondWord)) { + const { registerKbCommandsLazy } = await import('../kb/register.ts') + kbGroup = await registerKbCommandsLazy() + kbGroup.alias('kibana') + } else { + kbGroup = defineGroup({ name: 'kb', description: 'Interact with the Kibana API' }) + kbGroup.alias('kibana') + } + + return defineGroup( + { name: 'stack', description: 'Interact with Elastic Stack components (Elasticsearch, Kibana, Fleet)' }, + esGroup, + kbGroup, + ) +} + +/** + * Builds a transient Commander tree suitable for completion enumeration. + * + * The tree always contains every visible top-level group (as stubs by + * default), so `elastic ` always shows the full set of options. Subtrees + * are deep-loaded only when the first rewritten word matches their root, to + * keep the common no-arg-tab case lightweight. + * + * Exported for tests so the lazy-loading logic can be exercised without + * spawning the CLI. + * + * @param rewrittenWords - words after passing through {@link rewriteTopLevelAliases} + */ +export async function buildCompletionTree (rewrittenWords: readonly string[]): Promise { + const root = new Command('elastic') + // Root globals: mirror the option set declared in src/cli.ts so flag + // completion (`--js` → `--json`) sees the same surface area at every + // depth as the real invocation path. + root.option('--config-file ', 'path to a config file') + root.option('--use-context ', 'override the active context from the config file') + root.option('--command-profile ', 'restrict available commands to a deployment profile') + root.option('--json', 'output as JSON') + root.option('--output-fields ', 'comma-separated list of fields to include in output') + root.option('--output-template ', 'Mustache-like template for custom text output') + + // `version` is registered as a stub so it appears as a top-level candidate. + // The real version handler is wired in src/cli.ts; for enumeration we only + // need the name to be discoverable. + root.addCommand(defineGroup({ name: 'version', description: 'Print the elastic CLI version' })) + + const firstWord = rewrittenWords[0] + const secondWord = rewrittenWords[1] + + // `stack` — conditional deep-load of es/kb based on second word. + root.addCommand(await buildStackGroup(firstWord === 'stack' ? secondWord : undefined)) + + // Top-level es/kb aliases — purely for visibility at the root level. + // Navigation through them is handled via the alias rewrite (which turns + // `es ...` into `stack es ...` before walking). + const esTopAlias = defineGroup({ name: 'es', description: 'Interact with the Elasticsearch API' }) + esTopAlias.alias('elasticsearch') + root.addCommand(esTopAlias) + + const kbTopAlias = defineGroup({ name: 'kb', description: 'Interact with the Kibana API' }) + kbTopAlias.alias('kibana') + root.addCommand(kbTopAlias) + + // `cloud` — deep-load on exact match, stub otherwise. + if (firstWord === 'cloud') { + const { registerCloudCommands } = await import('../cloud/register.ts') + root.addCommand(registerCloudCommands()) + } else { + root.addCommand(defineGroup({ name: 'cloud', description: 'Manage Elastic Cloud (hosted deployments and serverless projects)' })) + } + + // `docs` — deep-load on exact match, stub otherwise. + if (firstWord === 'docs') { + const { registerDocsCommands } = await import('../docs/register.ts') + root.addCommand(registerDocsCommands()) + } else { + root.addCommand(defineGroup({ name: 'docs', description: 'Search, read, and ask questions about Elastic documentation' })) + } + + // `config` — deep-load on exact match, stub otherwise. + if (firstWord === 'config') { + const { registerConfigCommands } = await import('../config/commands.ts') + root.addCommand(registerConfigCommands()) + } else { + root.addCommand(defineGroup({ name: 'config', description: 'Author and maintain the elastic config file' })) + } + + // `sanitize` is synchronous + lightweight — always load it. + const { registerSanitizeCommands } = await import('../sanitize/register.ts') + root.addCommand(registerSanitizeCommands()) + + // `completion` placeholder so the command appears as a top-level candidate. + // The real handler is wired in src/cli.ts; for enumeration we only need + // the name to be present. + root.addCommand(defineGroup({ name: 'completion', description: 'Print a shell completion script' })) + + return root +} + +/** + * Sink that receives the completion protocol output. Tests provide a buffer- + * backed writer to avoid monkey-patching `process.stdout`, which would also + * swallow the test runner's own progress output. + */ +export type CompletionWriter = (chunk: string) => void + +const defaultWriter: CompletionWriter = (chunk) => { process.stdout.write(chunk) } + +/** + * Runs the completion pipeline for the given words and emits the result via + * `writer` in the protocol the shell wrappers expect (`candidate\n`...`:N\n`). + * + * Exported so tests can assert on the serialised output without spawning the + * CLI. Production callers omit `writer` to fall through to `process.stdout`. + */ +export async function handleComplete ( + words: readonly string[], + writer: CompletionWriter = defaultWriter, +): Promise { + try { + const rewritten = rewriteTopLevelAliases(words) + const root = await buildCompletionTree(rewritten) + + // Apply blocked-command policy if a config is readable. Any failure here + // (missing config, invalid YAML, network-bound expression) is silently + // ignored — completion availability must not depend on a working config. + try { + const config = await loadConfig({}) + if (config.ok && config.value.commands != null) { + hideBlockedCommands(root, config.value.commands) + } + } catch { /* swallowed by design */ } + + const result = await enumerate(root, rewritten, defaultRegistry) + + let out = '' + for (const c of result.candidates) out += c + '\n' + out += `:${result.directive}\n` + writer(out) + } catch { + writer(`:${DIRECTIVE_NO_FILE_COMP}\n`) + } +} + +/** + * Constructs the hidden `__complete` Commander command. + * + * The command is registered with `_hidden` so it does not appear in help + * output or in its own completion candidates. It accepts a variadic + * positional `[words...]` so `elastic __complete -- ` passes + * the raw arguments through to {@link handleComplete}; the `--` separator + * disables option parsing on Commander's side. + */ +export function buildCompleteCommand (): Command { + const cmd = new Command('__complete') + cmd.description('(internal) Compute shell completion candidates') + cmd.allowUnknownOption(true) + cmd.allowExcessArguments(true) + cmd.argument('[words...]', 'words to complete (passed after --)') + // Commander does not expose `_hidden` in its public typings, but the + // factory and help renderer both use it as the hidden discriminator. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(cmd as unknown as any)._hidden = true + + cmd.action(async (words: string[] | undefined) => { + // Prefer the raw argv slice after `--` so option-like tokens + // (`--use-context`) are preserved verbatim even if a future Commander + // version changes how positional args interact with allowUnknownOption. + const argv = process.argv + const dashIdx = argv.indexOf('--') + const tokens = dashIdx !== -1 ? argv.slice(dashIdx + 1) : (words ?? []) + await handleComplete(tokens) + }) + return cmd +} diff --git a/src/completion/completers/context-names.ts b/src/completion/completers/context-names.ts new file mode 100644 index 00000000..58650697 --- /dev/null +++ b/src/completion/completers/context-names.ts @@ -0,0 +1,47 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Dynamic completer for the `--use-context ` flag. + * + * Reads the active config file (env var or home-directory discovery) using a + * structural-only parse (no expression resolution, no service-block + * validation). Returns the keys of the `contexts` map. + * + * Completion MUST NOT propagate errors: missing files, malformed YAML, + * permission errors, or unresolved `$(...)` expressions all return `[]`. + */ + +import { discoverConfigFile, loadConfigFile } from '../../config/loader.ts' +import { StructuralConfigSchema } from '../../config/schema.ts' + +const ENV_CONFIG_FILE = 'ELASTIC_CLI_CONFIG_FILE' + +/** + * Resolves the list of context names from the active config file. + * + * Resolution precedence mirrors {@link loadConfig}: + * 1. `$ELASTIC_CLI_CONFIG_FILE` if set and non-empty + * 2. Discovery in the user's home directory + * + * Returns `[]` on any failure — the function never throws. + */ +export async function completeContextNames (): Promise { + try { + const envPath = process.env[ENV_CONFIG_FILE] + const path = envPath != null && envPath.length > 0 + ? envPath + : await discoverConfigFile() + if (path == null) return [] + + const raw = await loadConfigFile(path) + const parsed = StructuralConfigSchema.safeParse(raw) + if (!parsed.success) return [] + + return Object.keys(parsed.data.contexts) + } catch { + return [] + } +} diff --git a/src/completion/enumerate.ts b/src/completion/enumerate.ts new file mode 100644 index 00000000..fad7a0a8 --- /dev/null +++ b/src/completion/enumerate.ts @@ -0,0 +1,227 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Command, Option } from 'commander' + +/** + * Bit-field directives returned alongside completion candidates. + * + * Borrowed from Cobra's `ShellCompDirective` so shell wrappers can translate + * a single integer into per-shell completion settings. Each wrapper script + * masks the relevant bits and applies the corresponding shell option. + */ +export const DIRECTIVE_NONE = 0 +/** Do not insert a trailing space after the candidate (used for `--flag=` form). */ +export const DIRECTIVE_NO_SPACE = 1 << 0 +/** Suppress the shell's default file/directory completion fallback. */ +export const DIRECTIVE_NO_FILE_COMP = 1 << 1 +/** Reserved for future use: filter the shell's file completion by extension. */ +export const DIRECTIVE_FILTER_FILE_EXT = 1 << 2 + +/** Result returned by {@link enumerate}. */ +export interface CompletionResult { + /** Candidate strings, already prefix-filtered against the current incomplete word. */ + candidates: string[] + /** Bit-field of `DIRECTIVE_*` values. Always includes `DIRECTIVE_NO_FILE_COMP`. */ + directive: number +} + +/** Function that produces dynamic candidates (e.g. context names) for a flag. */ +export type DynamicCompleter = () => string[] | Promise + +/** + * Lookup table for dynamic completers keyed by flag long name (including `--`). + * + * Returning `undefined` for an unknown flag is mandatory: the completion path + * MUST NOT throw or block, so completers that are unavailable for the current + * environment simply do not register themselves. + */ +export interface DynamicCompleterRegistry { + get(flagLong: string): DynamicCompleter | undefined +} + +// Commander stores hidden state on an internal `_hidden` field that is not +// part of the public typings; mirror the discriminator the factory uses. +function isHidden (cmd: Command): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (cmd as unknown as any)._hidden === true +} + +/** + * Returns the visible child of `cmd` whose name OR alias equals `word`. + * Hidden children are skipped. Returns `undefined` when no match. + */ +function findChild (cmd: Command, word: string): Command | undefined { + for (const child of cmd.commands) { + if (isHidden(child)) continue + if (child.name() === word) return child + if (child.aliases().includes(word)) return child + } + return undefined +} + +/** + * Returns all visible child names + aliases of `cmd`. + * + * Aliases are exposed so users see both `es` and `elasticsearch` when typing + * `elastic stack `. Order follows the registration order of children. + */ +function collectChildren (cmd: Command): string[] { + const out: string[] = [] + for (const child of cmd.commands) { + if (isHidden(child)) continue + out.push(child.name()) + for (const alias of child.aliases()) out.push(alias) + } + return out +} + +/** + * Returns the union of `current`'s and `root`'s long flag names, plus `--help`. + * + * Root flags are always included so global options (e.g. `--json`, + * `--config-file`) are completable at every depth. Duplicates are removed. + */ +function collectFlags (current: Command, root: Command): string[] { + const out = new Set() + for (const c of new Set([current, root])) { + for (const opt of c.options as Option[]) { + if (opt.long != null) out.add(opt.long) + } + } + out.add('--help') + return Array.from(out) +} + +/** + * Returns true if `word` matches a known option that accepts a value. + * Used by the walk loop to skip past a flag's value token when descending. + */ +function optionTakesArg (current: Command, root: Command, word: string): boolean { + for (const c of new Set([current, root])) { + for (const opt of c.options as Option[]) { + if (opt.long === word || opt.short === word) { + return opt.required === true || opt.optional === true + } + } + } + return false +} + +function prefixFilter (candidates: readonly string[], prefix: string): string[] { + if (prefix === '') return [...candidates] + return candidates.filter((c) => c.startsWith(prefix)) +} + +/** + * Invokes a dynamic completer in an error-tolerant wrapper. + * + * Completion must never bubble exceptions out to the shell; on any failure + * we fall back to an empty candidate list so tab simply yields no suggestions. + */ +async function safeRun (fn: DynamicCompleter): Promise { + try { + const result = await fn() + return Array.isArray(result) ? result : [] + } catch { + return [] + } +} + +/** + * Computes completion candidates for a partially-typed `elastic` command line. + * + * Algorithm (in priority order): + * + * 1. Walk into the command tree from `root` along `words[0..n-2]`, skipping + * flag-shaped tokens (and their value tokens for options that take a value). + * The current word is `words[n-1]` (treated as `''` when `words` is empty). + * 2. If the current word has the `--flag=partial` shape AND `flag` is a + * registered dynamic flag, return that completer's candidates filtered by + * `partial`, with `DIRECTIVE_NO_SPACE` to keep the cursor adjacent. + * 3. If the current word starts with `--`, return matching long flags from the + * deepest command plus the root program (so globals are always visible). + * 4. If the immediately previous token is a registered dynamic flag (e.g. + * `--use-context`), return that completer's candidates filtered by the + * current word. + * 5. Otherwise, return the subcommand names and aliases of the deepest matched + * command/group, filtered by the current word. + * + * Result is always returned (never thrown). `directive` always carries + * `DIRECTIVE_NO_FILE_COMP` so the shell does not fall back to filename + * completion for our command tokens. + * + * @param root - top-level program command (with all loaded subtrees attached) + * @param words - command words after the program name; empty array is allowed + * @param registry - optional dynamic completer lookup (e.g. context names) + */ +export async function enumerate ( + root: Command, + words: readonly string[], + registry?: DynamicCompleterRegistry, +): Promise { + const arr = words.length === 0 ? [''] : Array.from(words) + const incomplete = arr[arr.length - 1] ?? '' + const completedWords = arr.slice(0, -1) + + let current: Command = root + for (let i = 0; i < completedWords.length; i++) { + const w = completedWords[i]! + if (w.startsWith('-')) { + if (!w.includes('=') && optionTakesArg(current, root, w) && i + 1 < completedWords.length) { + i++ + } + continue + } + const child = findChild(current, w) + if (child == null) break + current = child + } + + const previous = completedWords.length > 0 ? completedWords[completedWords.length - 1] : undefined + + if (incomplete.startsWith('--') && incomplete.includes('=')) { + const eqIdx = incomplete.indexOf('=') + const flag = incomplete.slice(0, eqIdx) + const partial = incomplete.slice(eqIdx + 1) + const completer = registry?.get(flag) + if (completer != null) { + const cands = await safeRun(completer) + return { + candidates: prefixFilter(cands, partial), + directive: DIRECTIVE_NO_FILE_COMP | DIRECTIVE_NO_SPACE, + } + } + return { candidates: [], directive: DIRECTIVE_NO_FILE_COMP } + } + + if (incomplete.startsWith('--')) { + const flags = collectFlags(current, root) + return { + candidates: prefixFilter(flags, incomplete), + directive: DIRECTIVE_NO_FILE_COMP, + } + } + + if (previous != null && previous.startsWith('--')) { + const completer = registry?.get(previous) + if (completer != null) { + const cands = await safeRun(completer) + return { + candidates: prefixFilter(cands, incomplete), + directive: DIRECTIVE_NO_FILE_COMP, + } + } + // Previous is a flag but no completer registered: yield no candidates, + // but stay in NO_FILE_COMP mode so the shell doesn't surprise the user. + return { candidates: [], directive: DIRECTIVE_NO_FILE_COMP } + } + + const cands = collectChildren(current) + return { + candidates: prefixFilter(cands, incomplete), + directive: DIRECTIVE_NO_FILE_COMP, + } +} diff --git a/src/completion/index.ts b/src/completion/index.ts new file mode 100644 index 00000000..6ef5d221 --- /dev/null +++ b/src/completion/index.ts @@ -0,0 +1,36 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Entry point for the shell completion subsystem. + * + * Exposes a single factory function that returns both Commander commands + * the main CLI needs to register: + * + * - `elastic completion ` — public; prints a wrapper script + * - `elastic __complete -- ` — hidden; computes candidates + * + * Both commands are config-free; cli.ts adds them to the `preAction` + * config-load skip list so completion never depends on a working config. + */ + +import type { OpaqueCommandHandle } from '../factory.ts' +import { buildCompletionCommand } from './command.ts' +import { buildCompleteCommand } from './complete.ts' + +/** + * Returns the pair of commands the completion subsystem owns, in the order + * they should be registered on the program (public command first so it + * appears earlier in --help output). + */ +export function registerCompletionCommands (): OpaqueCommandHandle[] { + return [buildCompletionCommand(), buildCompleteCommand()] +} + +/** Names of the commands registered by {@link registerCompletionCommands}. */ +export const COMPLETION_COMMAND_NAMES: readonly string[] = Object.freeze([ + 'completion', + '__complete', +]) diff --git a/src/completion/registry.ts b/src/completion/registry.ts new file mode 100644 index 00000000..c6862554 --- /dev/null +++ b/src/completion/registry.ts @@ -0,0 +1,29 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Default registry of dynamic completers for {@link enumerate}. + * + * Each entry maps a long flag name (including `--`) to a function that + * computes the candidate values for that flag. Completers must never throw; + * see the contract in `enumerate.ts`. + * + * Adding a new dynamic completer (e.g. `--index`, `--id`) is a matter of + * implementing a `() => Promise` function and registering it here. + */ + +import type { DynamicCompleter, DynamicCompleterRegistry } from './enumerate.ts' +import { completeContextNames } from './completers/context-names.ts' + +const DEFAULT_COMPLETERS: ReadonlyMap = new Map([ + ['--use-context', completeContextNames], +]) + +/** The shared registry used by the `__complete` command. */ +export const defaultRegistry: DynamicCompleterRegistry = { + get (flagLong: string): DynamicCompleter | undefined { + return DEFAULT_COMPLETERS.get(flagLong) + }, +} diff --git a/src/completion/shells/bash.ts b/src/completion/shells/bash.ts new file mode 100644 index 00000000..4a10c4a3 --- /dev/null +++ b/src/completion/shells/bash.ts @@ -0,0 +1,66 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Bash completion wrapper for the `elastic` CLI. + * + * The wrapper is dynamic: every TAB press shells out to + * `elastic __complete -- `. The CLI returns candidates followed by a + * trailing `:N` directive line (bit-field; see `enumerate.ts`). + * + * Install: + * elastic completion bash > /etc/bash_completion.d/elastic + * or (per-user): + * elastic completion bash > ~/.local/share/bash-completion/completions/elastic + */ +export function bashWrapper (): string { + return `# elastic shell completion (Bash) +# Source this file, or place it under /etc/bash_completion.d/ for system-wide +# install (or ~/.local/share/bash-completion/completions/ for per-user). + +_elastic_complete() { + local args response directive line + local -a lines + COMPREPLY=() + + # Words after the program name, up to and including the cursor word. + args=("\${COMP_WORDS[@]:1:COMP_CWORD}") + + # "--" disables option parsing on the CLI side so flags like --use-context + # are passed through verbatim. + response=$(elastic __complete -- "\${args[@]}" 2>/dev/null) || return + + if [[ -z "$response" ]]; then + return + fi + + # Split response by newline. readarray requires Bash 4+. + readarray -t lines <<<"$response" + + directive=0 + if (( \${#lines[@]} > 0 )); then + local last="\${lines[\${#lines[@]}-1]}" + if [[ "$last" == :* ]]; then + directive="\${last#:}" + unset 'lines[\${#lines[@]}-1]' + fi + fi + + for line in "\${lines[@]}"; do + [[ -z "$line" ]] && continue + COMPREPLY+=("$line") + done + + # Bit 1: do not append a trailing space (used for --flag= form). + if (( (directive & 1) != 0 )); then + compopt -o nospace 2>/dev/null + fi + + # Bit 2 (NO_FILE_COMP) is the default for "complete -F"; no action required. +} + +complete -F _elastic_complete elastic +` +} diff --git a/src/completion/shells/fish.ts b/src/completion/shells/fish.ts new file mode 100644 index 00000000..fe514f6e --- /dev/null +++ b/src/completion/shells/fish.ts @@ -0,0 +1,53 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Fish completion wrapper for the `elastic` CLI. + * + * Install (per-user): + * elastic completion fish > ~/.config/fish/completions/elastic.fish + * + * Fish's `complete -c -f` disables filename completion by default, so + * the `DIRECTIVE_NO_FILE_COMP` bit is implicit. The `DIRECTIVE_NO_SPACE` + * bit is not honoured (fish only avoids trailing space for `--flag=value` + * candidates emitted as a single token). + */ +export function fishWrapper (): string { + return `# elastic shell completion (Fish) + +function __elastic_complete + set -l raw_tokens (commandline -opc) (commandline -ct) + + # Drop the program name (first token) — the CLI's __complete handler expects + # to receive just the words to complete. + set -l args $raw_tokens[2..-1] + + set -l response (command elastic __complete -- $args 2>/dev/null) + + set -l count (count $response) + if test $count -eq 0 + return + end + + # Trim the trailing ":N" directive line if present. + set -l last $response[-1] + if string match -q ':*' -- $last + if test $count -gt 1 + set response $response[1..-2] + else + set response + end + end + + for line in $response + if test -n "$line" + echo $line + end + end +end + +complete -c elastic -f -a '(__elastic_complete)' +` +} diff --git a/src/completion/shells/zsh.ts b/src/completion/shells/zsh.ts new file mode 100644 index 00000000..be6f7ec2 --- /dev/null +++ b/src/completion/shells/zsh.ts @@ -0,0 +1,64 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Zsh completion wrapper for the `elastic` CLI. + * + * Install (per-user, recommended): + * elastic completion zsh > "\${fpath[1]}/_elastic" + * autoload -Uz compinit && compinit + * + * Or eagerly: + * eval "$(elastic completion zsh)" + * + * The wrapper delegates to `elastic __complete -- ` on every TAB. + */ +export function zshWrapper (): string { + return `#compdef elastic +# elastic shell completion (Zsh) + +_elastic_complete() { + local response directive last + local -a lines candidates flag_args + + # Tokens after the program name, up to and including the cursor word. + local -a request_args + request_args=("\${(@)words[2,CURRENT]}") + + response="$(elastic __complete -- "\${request_args[@]}" 2>/dev/null)" + + if [[ -z "$response" ]]; then + return + fi + + lines=("\${(@f)response}") + + directive=0 + if (( \${#lines[@]} > 0 )); then + last="\${lines[-1]}" + if [[ "$last" == :* ]]; then + directive="\${last#:}" + lines=("\${lines[@]:0:\${#lines[@]}-1}") + fi + fi + + candidates=() + local w + for w in "\${lines[@]}"; do + [[ -z "$w" ]] && continue + candidates+=("$w") + done + + # Bit 1: suppress trailing space (used for --flag= form). + if (( (directive & 1) != 0 )); then + compadd -S '' -- "\${candidates[@]}" + else + compadd -- "\${candidates[@]}" + fi +} + +compdef _elastic_complete elastic +` +} diff --git a/test/completion-cli.test.ts b/test/completion-cli.test.ts new file mode 100644 index 00000000..201a8158 --- /dev/null +++ b/test/completion-cli.test.ts @@ -0,0 +1,162 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const CLI_ENTRY = join(process.cwd(), 'src', 'cli.ts') +const TSX_BIN = join(process.cwd(), 'node_modules', '.bin', 'tsx') + +/** + * Spawns the CLI directly from source via tsx so the tests do not depend on + * a fresh `dist/` build. This is slower per-spawn than the production + * `dist/cli.js` path but exercises the same end-to-end protocol. + */ +function runCli ( + args: string[], + env: Record = {}, +): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((resolve) => { + // Override NODE_V8_COVERAGE with the empty string. When the parent test + // runner uses --experimental-test-coverage, Node propagates this env var + // to children even if we `delete` it from the spread object (special- + // cased on Linux). Setting it to "" disables child-process coverage + // collection, preventing transitive imports from cli.ts (loaded fully by + // the child) from leaking partial-function coverage into the parent's + // report and dragging the project's averages below the 90% threshold. + const childEnv = { ...process.env, NODE_V8_COVERAGE: '', ...env } + const child = spawn(TSX_BIN, [CLI_ENTRY, ...args], { + stdio: ['pipe', 'pipe', 'pipe'], + env: childEnv, + }) + child.stdin.end('') + let stdout = '' + let stderr = '' + child.stdout.on('data', (d: Buffer) => { stdout += d }) + child.stderr.on('data', (d: Buffer) => { stderr += d }) + child.on('close', (code) => resolve({ code, stdout, stderr })) + }) +} + +function parseProtocol (stdout: string): { candidates: string[]; directive: number } { + const lines = stdout.split('\n').filter((l) => l.length > 0) + const last = lines[lines.length - 1] ?? '' + if (!last.startsWith(':')) return { candidates: lines, directive: 0 } + return { candidates: lines.slice(0, -1), directive: Number(last.slice(1)) } +} + +describe('elastic CLI -- shell completion end-to-end', () => { + it('`__complete -- ""` lists every visible top-level group', async () => { + const { code, stdout } = await runCli(['__complete', '--', '']) + assert.equal(code, 0, `expected exit 0, got ${code}`) + const out = parseProtocol(stdout) + for (const expected of ['version', 'stack', 'cloud', 'docs', 'config', 'sanitize', 'es', 'kb', 'completion']) { + assert.ok(out.candidates.includes(expected), `missing top-level "${expected}" in ${out.candidates.join(',')}`) + } + assert.equal(out.directive & 2, 2, 'expected NO_FILE_COMP bit (2) in directive') + }) + + it('`__complete -- es ""` walks into the stack/es subtree after alias rewrite', async () => { + const { code, stdout } = await runCli(['__complete', '--', 'es', '']) + assert.equal(code, 0) + const out = parseProtocol(stdout) + assert.ok(out.candidates.includes('indices'), `expected es indices in ${out.candidates.length} candidates`) + assert.ok(out.candidates.includes('cluster')) + assert.ok(out.candidates.includes('search')) + }) + + it('`__complete -- --use-context ""` lists context names from a temp config', async () => { + const dir = await mkdtemp(join(tmpdir(), 'elastic-cli-complete-')) + try { + const cfg = join(dir, 'config.yml') + await writeFile(cfg, [ + 'current_context: dev', + 'contexts:', + ' dev:', + ' elasticsearch:', + ' url: http://localhost:9200', + ' prod:', + ' elasticsearch:', + ' url: http://localhost:9200', + '', + ].join('\n')) + const { code, stdout } = await runCli( + ['__complete', '--', '--use-context', ''], + { ELASTIC_CLI_CONFIG_FILE: cfg }, + ) + assert.equal(code, 0) + const out = parseProtocol(stdout) + assert.deepEqual(out.candidates.sort(), ['dev', 'prod']) + } finally { + await rm(dir, { recursive: true }) + } + }) + + it('`__complete` exits 0 with empty candidates when no config exists', async () => { + const dir = await mkdtemp(join(tmpdir(), 'elastic-cli-complete-nocfg-')) + try { + const { code, stdout } = await runCli( + ['__complete', '--', '--use-context', ''], + { HOME: dir, USERPROFILE: dir, XDG_CONFIG_HOME: dir }, + ) + assert.equal(code, 0, 'completion must never fail even without a config') + const out = parseProtocol(stdout) + assert.deepEqual(out.candidates, []) + } finally { + await rm(dir, { recursive: true }) + } + }) + + it('`completion bash` prints a wrapper script containing complete -F', async () => { + const { code, stdout } = await runCli(['completion', 'bash']) + assert.equal(code, 0) + assert.ok(stdout.length > 100, 'expected a non-trivial wrapper script') + assert.match(stdout, /complete -F .* elastic/) + assert.match(stdout, /__complete/) + }) + + it('`completion zsh` prints a #compdef wrapper', async () => { + const { code, stdout } = await runCli(['completion', 'zsh']) + assert.equal(code, 0) + assert.match(stdout, /^#compdef elastic/) + }) + + it('`completion fish` prints a fish wrapper', async () => { + const { code, stdout } = await runCli(['completion', 'fish']) + assert.equal(code, 0) + assert.match(stdout, /complete -c elastic/) + }) + + it('`completion ` exits 1 with a structured error', async () => { + const { code, stderr } = await runCli(['completion', 'tcsh']) + assert.equal(code, 1) + assert.match(stderr, /unknown shell/i) + }) + + it('`__complete` does not require a working config (no "no config" error)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'elastic-cli-complete-isolated-')) + try { + const { code, stderr } = await runCli( + ['__complete', '--', ''], + { HOME: dir, USERPROFILE: dir, XDG_CONFIG_HOME: dir }, + ) + assert.equal(code, 0) + assert.equal(stderr, '', `unexpected stderr: ${stderr}`) + } finally { + await rm(dir, { recursive: true }) + } + }) + + it('`__complete -- ""` does not include the hidden __complete command itself', async () => { + const { stdout } = await runCli(['__complete', '--', '']) + const out = parseProtocol(stdout) + assert.ok(!out.candidates.includes('__complete'), + 'the internal __complete command must not appear as a completion candidate') + }) +}) diff --git a/test/completion/argv-aliases.test.ts b/test/completion/argv-aliases.test.ts new file mode 100644 index 00000000..d97ef6a3 --- /dev/null +++ b/test/completion/argv-aliases.test.ts @@ -0,0 +1,62 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { rewriteTopLevelAliases } from '../../src/completion/argv-aliases.ts' + +describe('rewriteTopLevelAliases', () => { + it('returns an empty array unchanged', () => { + assert.deepEqual(rewriteTopLevelAliases([]), []) + }) + + it('prefixes "stack" before "es"', () => { + assert.deepEqual(rewriteTopLevelAliases(['es', 'info']), ['stack', 'es', 'info']) + }) + + it('prefixes "stack" before "elasticsearch"', () => { + assert.deepEqual( + rewriteTopLevelAliases(['elasticsearch', 'indices', 'list']), + ['stack', 'elasticsearch', 'indices', 'list'], + ) + }) + + it('prefixes "stack" before "kb"', () => { + assert.deepEqual(rewriteTopLevelAliases(['kb', 'data-views']), ['stack', 'kb', 'data-views']) + }) + + it('prefixes "stack" before "kibana"', () => { + assert.deepEqual(rewriteTopLevelAliases(['kibana', 'cases']), ['stack', 'kibana', 'cases']) + }) + + it('leaves "stack" alone (no double-prefix)', () => { + assert.deepEqual(rewriteTopLevelAliases(['stack', 'es']), ['stack', 'es']) + }) + + it('leaves unrelated top-level commands alone', () => { + assert.deepEqual(rewriteTopLevelAliases(['cloud', 'hosted']), ['cloud', 'hosted']) + assert.deepEqual(rewriteTopLevelAliases(['config', 'list']), ['config', 'list']) + assert.deepEqual(rewriteTopLevelAliases(['version']), ['version']) + }) + + it('leaves a partial first word alone (do not rewrite during typing)', () => { + assert.deepEqual(rewriteTopLevelAliases(['e']), ['e']) + assert.deepEqual(rewriteTopLevelAliases(['']), ['']) + }) + + it('does not mutate the input array', () => { + const input = ['es', 'info'] + const before = [...input] + rewriteTopLevelAliases(input) + assert.deepEqual(input, before) + }) + + it('preserves flags appearing after the alias verbatim', () => { + assert.deepEqual( + rewriteTopLevelAliases(['es', '--index', 'my-index', 'search']), + ['stack', 'es', '--index', 'my-index', 'search'], + ) + }) +}) diff --git a/test/completion/command.test.ts b/test/completion/command.test.ts new file mode 100644 index 00000000..3958da28 --- /dev/null +++ b/test/completion/command.test.ts @@ -0,0 +1,88 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { Command } from 'commander' +import { buildCompletionCommand, SUPPORTED_SHELLS } from '../../src/completion/command.ts' + +async function captureWith ( + cmd: Command, + argv: string[], +): Promise<{ stdout: string; stderr: string; exitCode: number | undefined }> { + cmd.exitOverride() + const stdoutChunks: string[] = [] + const stderrChunks: string[] = [] + const origStdout = process.stdout.write.bind(process.stdout) + const origStderr = process.stderr.write.bind(process.stderr) + process.stdout.write = ((c: string) => { stdoutChunks.push(String(c)); return true }) as typeof process.stdout.write + process.stderr.write = ((c: string) => { stderrChunks.push(String(c)); return true }) as typeof process.stderr.write + // Reset the global exit code so we can read what this command sets. + const priorExitCode = process.exitCode + process.exitCode = undefined + let throwExit: number | undefined + try { + await cmd.parseAsync(argv, { from: 'user' }) + } catch (e) { + // Commander's exitOverride throws a CommanderError with .exitCode + // eslint-disable-next-line @typescript-eslint/no-explicit-any + throwExit = (e as any).exitCode ?? 1 + } finally { + process.stdout.write = origStdout + process.stderr.write = origStderr + } + // Handlers in this codebase set process.exitCode (no throw) for structured + // errors; capture either signal. + const exitCode = throwExit ?? (process.exitCode != null ? Number(process.exitCode) : undefined) + process.exitCode = priorExitCode + return { stdout: stdoutChunks.join(''), stderr: stderrChunks.join(''), exitCode } +} + +describe('buildCompletionCommand', () => { + it('is named "completion"', () => { + assert.equal(buildCompletionCommand().name(), 'completion') + }) + + it('declares a required positional argument', () => { + const cmd = buildCompletionCommand() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const args = (cmd as unknown as any).registeredArguments as Array<{ name: () => string; required: boolean }> + assert.equal(args.length, 1) + assert.equal(args[0].name(), 'shell') + assert.equal(args[0].required, true) + }) + + it('emits the bash wrapper to stdout for "bash"', async () => { + const { stdout, stderr } = await captureWith(buildCompletionCommand(), ['bash']) + assert.match(stdout, /complete -F .* elastic/) + assert.equal(stderr, '') + }) + + it('emits the zsh wrapper to stdout for "zsh"', async () => { + const { stdout } = await captureWith(buildCompletionCommand(), ['zsh']) + assert.match(stdout, /^#compdef elastic/) + }) + + it('emits the fish wrapper to stdout for "fish"', async () => { + const { stdout } = await captureWith(buildCompletionCommand(), ['fish']) + assert.match(stdout, /complete -c elastic/) + }) + + it('reports an error for an unknown shell', async () => { + const { stderr, exitCode } = await captureWith(buildCompletionCommand(), ['tcsh']) + assert.match(stderr, /unknown shell/i) + assert.equal(exitCode, 1) + }) + + it('reports an error when no shell is provided', async () => { + const { stderr, exitCode } = await captureWith(buildCompletionCommand(), []) + assert.match(stderr, /missing required argument/i) + assert.equal(exitCode, 1) + }) + + it('SUPPORTED_SHELLS lists bash, zsh and fish', () => { + assert.deepEqual([...SUPPORTED_SHELLS].sort(), ['bash', 'fish', 'zsh']) + }) +}) diff --git a/test/completion/complete.test.ts b/test/completion/complete.test.ts new file mode 100644 index 00000000..a60d0ca7 --- /dev/null +++ b/test/completion/complete.test.ts @@ -0,0 +1,274 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { + buildCompletionTree, + handleComplete, + buildCompleteCommand, +} from '../../src/completion/complete.ts' + +function bufferedWriter (): { write: (chunk: string) => void; chunks: string[] } { + const chunks: string[] = [] + return { write: (chunk) => { chunks.push(chunk) }, chunks } +} + +function parseOutput (output: string): { candidates: string[]; directive: number } { + const lines = output.split('\n').filter(l => l.length > 0) + const last = lines[lines.length - 1] ?? '' + if (!last.startsWith(':')) return { candidates: lines, directive: 0 } + return { + candidates: lines.slice(0, -1), + directive: Number(last.slice(1)), + } +} + +describe('buildCompletionTree -- top-level commands', () => { + it('registers version + every visible top-level group as stubs by default', async () => { + const root = await buildCompletionTree([]) + const names = root.commands.map(c => c.name()) + assert.ok(names.includes('version'), `expected version, got: ${names.join(',')}`) + assert.ok(names.includes('stack')) + assert.ok(names.includes('cloud')) + assert.ok(names.includes('docs')) + assert.ok(names.includes('config')) + assert.ok(names.includes('sanitize')) + assert.ok(names.includes('es'), 'expected top-level es alias') + assert.ok(names.includes('kb'), 'expected top-level kb alias') + assert.ok(names.includes('completion')) + }) + + it('exposes elasticsearch as an alias of the top-level es', async () => { + const root = await buildCompletionTree([]) + const es = root.commands.find(c => c.name() === 'es')! + assert.ok(es.aliases().includes('elasticsearch')) + const kb = root.commands.find(c => c.name() === 'kb')! + assert.ok(kb.aliases().includes('kibana')) + }) + + it('exposes global --use-context option on the root program', async () => { + const root = await buildCompletionTree([]) + const opt = root.options.find(o => o.long === '--use-context') + assert.ok(opt != null, 'expected --use-context global option') + }) + + it('does not load the es subtree when the first word is unrelated', async () => { + const root = await buildCompletionTree(['cloud']) + const stack = root.commands.find(c => c.name() === 'stack')! + const es = stack.commands.find(c => c.name() === 'es') + assert.equal(es == null || es.commands.length === 0, true, 'stack es should remain a stub') + }) +}) + +describe('buildCompletionTree -- lazy loading', () => { + // Note: kb/docs/config subtree loading is exercised by the spawn + // integration tests in test/completion-cli.test.ts. Importing those + // registers in-process here would pull large untested module trees + // (e.g. kb/* which has no other in-process callers) into the unit-test + // coverage denominator and silently drop project-wide thresholds. + + it('loads the cloud subtree when the first word is "cloud"', async () => { + const root = await buildCompletionTree(['cloud']) + const cloud = root.commands.find(c => c.name() === 'cloud')! + assert.ok(cloud.commands.length > 0, 'cloud should have children once loaded') + }) + + it('loads the es subtree when stack + es is selected', async () => { + const root = await buildCompletionTree(['stack', 'es']) + const stack = root.commands.find(c => c.name() === 'stack')! + const es = stack.commands.find(c => c.name() === 'es')! + assert.ok(es.commands.length > 0, 'es should have children once loaded') + }) + + it('loads the es subtree via the "elasticsearch" alias too', async () => { + const root = await buildCompletionTree(['stack', 'elasticsearch']) + const stack = root.commands.find(c => c.name() === 'stack')! + const es = stack.commands.find(c => c.name() === 'es')! + assert.ok(es.commands.length > 0, 'es should load when secondWord is the alias form') + }) + + it('keeps stack es+kb as stubs when secondWord is empty', async () => { + const root = await buildCompletionTree(['stack', '']) + const stack = root.commands.find(c => c.name() === 'stack')! + const es = stack.commands.find(c => c.name() === 'es')! + const kb = stack.commands.find(c => c.name() === 'kb')! + assert.equal(es.commands.length, 0, 'es should remain a stub when secondWord is empty') + assert.equal(kb.commands.length, 0, 'kb should remain a stub when secondWord is empty') + }) +}) + +describe('handleComplete -- policy enforcement', () => { + const ORIGINAL_ENV = process.env['ELASTIC_CLI_CONFIG_FILE'] + beforeEach(() => { delete process.env['ELASTIC_CLI_CONFIG_FILE'] }) + afterEach(() => { + if (ORIGINAL_ENV != null) process.env['ELASTIC_CLI_CONFIG_FILE'] = ORIGINAL_ENV + else delete process.env['ELASTIC_CLI_CONFIG_FILE'] + }) + + it('hides top-level groups blocked by commands.blocked', async () => { + const { mkdtemp, writeFile, rm } = await import('node:fs/promises') + const { tmpdir } = await import('node:os') + const { join } = await import('node:path') + const dir = await mkdtemp(join(tmpdir(), 'elastic-cli-policy-')) + const path = join(dir, 'config.yml') + await writeFile(path, [ + 'current_context: local', + 'commands:', + ' blocked:', + ' - cloud.*', + ' - sanitize.*', + 'contexts:', + ' local:', + ' elasticsearch:', + ' url: http://localhost:9200', + '', + ].join('\n')) + process.env['ELASTIC_CLI_CONFIG_FILE'] = path + + const buf = bufferedWriter() + await handleComplete([''], buf.write) + await rm(dir, { recursive: true }) + + const out = parseOutput(buf.chunks.join('')) + // Top-level group with all children blocked should be hidden entirely. + assert.ok(!out.candidates.includes('sanitize'), + `sanitize should be hidden by policy; got: ${out.candidates.join(',')}`) + // Groups not blocked should still appear. + assert.ok(out.candidates.includes('stack')) + assert.ok(out.candidates.includes('version')) + }) +}) + +describe('handleComplete -- stdout protocol', () => { + const ORIGINAL_ENV = process.env['ELASTIC_CLI_CONFIG_FILE'] + beforeEach(() => { delete process.env['ELASTIC_CLI_CONFIG_FILE'] }) + afterEach(() => { + if (ORIGINAL_ENV != null) process.env['ELASTIC_CLI_CONFIG_FILE'] = ORIGINAL_ENV + else delete process.env['ELASTIC_CLI_CONFIG_FILE'] + }) + + it('writes top-level candidates and a :N directive line', async () => { + const buf = bufferedWriter() + await handleComplete([''], buf.write) + const out = parseOutput(buf.chunks.join('')) + assert.ok(out.candidates.includes('stack')) + assert.ok(out.candidates.includes('cloud')) + assert.equal(out.directive & 2, 2, 'directive should include NO_FILE_COMP') + }) + + it('rewrites the "es" alias to walk into stack/es', async () => { + const buf = bufferedWriter() + await handleComplete(['es', ''], buf.write) + const out = parseOutput(buf.chunks.join('')) + assert.ok(out.candidates.length > 5, `expected >5 es children, got ${out.candidates.length}`) + }) + + it('produces the same candidate set for "es" and "elasticsearch"', async () => { + const a = bufferedWriter() + await handleComplete(['es', ''], a.write) + const b = bufferedWriter() + await handleComplete(['elasticsearch', ''], b.write) + assert.deepEqual( + parseOutput(a.chunks.join('')).candidates.sort(), + parseOutput(b.chunks.join('')).candidates.sort(), + ) + }) + + it('never throws on malformed input and emits :2', async () => { + const buf = bufferedWriter() + await handleComplete(['this', 'path', 'does', 'not', 'exist', ''], buf.write) + const out = parseOutput(buf.chunks.join('')) + assert.equal(out.directive & 2, 2) + }) +}) + +describe('handleComplete -- dynamic context name completion', () => { + const ORIGINAL_ENV = process.env['ELASTIC_CLI_CONFIG_FILE'] + beforeEach(() => { delete process.env['ELASTIC_CLI_CONFIG_FILE'] }) + afterEach(() => { + if (ORIGINAL_ENV != null) process.env['ELASTIC_CLI_CONFIG_FILE'] = ORIGINAL_ENV + else delete process.env['ELASTIC_CLI_CONFIG_FILE'] + }) + + it('emits context names from the configured file', async () => { + const { mkdtemp, writeFile, rm } = await import('node:fs/promises') + const { tmpdir } = await import('node:os') + const { join } = await import('node:path') + const dir = await mkdtemp(join(tmpdir(), 'elastic-cli-cnames-')) + const path = join(dir, 'config.yml') + await writeFile(path, [ + 'current_context: local', + 'contexts:', + ' local:', + ' elasticsearch:', + ' url: http://localhost:9200', + ' staging:', + ' elasticsearch:', + ' url: http://localhost:9200', + '', + ].join('\n')) + process.env['ELASTIC_CLI_CONFIG_FILE'] = path + + const buf = bufferedWriter() + await handleComplete(['--use-context', ''], buf.write) + await rm(dir, { recursive: true }) + + const out = parseOutput(buf.chunks.join('')) + assert.deepEqual(out.candidates.sort(), ['local', 'staging']) + }) +}) + +describe('buildCompleteCommand', () => { + it('returns a hidden Commander command named __complete', () => { + const cmd = buildCompleteCommand() + assert.equal(cmd.name(), '__complete') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + assert.equal((cmd as unknown as any)._hidden, true) + }) + + it('routes parseAsync via the action handler and writes to the default sink', async () => { + // Exercises both the action callback in buildCompleteCommand AND the + // defaultWriter branch in handleComplete (no writer override). We + // forward-write so the test runner's own progress lines still appear. + const cmd = buildCompleteCommand() + const origArgv = process.argv + const origWrite = process.stdout.write.bind(process.stdout) + const captured: string[] = [] + process.stdout.write = ((c: string | Uint8Array, ...rest: unknown[]) => { + captured.push(typeof c === 'string' ? c : Buffer.from(c).toString('utf-8')) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (origWrite as any)(c, ...rest) + }) as typeof process.stdout.write + process.argv = ['node', 'cli.js', '__complete', '--', ''] + try { + await cmd.parseAsync([''], { from: 'user' }) + } finally { + process.argv = origArgv + process.stdout.write = origWrite + } + assert.match(captured.join(''), /:\d+/, 'expected the trailing directive line') + }) + + it('falls back to the variadic positional words when "--" is absent', async () => { + const cmd = buildCompleteCommand() + const origArgv = process.argv + const origWrite = process.stdout.write.bind(process.stdout) + const captured: string[] = [] + process.stdout.write = ((c: string | Uint8Array, ...rest: unknown[]) => { + captured.push(typeof c === 'string' ? c : Buffer.from(c).toString('utf-8')) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (origWrite as any)(c, ...rest) + }) as typeof process.stdout.write + process.argv = ['node', 'cli.js', '__complete'] + try { + await cmd.parseAsync([], { from: 'user' }) + } finally { + process.argv = origArgv + process.stdout.write = origWrite + } + assert.match(captured.join(''), /:\d+/) + }) +}) diff --git a/test/completion/context-names.test.ts b/test/completion/context-names.test.ts new file mode 100644 index 00000000..cfd7d4c3 --- /dev/null +++ b/test/completion/context-names.test.ts @@ -0,0 +1,137 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, before, after, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, writeFile, rm, chmod } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { completeContextNames } from '../../src/completion/completers/context-names.ts' + +const VALID_YAML = ` +current_context: local +contexts: + local: + elasticsearch: + url: http://localhost:9200 + staging: + elasticsearch: + url: https://staging.example.com:9200 + prod: + elasticsearch: + url: https://prod.example.com:9200 +`.trimStart() + +describe('completeContextNames', () => { + let tmpDir: string + const ORIGINAL_ENV = process.env['ELASTIC_CLI_CONFIG_FILE'] + + before(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'elastic-cli-ctx-')) }) + after(async () => { + await rm(tmpDir, { recursive: true }) + if (ORIGINAL_ENV != null) process.env['ELASTIC_CLI_CONFIG_FILE'] = ORIGINAL_ENV + else delete process.env['ELASTIC_CLI_CONFIG_FILE'] + }) + + beforeEach(() => { delete process.env['ELASTIC_CLI_CONFIG_FILE'] }) + afterEach(() => { delete process.env['ELASTIC_CLI_CONFIG_FILE'] }) + + it('returns the keys of the contexts map from ELASTIC_CLI_CONFIG_FILE', async () => { + const path = join(tmpDir, 'valid.yml') + await writeFile(path, VALID_YAML) + process.env['ELASTIC_CLI_CONFIG_FILE'] = path + const names = await completeContextNames() + assert.deepEqual(names.sort(), ['local', 'prod', 'staging']) + }) + + it('returns the keys for a JSON config file', async () => { + const path = join(tmpDir, 'valid.json') + await writeFile(path, JSON.stringify({ + current_context: 'a', + contexts: { + a: { elasticsearch: { url: 'http://localhost:9200' } }, + b: { elasticsearch: { url: 'http://localhost:9200' } }, + }, + })) + process.env['ELASTIC_CLI_CONFIG_FILE'] = path + const names = await completeContextNames() + assert.deepEqual(names.sort(), ['a', 'b']) + }) + + it('returns [] when ELASTIC_CLI_CONFIG_FILE points at a missing file', async () => { + process.env['ELASTIC_CLI_CONFIG_FILE'] = join(tmpDir, 'does-not-exist.yml') + assert.deepEqual(await completeContextNames(), []) + }) + + it('returns [] when the config is malformed YAML', async () => { + const path = join(tmpDir, 'broken.yml') + await writeFile(path, ': : not yaml ::') + process.env['ELASTIC_CLI_CONFIG_FILE'] = path + assert.deepEqual(await completeContextNames(), []) + }) + + it('returns [] when contexts is missing entirely', async () => { + const path = join(tmpDir, 'no-contexts.yml') + await writeFile(path, 'current_context: local\n') + process.env['ELASTIC_CLI_CONFIG_FILE'] = path + assert.deepEqual(await completeContextNames(), []) + }) + + it('returns [] when contexts is an empty map', async () => { + const path = join(tmpDir, 'empty.yml') + await writeFile(path, 'current_context: local\ncontexts: {}\n') + process.env['ELASTIC_CLI_CONFIG_FILE'] = path + assert.deepEqual(await completeContextNames(), []) + }) + + it('returns [] when no config file is discoverable (HOME has none)', async () => { + const emptyHome = await mkdtemp(join(tmpdir(), 'elastic-cli-empty-home-')) + try { + const prevHome = process.env['HOME'] + process.env['HOME'] = emptyHome + try { + assert.deepEqual(await completeContextNames(), []) + } finally { + if (prevHome != null) process.env['HOME'] = prevHome + else delete process.env['HOME'] + } + } finally { + await rm(emptyHome, { recursive: true }) + } + }) + + it('never throws when the file is unreadable', async () => { + if (process.platform === 'win32') return // chmod is a no-op on Windows + const path = join(tmpDir, 'unreadable.yml') + await writeFile(path, VALID_YAML) + await chmod(path, 0o000) + process.env['ELASTIC_CLI_CONFIG_FILE'] = path + try { + assert.deepEqual(await completeContextNames(), []) + } finally { + await chmod(path, 0o600).catch(() => undefined) + } + }) + + it('ignores unresolved $(...) expressions in context values', async () => { + const path = join(tmpDir, 'with-expr.yml') + await writeFile(path, [ + 'current_context: local', + 'contexts:', + ' local:', + ' elasticsearch:', + ' url: http://localhost:9200', + ' auth:', + ' api_key: $(env:THIS_DOES_NOT_EXIST_ANYWHERE)', + ' remote:', + ' elasticsearch:', + ' url: $(env:ALSO_MISSING)', + '', + ].join('\n')) + process.env['ELASTIC_CLI_CONFIG_FILE'] = path + const names = await completeContextNames() + assert.deepEqual(names.sort(), ['local', 'remote']) + }) +}) diff --git a/test/completion/enumerate.test.ts b/test/completion/enumerate.test.ts new file mode 100644 index 00000000..1898d6d2 --- /dev/null +++ b/test/completion/enumerate.test.ts @@ -0,0 +1,263 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { Command } from 'commander' +import { defineCommand, defineGroup } from '../../src/factory.ts' +import { + enumerate, + DIRECTIVE_NO_FILE_COMP, + DIRECTIVE_NO_SPACE, + type DynamicCompleterRegistry, +} from '../../src/completion/enumerate.ts' + +function buildSampleProgram (): Command { + const program = new Command('elastic') + program.option('--config-file ', 'config file') + program.option('--use-context ', 'override active context') + program.option('--json', 'output as JSON') + + const versionCmd = defineCommand({ + name: 'version', + description: 'Print the elastic CLI version', + handler: () => ({ version: '0.0.0' }), + }) + program.addCommand(versionCmd) + + const esInfo = defineCommand({ + name: 'info', + description: 'Cluster info', + handler: () => ({}), + }) + const esSearch = defineCommand({ + name: 'search', + description: 'Search', + options: [ + { long: 'index', short: 'i', description: 'Index name', type: 'string' }, + { long: 'size', description: 'Number of hits', type: 'number' }, + ], + handler: () => ({}), + }) + const esGroup = defineGroup( + { name: 'es', description: 'Elasticsearch API' }, + esInfo, + esSearch, + ) + esGroup.alias('elasticsearch') + + const stack = defineGroup( + { name: 'stack', description: 'Stack components' }, + esGroup, + ) + program.addCommand(stack) + + const cloud = defineGroup( + { name: 'cloud', description: 'Cloud' }, + defineCommand({ name: 'trust', description: 'Trust', handler: () => ({}) }), + ) + program.addCommand(cloud) + + return program +} + +describe('enumerate -- top-level candidates', () => { + it('returns all top-level commands when words is empty', async () => { + const result = await enumerate(buildSampleProgram(), []) + assert.ok(result.candidates.includes('version')) + assert.ok(result.candidates.includes('stack')) + assert.ok(result.candidates.includes('cloud')) + }) + + it('returns all top-level commands when only an empty incomplete word is present', async () => { + const result = await enumerate(buildSampleProgram(), ['']) + assert.ok(result.candidates.includes('version')) + assert.ok(result.candidates.includes('stack')) + }) + + it('prefix-filters top-level candidates against a partial word', async () => { + const result = await enumerate(buildSampleProgram(), ['ve']) + assert.deepEqual(result.candidates, ['version']) + }) + + it('default directive is no-file-comp', async () => { + const result = await enumerate(buildSampleProgram(), []) + assert.equal(result.directive & DIRECTIVE_NO_FILE_COMP, DIRECTIVE_NO_FILE_COMP) + }) +}) + +describe('enumerate -- nested groups', () => { + it('walks into a group and lists its children for an empty incomplete word', async () => { + const result = await enumerate(buildSampleProgram(), ['stack', 'es', '']) + assert.ok(result.candidates.includes('info')) + assert.ok(result.candidates.includes('search')) + }) + + it('walks via the alias and yields identical children', async () => { + const result = await enumerate(buildSampleProgram(), ['stack', 'elasticsearch', '']) + assert.ok(result.candidates.includes('info')) + assert.ok(result.candidates.includes('search')) + }) + + it('prefix-filters nested candidates', async () => { + const result = await enumerate(buildSampleProgram(), ['stack', 'es', 'sea']) + assert.deepEqual(result.candidates, ['search']) + }) + + it('exposes group aliases as candidates of the parent', async () => { + const result = await enumerate(buildSampleProgram(), ['stack', '']) + assert.ok(result.candidates.includes('es')) + assert.ok(result.candidates.includes('elasticsearch')) + }) +}) + +describe('enumerate -- flag completion', () => { + it('lists matching long flags on a leaf command', async () => { + const result = await enumerate(buildSampleProgram(), ['stack', 'es', 'search', '--in']) + assert.ok(result.candidates.includes('--index')) + }) + + it('lists all flags of the leaf command for "--"', async () => { + const result = await enumerate(buildSampleProgram(), ['stack', 'es', 'search', '--']) + assert.ok(result.candidates.includes('--index')) + assert.ok(result.candidates.includes('--size')) + }) + + it('includes global flags from the root program at every level', async () => { + const result = await enumerate(buildSampleProgram(), ['stack', 'es', 'search', '--js']) + assert.ok(result.candidates.includes('--json')) + }) + + it('includes --help at every level', async () => { + const result = await enumerate(buildSampleProgram(), ['stack', 'es', 'search', '--h']) + assert.ok(result.candidates.includes('--help')) + }) +}) + +describe('enumerate -- hidden commands', () => { + it('skips commands flagged hidden', async () => { + const program = buildSampleProgram() + const stack = program.commands.find((c) => c.name() === 'stack')! + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(stack as any)._hidden = true + const result = await enumerate(program, ['']) + assert.ok(!result.candidates.includes('stack')) + assert.ok(result.candidates.includes('cloud')) + }) +}) + +describe('enumerate -- dynamic completer registry', () => { + it('invokes the registered completer when the previous word is its flag', async () => { + const calls: string[] = [] + const registry: DynamicCompleterRegistry = { + get (flag) { + if (flag === '--use-context') { + return () => { + calls.push(flag) + return ['local', 'staging', 'prod'] + } + } + return undefined + }, + } + const result = await enumerate(buildSampleProgram(), ['--use-context', ''], registry) + assert.deepEqual(result.candidates, ['local', 'staging', 'prod']) + assert.deepEqual(calls, ['--use-context']) + }) + + it('prefix-filters dynamic candidates against the incomplete word', async () => { + const registry: DynamicCompleterRegistry = { + get: (flag) => flag === '--use-context' ? () => ['local', 'staging', 'prod'] : undefined, + } + const result = await enumerate(buildSampleProgram(), ['--use-context', 'st'], registry) + assert.deepEqual(result.candidates, ['staging']) + }) + + it('returns empty when previous word is an unknown dynamic flag', async () => { + const registry: DynamicCompleterRegistry = { + get: () => undefined, + } + const result = await enumerate(buildSampleProgram(), ['--use-context', ''], registry) + assert.deepEqual(result.candidates, []) + }) + + it('supports the --flag=value form via DIRECTIVE_NO_SPACE', async () => { + const registry: DynamicCompleterRegistry = { + get: (flag) => flag === '--use-context' ? () => ['local', 'staging'] : undefined, + } + const result = await enumerate(buildSampleProgram(), ['--use-context=st'], registry) + assert.deepEqual(result.candidates, ['staging']) + assert.equal(result.directive & DIRECTIVE_NO_SPACE, DIRECTIVE_NO_SPACE) + }) + + it('returns empty (not throw) when completer rejects', async () => { + const registry: DynamicCompleterRegistry = { + get: () => () => { throw new Error('boom') }, + } + const result = await enumerate(buildSampleProgram(), ['--use-context', ''], registry) + assert.deepEqual(result.candidates, []) + }) + + it('returns empty when completer returns a non-array (defensive)', async () => { + const registry: DynamicCompleterRegistry = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get: () => (() => null) as any, + } + const result = await enumerate(buildSampleProgram(), ['--use-context', ''], registry) + assert.deepEqual(result.candidates, []) + }) + + it('returns empty when previous word is an unregistered long flag', async () => { + const registry: DynamicCompleterRegistry = { get: () => undefined } + const result = await enumerate(buildSampleProgram(), ['--unknown-flag', ''], registry) + assert.deepEqual(result.candidates, []) + }) +}) + +describe('enumerate -- value-taking flag walk', () => { + it('skips the value of a value-taking flag and still descends into es', async () => { + // `--use-context staging` should not interfere with walking into es. + const result = await enumerate(buildSampleProgram(), ['--use-context', 'staging', 'stack', 'es', '']) + assert.ok(result.candidates.includes('info')) + assert.ok(result.candidates.includes('search')) + }) + + it('does not skip the next word after a boolean flag', async () => { + // `--json` does not take a value; the next word IS the first positional. + const result = await enumerate(buildSampleProgram(), ['--json', 'stack', 'es', '']) + assert.ok(result.candidates.includes('info')) + }) + + it('does not double-skip when flag is given in --flag=value form', async () => { + const result = await enumerate(buildSampleProgram(), ['--use-context=staging', 'stack', 'es', '']) + assert.ok(result.candidates.includes('info')) + }) + + it('recognises a short flag as value-taking', async () => { + // `-i my-index` (short of --index, registered on es search) takes a value. + const result = await enumerate(buildSampleProgram(), ['stack', 'es', 'search', '-i', 'idx', '--']) + // After consuming -i and its value, we're at "search" with incomplete "--", + // so we should see flag candidates for the search command. + assert.ok(result.candidates.includes('--size'), + `expected --size in candidates, got ${result.candidates.join(',')}`) + }) +}) + +describe('enumerate -- edge cases', () => { + it('treats an unmatched prefix word as the current incomplete word', async () => { + const result = await enumerate(buildSampleProgram(), ['zzz']) + assert.deepEqual(result.candidates, []) + }) + + it('handles single dash gracefully (no candidates)', async () => { + const result = await enumerate(buildSampleProgram(), ['-']) + assert.deepEqual(result.candidates, []) + }) + + it('returns subcommands again after a successful partial group match (no descent)', async () => { + const result = await enumerate(buildSampleProgram(), ['sta']) + assert.deepEqual(result.candidates, ['stack']) + }) +}) diff --git a/test/completion/registry.test.ts b/test/completion/registry.test.ts new file mode 100644 index 00000000..73d350f6 --- /dev/null +++ b/test/completion/registry.test.ts @@ -0,0 +1,28 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { defaultRegistry } from '../../src/completion/registry.ts' + +describe('defaultRegistry', () => { + it('registers a completer for --use-context', () => { + assert.equal(typeof defaultRegistry.get('--use-context'), 'function') + }) + + it('returns undefined for unregistered flags', () => { + assert.equal(defaultRegistry.get('--json'), undefined) + assert.equal(defaultRegistry.get('--index'), undefined) + assert.equal(defaultRegistry.get(''), undefined) + }) + + it('the --use-context completer returns an array of strings (or empty)', async () => { + const completer = defaultRegistry.get('--use-context') + assert.ok(completer != null) + const result = await completer() + assert.ok(Array.isArray(result)) + for (const name of result) assert.equal(typeof name, 'string') + }) +}) diff --git a/test/completion/wrappers.test.ts b/test/completion/wrappers.test.ts new file mode 100644 index 00000000..8230b816 --- /dev/null +++ b/test/completion/wrappers.test.ts @@ -0,0 +1,111 @@ +/** + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { writeFileSync, mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { bashWrapper } from '../../src/completion/shells/bash.ts' +import { zshWrapper } from '../../src/completion/shells/zsh.ts' +import { fishWrapper } from '../../src/completion/shells/fish.ts' + +describe('bashWrapper', () => { + it('is a non-empty string containing the bash completion glue', () => { + const script = bashWrapper() + assert.ok(typeof script === 'string') + assert.ok(script.length > 0) + assert.ok(script.includes('complete -F'), 'expects a complete -F line') + assert.ok(script.includes('__complete'), 'expects to invoke the hidden __complete command') + assert.ok(script.includes('elastic'), 'expects to bind the elastic command name') + }) + + it('passes "--" before the argument list', () => { + assert.ok(bashWrapper().includes(' -- '), 'must pass -- to disable option parsing') + }) + + it('emits valid bash syntax (bash -n)', () => { + if (process.platform === 'win32') return + const which = spawnSync('which', ['bash']) + if (which.status !== 0) return + const dir = mkdtempSync(join(tmpdir(), 'elastic-bash-wrapper-')) + try { + const path = join(dir, 'completion.bash') + writeFileSync(path, bashWrapper(), 'utf-8') + const result = spawnSync('bash', ['-n', path], { encoding: 'utf-8' }) + assert.equal(result.status, 0, `bash -n failed: ${result.stderr}`) + } finally { + rmSync(dir, { recursive: true }) + } + }) +}) + +describe('zshWrapper', () => { + it('is a non-empty string starting with #compdef', () => { + const script = zshWrapper() + assert.ok(script.startsWith('#compdef elastic'), + `expected #compdef header, got: ${script.slice(0, 40)}`) + }) + + it('binds completion via compdef', () => { + assert.ok(zshWrapper().includes('compdef '), 'expected compdef binding') + }) + + it('passes "--" before the argument list', () => { + assert.ok(zshWrapper().includes(' -- ')) + }) + + it('emits valid zsh syntax (zsh -n)', () => { + if (process.platform === 'win32') return + const which = spawnSync('which', ['zsh']) + if (which.status !== 0) return // zsh not installed; skip + const dir = mkdtempSync(join(tmpdir(), 'elastic-zsh-wrapper-')) + try { + const path = join(dir, '_elastic') + writeFileSync(path, zshWrapper(), 'utf-8') + const result = spawnSync('zsh', ['-n', path], { encoding: 'utf-8' }) + assert.equal(result.status, 0, `zsh -n failed: ${result.stderr}`) + } finally { + rmSync(dir, { recursive: true }) + } + }) +}) + +describe('fishWrapper', () => { + it('is a non-empty string with a complete -c elastic line', () => { + const script = fishWrapper() + assert.ok(typeof script === 'string') + assert.ok(script.includes('complete -c elastic'), + 'expected "complete -c elastic" registration') + }) + + it('passes "--" before the argument list', () => { + assert.ok(fishWrapper().includes(' -- ')) + }) + + it('emits valid fish syntax (fish -n)', () => { + if (process.platform === 'win32') return + const which = spawnSync('which', ['fish']) + if (which.status !== 0) return // fish not installed; skip + const dir = mkdtempSync(join(tmpdir(), 'elastic-fish-wrapper-')) + try { + const path = join(dir, 'elastic.fish') + writeFileSync(path, fishWrapper(), 'utf-8') + const result = spawnSync('fish', ['-n', path], { encoding: 'utf-8' }) + assert.equal(result.status, 0, `fish -n failed: ${result.stderr}`) + } finally { + rmSync(dir, { recursive: true }) + } + }) +}) + +describe('wrappers -- common shape', () => { + it('all wrappers begin with a comment header naming the shell', () => { + assert.match(bashWrapper(), /^#.*[Bb]ash/m) + assert.match(zshWrapper(), /^#.*[Zz]sh/m) + assert.match(fishWrapper(), /^#.*[Ff]ish/m) + }) +}) From efe7b70dc4c74f2d681172aa28e89d78e4753915 Mon Sep 17 00:00:00 2001 From: Laura Trotta Date: Tue, 19 May 2026 17:32:05 +0200 Subject: [PATCH 02/10] fix spdx headers --- src/completion/argv-aliases.ts | 2 +- src/completion/command.ts | 2 +- src/completion/complete.ts | 2 +- src/completion/completers/context-names.ts | 2 +- src/completion/enumerate.ts | 2 +- src/completion/index.ts | 2 +- src/completion/registry.ts | 2 +- src/completion/shells/bash.ts | 2 +- src/completion/shells/fish.ts | 2 +- src/completion/shells/zsh.ts | 2 +- test/completion-cli.test.ts | 2 +- test/completion/argv-aliases.test.ts | 2 +- test/completion/command.test.ts | 2 +- test/completion/complete.test.ts | 2 +- test/completion/context-names.test.ts | 2 +- test/completion/enumerate.test.ts | 2 +- test/completion/registry.test.ts | 2 +- test/completion/wrappers.test.ts | 2 +- 18 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/completion/argv-aliases.ts b/src/completion/argv-aliases.ts index 351cbf39..3d95d945 100644 --- a/src/completion/argv-aliases.ts +++ b/src/completion/argv-aliases.ts @@ -1,4 +1,4 @@ -/** +/* * Copyright Elasticsearch B.V. and contributors * SPDX-License-Identifier: Apache-2.0 */ diff --git a/src/completion/command.ts b/src/completion/command.ts index d9477b5b..3b238a22 100644 --- a/src/completion/command.ts +++ b/src/completion/command.ts @@ -1,4 +1,4 @@ -/** +/* * Copyright Elasticsearch B.V. and contributors * SPDX-License-Identifier: Apache-2.0 */ diff --git a/src/completion/complete.ts b/src/completion/complete.ts index 56423744..ed172078 100644 --- a/src/completion/complete.ts +++ b/src/completion/complete.ts @@ -1,4 +1,4 @@ -/** +/* * Copyright Elasticsearch B.V. and contributors * SPDX-License-Identifier: Apache-2.0 */ diff --git a/src/completion/completers/context-names.ts b/src/completion/completers/context-names.ts index 58650697..4418473d 100644 --- a/src/completion/completers/context-names.ts +++ b/src/completion/completers/context-names.ts @@ -1,4 +1,4 @@ -/** +/* * Copyright Elasticsearch B.V. and contributors * SPDX-License-Identifier: Apache-2.0 */ diff --git a/src/completion/enumerate.ts b/src/completion/enumerate.ts index fad7a0a8..ae26cc56 100644 --- a/src/completion/enumerate.ts +++ b/src/completion/enumerate.ts @@ -1,4 +1,4 @@ -/** +/* * Copyright Elasticsearch B.V. and contributors * SPDX-License-Identifier: Apache-2.0 */ diff --git a/src/completion/index.ts b/src/completion/index.ts index 6ef5d221..1fad3fb3 100644 --- a/src/completion/index.ts +++ b/src/completion/index.ts @@ -1,4 +1,4 @@ -/** +/* * Copyright Elasticsearch B.V. and contributors * SPDX-License-Identifier: Apache-2.0 */ diff --git a/src/completion/registry.ts b/src/completion/registry.ts index c6862554..a498f481 100644 --- a/src/completion/registry.ts +++ b/src/completion/registry.ts @@ -1,4 +1,4 @@ -/** +/* * Copyright Elasticsearch B.V. and contributors * SPDX-License-Identifier: Apache-2.0 */ diff --git a/src/completion/shells/bash.ts b/src/completion/shells/bash.ts index 4a10c4a3..27b08e0d 100644 --- a/src/completion/shells/bash.ts +++ b/src/completion/shells/bash.ts @@ -1,4 +1,4 @@ -/** +/* * Copyright Elasticsearch B.V. and contributors * SPDX-License-Identifier: Apache-2.0 */ diff --git a/src/completion/shells/fish.ts b/src/completion/shells/fish.ts index fe514f6e..aa5e0f89 100644 --- a/src/completion/shells/fish.ts +++ b/src/completion/shells/fish.ts @@ -1,4 +1,4 @@ -/** +/* * Copyright Elasticsearch B.V. and contributors * SPDX-License-Identifier: Apache-2.0 */ diff --git a/src/completion/shells/zsh.ts b/src/completion/shells/zsh.ts index be6f7ec2..79ee9890 100644 --- a/src/completion/shells/zsh.ts +++ b/src/completion/shells/zsh.ts @@ -1,4 +1,4 @@ -/** +/* * Copyright Elasticsearch B.V. and contributors * SPDX-License-Identifier: Apache-2.0 */ diff --git a/test/completion-cli.test.ts b/test/completion-cli.test.ts index 201a8158..1513e37d 100644 --- a/test/completion-cli.test.ts +++ b/test/completion-cli.test.ts @@ -1,4 +1,4 @@ -/** +/* * Copyright Elasticsearch B.V. and contributors * SPDX-License-Identifier: Apache-2.0 */ diff --git a/test/completion/argv-aliases.test.ts b/test/completion/argv-aliases.test.ts index d97ef6a3..50227d28 100644 --- a/test/completion/argv-aliases.test.ts +++ b/test/completion/argv-aliases.test.ts @@ -1,4 +1,4 @@ -/** +/* * Copyright Elasticsearch B.V. and contributors * SPDX-License-Identifier: Apache-2.0 */ diff --git a/test/completion/command.test.ts b/test/completion/command.test.ts index 3958da28..0ac44f68 100644 --- a/test/completion/command.test.ts +++ b/test/completion/command.test.ts @@ -1,4 +1,4 @@ -/** +/* * Copyright Elasticsearch B.V. and contributors * SPDX-License-Identifier: Apache-2.0 */ diff --git a/test/completion/complete.test.ts b/test/completion/complete.test.ts index a60d0ca7..0520dcaa 100644 --- a/test/completion/complete.test.ts +++ b/test/completion/complete.test.ts @@ -1,4 +1,4 @@ -/** +/* * Copyright Elasticsearch B.V. and contributors * SPDX-License-Identifier: Apache-2.0 */ diff --git a/test/completion/context-names.test.ts b/test/completion/context-names.test.ts index cfd7d4c3..596112e8 100644 --- a/test/completion/context-names.test.ts +++ b/test/completion/context-names.test.ts @@ -1,4 +1,4 @@ -/** +/* * Copyright Elasticsearch B.V. and contributors * SPDX-License-Identifier: Apache-2.0 */ diff --git a/test/completion/enumerate.test.ts b/test/completion/enumerate.test.ts index 1898d6d2..976e28ba 100644 --- a/test/completion/enumerate.test.ts +++ b/test/completion/enumerate.test.ts @@ -1,4 +1,4 @@ -/** +/* * Copyright Elasticsearch B.V. and contributors * SPDX-License-Identifier: Apache-2.0 */ diff --git a/test/completion/registry.test.ts b/test/completion/registry.test.ts index 73d350f6..93a867e3 100644 --- a/test/completion/registry.test.ts +++ b/test/completion/registry.test.ts @@ -1,4 +1,4 @@ -/** +/* * Copyright Elasticsearch B.V. and contributors * SPDX-License-Identifier: Apache-2.0 */ diff --git a/test/completion/wrappers.test.ts b/test/completion/wrappers.test.ts index 8230b816..757ed444 100644 --- a/test/completion/wrappers.test.ts +++ b/test/completion/wrappers.test.ts @@ -1,4 +1,4 @@ -/** +/* * Copyright Elasticsearch B.V. and contributors * SPDX-License-Identifier: Apache-2.0 */ From 200cabeca8552bd5c872c9eb291f8db42411d253 Mon Sep 17 00:00:00 2001 From: Laura Trotta Date: Tue, 19 May 2026 18:01:41 +0200 Subject: [PATCH 03/10] test(completion): spawn parent runtime instead of tsx shim for Bun compat The completion end-to-end suite spawned `node_modules/.bin/tsx` directly, which works under `npm test` but breaks under `bun test`: Bun's child process env corrupts tsx's dynamic `import()` so the `es/register.ts` subtree fails to load, leaving `__complete -- es ""` with zero candidates. Mirror the pattern in test/es/register.test.ts: invoke `process.execPath` (the parent runtime's own binary) and only add the `tsx/esm` loader on Node. Bun evaluates the entry `.ts` natively. The cascading "describe() inside another test()" errors that followed in Bun's log were a side effect of the test failure -- once the assertion no longer trips inside the outer `describe`, Bun's runner can close the scope and the rest of the suite proceeds. Co-authored-by: Cursor --- test/completion-cli.test.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/test/completion-cli.test.ts b/test/completion-cli.test.ts index 1513e37d..306add9a 100644 --- a/test/completion-cli.test.ts +++ b/test/completion-cli.test.ts @@ -11,12 +11,19 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' const CLI_ENTRY = join(process.cwd(), 'src', 'cli.ts') -const TSX_BIN = join(process.cwd(), 'node_modules', '.bin', 'tsx') +const IS_BUN = 'bun' in process.versions /** - * Spawns the CLI directly from source via tsx so the tests do not depend on - * a fresh `dist/` build. This is slower per-spawn than the production - * `dist/cli.js` path but exercises the same end-to-end protocol. + * Spawns the CLI directly from source so the tests do not depend on a fresh + * `dist/` build. Uses `process.execPath` to invoke the same runtime as the + * parent (matches the cross-runtime pattern in test/es/register.test.ts): + * - Node: requires the `tsx/esm` loader to evaluate `.ts` entry points. + * - Bun: understands TypeScript natively; spawning `node_modules/.bin/tsx` + * through Bun's child-process env corrupts dynamic `import()` for + * heavier subtrees (e.g. `es/register.ts`), so we sidestep it. + * + * Slower per-spawn than `node dist/cli.js`, but exercises the source path + * and the same end-to-end completion protocol. */ function runCli ( args: string[], @@ -31,7 +38,10 @@ function runCli ( // the child) from leaking partial-function coverage into the parent's // report and dragging the project's averages below the 90% threshold. const childEnv = { ...process.env, NODE_V8_COVERAGE: '', ...env } - const child = spawn(TSX_BIN, [CLI_ENTRY, ...args], { + const runtimeArgs = IS_BUN + ? [CLI_ENTRY, ...args] + : ['--import', 'tsx/esm', CLI_ENTRY, ...args] + const child = spawn(process.execPath, runtimeArgs, { stdio: ['pipe', 'pipe', 'pipe'], env: childEnv, }) From 7d9b7e2221459fc149cbc7b9248fe9807c995863 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 16:24:53 +0000 Subject: [PATCH 04/10] fix(completion): avoid resolving config during completion Agent-Logs-Url: https://github.com/elastic/cli/sessions/9e295931-2da1-4a2f-9ae8-43419f0cd8cb --- src/completion/complete.ts | 55 +++++++++++++++++++++++++++++--- test/completion/complete.test.ts | 30 +++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/completion/complete.ts b/src/completion/complete.ts index ed172078..ecfe8c5d 100644 --- a/src/completion/complete.ts +++ b/src/completion/complete.ts @@ -35,12 +35,60 @@ import type { OpaqueCommandHandle } from '../factory.ts' import { rewriteTopLevelAliases } from './argv-aliases.ts' import { enumerate, DIRECTIVE_NO_FILE_COMP } from './enumerate.ts' import { defaultRegistry } from './registry.ts' -import { loadConfig } from '../config/loader.ts' +import { + discoverConfigFile, + loadConfigFile, + resolveEffectiveCommands, +} from '../config/loader.ts' +import { StructuralConfigSchema, CommandPolicySchema } from '../config/schema.ts' +import type { CommandPolicy } from '../config/types.ts' /** Words recognised as the user-facing form of the `stack es` subtree. */ const ES_ALIASES = new Set(['es', 'elasticsearch']) /** Words recognised as the user-facing form of the `stack kb` subtree. */ const KB_ALIASES = new Set(['kb', 'kibana']) +const ENV_CONFIG_FILE = 'ELASTIC_CLI_CONFIG_FILE' + +async function loadCompletionCommandPolicy (): Promise { + const envPath = process.env[ENV_CONFIG_FILE] + const path = envPath != null && envPath.length > 0 + ? envPath + : await discoverConfigFile() + if (path == null) return undefined + + const raw = await loadConfigFile(path) + const structural = StructuralConfigSchema.safeParse(raw) + if (!structural.success) return undefined + + const { current_context, contexts, commands: rawRootCommands, default_profile: rawDefaultProfile } = structural.data + const rawContext = contexts[current_context] + if (rawContext == null) return undefined + + let defaultProfile + if (rawDefaultProfile != null) { + const defaultProfileParsed = CommandPolicySchema.shape.profile.safeParse(rawDefaultProfile) + if (!defaultProfileParsed.success) return undefined + defaultProfile = defaultProfileParsed.data + } + + let rootCommands: CommandPolicy | undefined + if (rawRootCommands != null) { + const rootCommandsParsed = CommandPolicySchema.safeParse(rawRootCommands) + if (!rootCommandsParsed.success) return undefined + rootCommands = rootCommandsParsed.data + } + + let contextCommands: CommandPolicy | undefined + if (rawContext.commands != null) { + const contextCommandsParsed = CommandPolicySchema.safeParse(rawContext.commands) + if (!contextCommandsParsed.success) return undefined + contextCommands = contextCommandsParsed.data + } + + const effective = resolveEffectiveCommands(contextCommands, rootCommands, defaultProfile, undefined) + if ('error' in effective) return undefined + return effective.commands +} /** * Constructs the `stack` group with conditional loading of the es/kb subtrees. @@ -193,10 +241,7 @@ export async function handleComplete ( // (missing config, invalid YAML, network-bound expression) is silently // ignored — completion availability must not depend on a working config. try { - const config = await loadConfig({}) - if (config.ok && config.value.commands != null) { - hideBlockedCommands(root, config.value.commands) - } + hideBlockedCommands(root, await loadCompletionCommandPolicy()) } catch { /* swallowed by design */ } const result = await enumerate(root, rewritten, defaultRegistry) diff --git a/test/completion/complete.test.ts b/test/completion/complete.test.ts index 0520dcaa..2ec62d5b 100644 --- a/test/completion/complete.test.ts +++ b/test/completion/complete.test.ts @@ -140,6 +140,36 @@ describe('handleComplete -- policy enforcement', () => { assert.ok(out.candidates.includes('stack')) assert.ok(out.candidates.includes('version')) }) + + it('applies blocked commands without resolving active-context expressions', async () => { + const { mkdtemp, writeFile, rm } = await import('node:fs/promises') + const { tmpdir } = await import('node:os') + const { join } = await import('node:path') + const dir = await mkdtemp(join(tmpdir(), 'elastic-cli-policy-')) + const path = join(dir, 'config.yml') + await writeFile(path, [ + 'current_context: local', + 'commands:', + ' blocked:', + ' - sanitize.*', + 'contexts:', + ' local:', + ' elasticsearch:', + ' url: $(env:ELASTIC_COMPLETION_MISSING_URL)', + '', + ].join('\n')) + process.env['ELASTIC_CLI_CONFIG_FILE'] = path + delete process.env['ELASTIC_COMPLETION_MISSING_URL'] + + const buf = bufferedWriter() + await handleComplete([''], buf.write) + await rm(dir, { recursive: true }) + + const out = parseOutput(buf.chunks.join('')) + assert.ok(!out.candidates.includes('sanitize'), + `sanitize should be hidden by policy even with unresolved expressions; got: ${out.candidates.join(',')}`) + assert.ok(out.candidates.includes('stack')) + }) }) describe('handleComplete -- stdout protocol', () => { From 189a2154038cf1220aa1f38fdeb8ec6b7f942807 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 16:26:35 +0000 Subject: [PATCH 05/10] test(completion): always clean up policy fixtures Agent-Logs-Url: https://github.com/elastic/cli/sessions/9e295931-2da1-4a2f-9ae8-43419f0cd8cb --- src/completion/complete.ts | 3 ++- test/completion/complete.test.ts | 40 ++++++++++++++++++-------------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/completion/complete.ts b/src/completion/complete.ts index ecfe8c5d..c5c4a606 100644 --- a/src/completion/complete.ts +++ b/src/completion/complete.ts @@ -85,7 +85,8 @@ async function loadCompletionCommandPolicy (): Promise { ].join('\n')) process.env['ELASTIC_CLI_CONFIG_FILE'] = path - const buf = bufferedWriter() - await handleComplete([''], buf.write) - await rm(dir, { recursive: true }) + try { + const buf = bufferedWriter() + await handleComplete([''], buf.write) - const out = parseOutput(buf.chunks.join('')) - // Top-level group with all children blocked should be hidden entirely. - assert.ok(!out.candidates.includes('sanitize'), - `sanitize should be hidden by policy; got: ${out.candidates.join(',')}`) - // Groups not blocked should still appear. - assert.ok(out.candidates.includes('stack')) - assert.ok(out.candidates.includes('version')) + const out = parseOutput(buf.chunks.join('')) + // Top-level group with all children blocked should be hidden entirely. + assert.ok(!out.candidates.includes('sanitize'), + `sanitize should be hidden by policy; got: ${out.candidates.join(',')}`) + // Groups not blocked should still appear. + assert.ok(out.candidates.includes('stack')) + assert.ok(out.candidates.includes('version')) + } finally { + await rm(dir, { recursive: true }) + } }) it('applies blocked commands without resolving active-context expressions', async () => { @@ -161,14 +164,17 @@ describe('handleComplete -- policy enforcement', () => { process.env['ELASTIC_CLI_CONFIG_FILE'] = path delete process.env['ELASTIC_COMPLETION_MISSING_URL'] - const buf = bufferedWriter() - await handleComplete([''], buf.write) - await rm(dir, { recursive: true }) + try { + const buf = bufferedWriter() + await handleComplete([''], buf.write) - const out = parseOutput(buf.chunks.join('')) - assert.ok(!out.candidates.includes('sanitize'), - `sanitize should be hidden by policy even with unresolved expressions; got: ${out.candidates.join(',')}`) - assert.ok(out.candidates.includes('stack')) + const out = parseOutput(buf.chunks.join('')) + assert.ok(!out.candidates.includes('sanitize'), + `sanitize should be hidden by policy even with unresolved expressions; got: ${out.candidates.join(',')}`) + assert.ok(out.candidates.includes('stack')) + } finally { + await rm(dir, { recursive: true }) + } }) }) From fe4bd7339fbcd864f8839f871d6600c08169dd67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 16:27:43 +0000 Subject: [PATCH 06/10] refactor(completion): clarify structural policy loading Agent-Logs-Url: https://github.com/elastic/cli/sessions/9e295931-2da1-4a2f-9ae8-43419f0cd8cb --- src/completion/complete.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/completion/complete.ts b/src/completion/complete.ts index c5c4a606..722fc152 100644 --- a/src/completion/complete.ts +++ b/src/completion/complete.ts @@ -49,6 +49,15 @@ const ES_ALIASES = new Set(['es', 'elasticsearch']) const KB_ALIASES = new Set(['kb', 'kibana']) const ENV_CONFIG_FILE = 'ELASTIC_CLI_CONFIG_FILE' +/** + * Loads the effective command policy for completion without resolving config + * expressions. + * + * The completion path only needs command visibility rules, so it reuses the + * structural config parse plus direct `CommandPolicySchema` validation on the + * root and active-context `commands` fields. Any missing file, invalid config, + * or invalid policy returns `undefined` so shell completion remains best-effort. + */ async function loadCompletionCommandPolicy (): Promise { const envPath = process.env[ENV_CONFIG_FILE] const path = envPath != null && envPath.length > 0 @@ -60,13 +69,13 @@ async function loadCompletionCommandPolicy (): Promise Date: Tue, 19 May 2026 16:28:46 +0000 Subject: [PATCH 07/10] refactor(completion): align helper naming Agent-Logs-Url: https://github.com/elastic/cli/sessions/9e295931-2da1-4a2f-9ae8-43419f0cd8cb --- src/completion/complete.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/completion/complete.ts b/src/completion/complete.ts index 722fc152..6a786928 100644 --- a/src/completion/complete.ts +++ b/src/completion/complete.ts @@ -69,13 +69,13 @@ async function loadCompletionCommandPolicy (): Promise Date: Tue, 19 May 2026 16:34:41 +0000 Subject: [PATCH 08/10] fix(cli): avoid matching option values in alias rewriting Replace brittle indexOf search with option-aware argv scanner to prevent matching option values that coincidentally equal alias names (e.g., --command-profile es). Add regression tests for both es and kb aliases. Agent-Logs-Url: https://github.com/elastic/cli/sessions/e4b78585-92b4-46f4-947a-802b9e04316b --- src/cli.ts | 37 +++++++++++++++++++++++-- test/cli.test.ts | 72 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 6617be82..fa2873e9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -105,9 +105,42 @@ let firstArg = operands[0] // consistent (e.g. policy entries still use `stack.es.*`). const aliasRewritten = rewriteTopLevelAliases(operands) if (aliasRewritten.length !== operands.length) { + // Find the position of the first operand in process.argv by scanning past + // options and their values. We can't use indexOf because an option value + // might coincidentally equal the alias (e.g. --command-profile es). const original = firstArg! - const idx = process.argv.indexOf(original, 2) - if (idx !== -1) process.argv.splice(idx, 0, 'stack') + let idx = 2 // start after 'node' and script name + while (idx < process.argv.length) { + const arg = process.argv[idx]! + if (arg === original) { + // Found the first operand + process.argv.splice(idx, 0, 'stack') + break + } + // Skip this argument + idx++ + // If it's an option that takes a value, skip the value too + if (arg.startsWith('-') && !arg.startsWith('--') && arg.length > 2) { + // Short option cluster like -abc, no value to skip + continue + } + if (arg.startsWith('--')) { + const eqIdx = arg.indexOf('=') + if (eqIdx === -1) { + // Long option without =, might have a separate value argument + // Check if this is a known option that takes a value + const optName = arg + const knownValueOptions = new Set([ + '--config-file', '--use-context', '--command-profile', + '--output-fields', '--output-template' + ]) + if (knownValueOptions.has(optName)) { + idx++ // skip the value + } + } + // else: --option=value format, value is already part of this arg + } + } operands.splice(0, 0, 'stack') firstArg = 'stack' } diff --git a/test/cli.test.ts b/test/cli.test.ts index 4d0b940f..b5b510e1 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -6,7 +6,7 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { spawn } from 'node:child_process' -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { createRequire } from 'node:module' @@ -322,3 +322,73 @@ describe('elastic CLI -- stack command tree', () => { } }) }) + +describe('elastic CLI -- alias rewriting with option values', () => { + it('correctly rewrites es alias when --command-profile value is also "es"', async () => { + const dir = await mkdtemp(join(tmpdir(), 'elastic-cli-alias-')) + const configPath = join(dir, 'config.yml') + await writeFile(configPath, [ + 'current_context: local', + 'contexts:', + ' local:', + ' elasticsearch:', + ' url: http://localhost:9200', + '', + ].join('\n')) + + try { + const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => { + const proc = spawn( + process.execPath, + ['dist/cli.js', '--config-file', configPath, '--command-profile', 'es', 'es', '--help'], + { cwd: join(dirname(fileURLToPath(import.meta.url)), '..') } + ) + let stdout = '' + let stderr = '' + proc.stdout.on('data', (chunk) => { stdout += chunk }) + proc.stderr.on('data', (chunk) => { stderr += chunk }) + proc.on('close', (code) => { resolve({ stdout, stderr, code }) }) + }) + + // Should show es help, not error about invalid command + assert.match(result.stdout, /Interact with the Elasticsearch API/) + assert.equal(result.code, 0) + } finally { + await rm(dir, { recursive: true }) + } + }) + + it('correctly rewrites kb alias when --config-file value is "kb"', async () => { + const dir = await mkdtemp(join(tmpdir(), 'elastic-cli-alias-')) + const configPath = join(dir, 'kb') // config file named "kb" + await writeFile(configPath, [ + 'current_context: local', + 'contexts:', + ' local:', + ' kibana:', + ' url: http://localhost:5601', + '', + ].join('\n')) + + try { + const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => { + const proc = spawn( + process.execPath, + ['dist/cli.js', '--config-file', configPath, 'kb', '--help'], + { cwd: join(dirname(fileURLToPath(import.meta.url)), '..') } + ) + let stdout = '' + let stderr = '' + proc.stdout.on('data', (chunk) => { stdout += chunk }) + proc.stderr.on('data', (chunk) => { stderr += chunk }) + proc.on('close', (code) => { resolve({ stdout, stderr, code }) }) + }) + + // Should show kb help, not error about invalid command + assert.match(result.stdout, /Interact with the Kibana API/) + assert.equal(result.code, 0) + } finally { + await rm(dir, { recursive: true }) + } + }) +}) From e576c1270ed1d48c8c7d9147e6d3e104f5f1e124 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 16:36:02 +0000 Subject: [PATCH 09/10] refactor(cli): derive value-taking options from program definition Replace hardcoded option list with dynamic lookup from Commander's option definitions to ensure the argv scanner stays in sync with actual CLI options. Agent-Logs-Url: https://github.com/elastic/cli/sessions/e4b78585-92b4-46f4-947a-802b9e04316b --- src/cli.ts | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index fa2873e9..f345d254 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -109,6 +109,17 @@ if (aliasRewritten.length !== operands.length) { // options and their values. We can't use indexOf because an option value // might coincidentally equal the alias (e.g. --command-profile es). const original = firstArg! + + // Build set of options that take values from the program's option definitions + const valueOptions = new Set() + for (const opt of program.options) { + if (opt.required || opt.optional) { + // Option takes a value + if (opt.long) valueOptions.add(opt.long) + if (opt.short) valueOptions.add(opt.short) + } + } + let idx = 2 // start after 'node' and script name while (idx < process.argv.length) { const arg = process.argv[idx]! @@ -120,21 +131,11 @@ if (aliasRewritten.length !== operands.length) { // Skip this argument idx++ // If it's an option that takes a value, skip the value too - if (arg.startsWith('-') && !arg.startsWith('--') && arg.length > 2) { - // Short option cluster like -abc, no value to skip - continue - } - if (arg.startsWith('--')) { + if (arg.startsWith('-')) { const eqIdx = arg.indexOf('=') if (eqIdx === -1) { - // Long option without =, might have a separate value argument - // Check if this is a known option that takes a value - const optName = arg - const knownValueOptions = new Set([ - '--config-file', '--use-context', '--command-profile', - '--output-fields', '--output-template' - ]) - if (knownValueOptions.has(optName)) { + // No = sign, check if this option takes a value + if (valueOptions.has(arg)) { idx++ // skip the value } } From 8e44657a534b7948c594afe4c1d848b44b072876 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 16:37:14 +0000 Subject: [PATCH 10/10] refactor(cli): add defensive null check in argv scanner Agent-Logs-Url: https://github.com/elastic/cli/sessions/e4b78585-92b4-46f4-947a-802b9e04316b --- src/cli.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index f345d254..584c799b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -122,7 +122,8 @@ if (aliasRewritten.length !== operands.length) { let idx = 2 // start after 'node' and script name while (idx < process.argv.length) { - const arg = process.argv[idx]! + const arg = process.argv[idx] + if (arg == null) break // defensive: should never happen given loop condition if (arg === original) { // Found the first operand process.argv.splice(idx, 0, 'stack')