From 9e92b0b2b55a066adfceb759e2405c8a46a3f7b2 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 14:07:55 +0100 Subject: [PATCH 01/14] feat(cli): port shell completion to native TypeScript (CLI-1965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Go-binary passthrough for shell tab-completion with two native implementations: a static script generator that transcribes cobra v1.10.2's own bash/zsh/fish/powershell templates byte-for-byte (pinned against real cobra output via checked-in golden fixtures), and a dynamic __complete/__completeNoDesc responder that reimplements cobra's completion protocol by reflecting over the live legacyRoot command tree instead of shelling out to the Go binary. Deletes complete-passthrough.ts and the four Go-proxy completion handlers, removing the last dependency the completion command family had on the bundled Go binary — this was the structural blocker for trimming the Go binary (every cmd/*.go registration was load-bearing for tab completion even where the handler itself was dead). --- apps/cli/docs/go-cli-porting-status.md | 14 +- .../src/legacy/cli/complete-passthrough.ts | 64 - .../cli/complete-passthrough.unit.test.ts | 114 -- .../legacy/cli/legacy-complete.e2e.test.ts | 42 + apps/cli/src/legacy/cli/legacy-complete.ts | 671 +++++++++ .../legacy/cli/legacy-complete.unit.test.ts | 653 +++++++++ apps/cli/src/legacy/cli/main.ts | 4 +- .../commands/completion/SIDE_EFFECTS.md | 81 +- .../completion/__fixtures__/bash.desc.txt | 426 ++++++ .../completion/__fixtures__/bash.nodesc.txt | 426 ++++++ .../completion/__fixtures__/fish.desc.txt | 235 +++ .../completion/__fixtures__/fish.nodesc.txt | 235 +++ .../__fixtures__/powershell.desc.txt | 270 ++++ .../__fixtures__/powershell.nodesc.txt | 270 ++++ .../completion/__fixtures__/zsh.desc.txt | 212 +++ .../completion/__fixtures__/zsh.nodesc.txt | 212 +++ .../commands/completion/bash/bash.command.ts | 14 +- .../commands/completion/bash/bash.handler.ts | 11 +- .../completion/bash/bash.integration.test.ts | 64 +- .../commands/completion/completion.command.ts | 2 +- .../completion/completion.e2e.test.ts | 31 +- .../commands/completion/fish/fish.command.ts | 9 +- .../commands/completion/fish/fish.handler.ts | 11 +- .../completion/fish/fish.integration.test.ts | 64 +- .../completion/legacy-completion-scripts.ts | 1258 +++++++++++++++++ .../legacy-completion-scripts.unit.test.ts | 145 ++ .../powershell/powershell.command.ts | 8 +- .../powershell/powershell.handler.ts | 11 +- .../powershell/powershell.integration.test.ts | 64 +- .../commands/completion/zsh/zsh.command.ts | 15 +- .../commands/completion/zsh/zsh.handler.ts | 11 +- .../completion/zsh/zsh.integration.test.ts | 64 +- .../shared/legacy-param-introspection.ts | 98 ++ .../legacy-param-introspection.unit.test.ts | 94 ++ .../legacy-command-instrumentation.ts | 39 +- 35 files changed, 5537 insertions(+), 405 deletions(-) delete mode 100644 apps/cli/src/legacy/cli/complete-passthrough.ts delete mode 100644 apps/cli/src/legacy/cli/complete-passthrough.unit.test.ts create mode 100644 apps/cli/src/legacy/cli/legacy-complete.e2e.test.ts create mode 100644 apps/cli/src/legacy/cli/legacy-complete.ts create mode 100644 apps/cli/src/legacy/cli/legacy-complete.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/completion/__fixtures__/bash.desc.txt create mode 100644 apps/cli/src/legacy/commands/completion/__fixtures__/bash.nodesc.txt create mode 100644 apps/cli/src/legacy/commands/completion/__fixtures__/fish.desc.txt create mode 100644 apps/cli/src/legacy/commands/completion/__fixtures__/fish.nodesc.txt create mode 100644 apps/cli/src/legacy/commands/completion/__fixtures__/powershell.desc.txt create mode 100644 apps/cli/src/legacy/commands/completion/__fixtures__/powershell.nodesc.txt create mode 100644 apps/cli/src/legacy/commands/completion/__fixtures__/zsh.desc.txt create mode 100644 apps/cli/src/legacy/commands/completion/__fixtures__/zsh.nodesc.txt create mode 100644 apps/cli/src/legacy/commands/completion/legacy-completion-scripts.ts create mode 100644 apps/cli/src/legacy/commands/completion/legacy-completion-scripts.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-param-introspection.ts create mode 100644 apps/cli/src/legacy/shared/legacy-param-introspection.unit.test.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 5f461d0990..5e4d9399c5 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -193,13 +193,13 @@ These route-first equivalents are intentionally lower-level than the old Go comm ## Additional Commands -| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | -| ----------------------- | --------- | -------------------------------- | --------------------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `completion bash` | `ported` | `supabase completion bash` | `-` | `-` | Proxies verbatim to the Go binary so the emitted script is byte-identical to Cobra's output (CLI-1532). `--no-descriptions` added to match cobra's auto-registered flag (CLI-1858). | -| `completion fish` | `ported` | `supabase completion fish` | `-` | `-` | Proxies verbatim to the Go binary so the emitted script is byte-identical to Cobra's output (CLI-1532). `--no-descriptions` added to match cobra's auto-registered flag (CLI-1858). | -| `completion powershell` | `ported` | `supabase completion powershell` | `-` | `-` | Proxies verbatim to the Go binary so the emitted script is byte-identical to Cobra's output (CLI-1532). `--no-descriptions` added to match cobra's auto-registered flag (CLI-1858). | -| `completion zsh` | `ported` | `supabase completion zsh` | `-` | `-` | Proxies verbatim to the Go binary so the emitted script is byte-identical to Cobra's output (CLI-1532). `--no-descriptions` added to match cobra's auto-registered flag (CLI-1858). | -| `help` | `partial` | `supabase --help` | Go-style top-level `help` command shape | `-` | Feature parity exists via the framework-provided global `--help` flag instead of a dedicated `help` command. | +| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | +| ----------------------- | --------- | -------------------------------- | --------------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `completion bash` | `ported` | `supabase completion bash` | `-` | `-` | Generates the completion script natively in TS, byte-matching cobra v1.10.2's static template (CLI-1965); `--no-descriptions` selects the no-desc variant. Dynamic `__complete`/`__completeNoDesc` responder is also native (`legacy/cli/legacy-complete.ts`), reflecting over the live TS command tree; documented accepted gaps vs. real cobra (mutually-exclusive flag-group hiding, deprecated-command/flag filtering) live in that file's own doc comment. | +| `completion fish` | `ported` | `supabase completion fish` | `-` | `-` | Generates the completion script natively in TS, byte-matching cobra v1.10.2's static template (CLI-1965); `--no-descriptions` selects the no-desc variant. Dynamic `__complete`/`__completeNoDesc` responder is also native (`legacy/cli/legacy-complete.ts`), reflecting over the live TS command tree; documented accepted gaps vs. real cobra (mutually-exclusive flag-group hiding, deprecated-command/flag filtering) live in that file's own doc comment. | +| `completion powershell` | `ported` | `supabase completion powershell` | `-` | `-` | Generates the completion script natively in TS, byte-matching cobra v1.10.2's static template (CLI-1965); `--no-descriptions` selects the no-desc variant. Dynamic `__complete`/`__completeNoDesc` responder is also native (`legacy/cli/legacy-complete.ts`), reflecting over the live TS command tree; documented accepted gaps vs. real cobra (mutually-exclusive flag-group hiding, deprecated-command/flag filtering) live in that file's own doc comment. | +| `completion zsh` | `ported` | `supabase completion zsh` | `-` | `-` | Generates the completion script natively in TS, byte-matching cobra v1.10.2's static template (CLI-1965); `--no-descriptions` selects the no-desc variant. Dynamic `__complete`/`__completeNoDesc` responder is also native (`legacy/cli/legacy-complete.ts`), reflecting over the live TS command tree; documented accepted gaps vs. real cobra (mutually-exclusive flag-group hiding, deprecated-command/flag filtering) live in that file's own doc comment. | +| `help` | `partial` | `supabase --help` | Go-style top-level `help` command shape | `-` | Feature parity exists via the framework-provided global `--help` flag instead of a dedicated `help` command. | ## Legacy Shell Wrapping Status diff --git a/apps/cli/src/legacy/cli/complete-passthrough.ts b/apps/cli/src/legacy/cli/complete-passthrough.ts deleted file mode 100644 index 313868bc2c..0000000000 --- a/apps/cli/src/legacy/cli/complete-passthrough.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { spawnSync, type SpawnSyncReturns } from "node:child_process"; -import process from "node:process"; -import { - type BinaryResolution, - formatGoBinaryNotFoundError, - resolveBinary, -} from "../../shared/legacy/go-proxy.layer.ts"; - -export interface CompletePassthroughDeps { - readonly argv: ReadonlyArray; - readonly resolveBinary: () => BinaryResolution; - readonly spawn: (cmd: string, args: ReadonlyArray) => SpawnSyncReturns; - readonly stderrWrite: (message: string) => void; - readonly exit: (code: number) => void; -} - -/** - * Cobra-generated completion scripts (`supabase completion {bash,zsh,fish,powershell}`) - * call back into `supabase __complete ` on every tab press — or - * `supabase __completeNoDesc ` when the script was generated with - * `--no-descriptions` (`__completeNoDesc` is cobra's alias for the same hidden - * command, `ShellCompNoDescRequestCmd` in `spf13/cobra@v1.10.2/completions.go`, - * baked into the generated script at generation time). The args may include - * partial flag tokens (e.g. `--de` while the user is mid-completion of a flag - * name) that Effect's structured parser would reject. Bypass Effect entirely - * for this code path and proxy the raw argv to the bundled Go binary, which is - * the authority on completion behavior for the legacy shell. - * - * Returns `true` when the call was intercepted (caller must not continue), `false` - * otherwise. - */ -export function tryCompletePassthrough(deps: CompletePassthroughDeps): boolean { - if (deps.argv[0] !== "__complete" && deps.argv[0] !== "__completeNoDesc") return false; - - const resolved = deps.resolveBinary(); - if (!("found" in resolved)) { - deps.stderrWrite(`${formatGoBinaryNotFoundError(resolved.notFound)}\n`); - deps.exit(1); - return true; - } - - const result = deps.spawn(resolved.found, deps.argv); - if (result.error) { - deps.stderrWrite(`${result.error.message}\n`); - deps.exit(1); - return true; - } - deps.exit(result.status ?? 1); - return true; -} - -export function defaultCompletePassthroughDeps(): CompletePassthroughDeps { - return { - argv: process.argv.slice(2), - resolveBinary, - spawn: (cmd, args) => spawnSync(cmd, [...args], { stdio: "inherit" }), - stderrWrite: (message) => { - process.stderr.write(message); - }, - exit: (code) => { - process.exit(code); - }, - }; -} diff --git a/apps/cli/src/legacy/cli/complete-passthrough.unit.test.ts b/apps/cli/src/legacy/cli/complete-passthrough.unit.test.ts deleted file mode 100644 index 09f578a801..0000000000 --- a/apps/cli/src/legacy/cli/complete-passthrough.unit.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { SpawnSyncReturns } from "node:child_process"; -import { describe, expect, it, vi } from "vitest"; -import { type BinaryResolution } from "../../shared/legacy/go-proxy.layer.ts"; -import { type CompletePassthroughDeps, tryCompletePassthrough } from "./complete-passthrough.ts"; - -function spawnResult(status: number | null, error?: Error): SpawnSyncReturns { - return { - pid: 1, - output: [], - stdout: Buffer.alloc(0), - stderr: Buffer.alloc(0), - status, - signal: null, - error, - }; -} - -function makeDeps(overrides: Partial = {}): { - deps: CompletePassthroughDeps; - spawnCalls: Array<{ cmd: string; args: ReadonlyArray }>; - stderr: Array; - exits: Array; -} { - const spawnCalls: Array<{ cmd: string; args: ReadonlyArray }> = []; - const stderr: Array = []; - const exits: Array = []; - const deps: CompletePassthroughDeps = { - argv: ["__complete", "migration", "li"], - resolveBinary: (): BinaryResolution => ({ found: "/path/to/supabase-go" }), - spawn: (cmd, args) => { - spawnCalls.push({ cmd, args }); - return spawnResult(0); - }, - stderrWrite: (msg) => { - stderr.push(msg); - }, - exit: (code) => { - exits.push(code); - }, - ...overrides, - }; - return { deps, spawnCalls, stderr, exits }; -} - -describe("tryCompletePassthrough", () => { - it("returns false and does nothing when first argv is not __complete", () => { - const { deps, spawnCalls, exits } = makeDeps({ argv: ["migration", "list"] }); - expect(tryCompletePassthrough(deps)).toBe(false); - expect(spawnCalls).toEqual([]); - expect(exits).toEqual([]); - }); - - it("returns false on empty argv (e.g. bare `supabase`)", () => { - const { deps, spawnCalls, exits } = makeDeps({ argv: [] }); - expect(tryCompletePassthrough(deps)).toBe(false); - expect(spawnCalls).toEqual([]); - expect(exits).toEqual([]); - }); - - it("forwards verbatim argv (including flag-like tokens) to the Go binary on __complete", () => { - const { deps, spawnCalls, exits } = makeDeps({ - argv: ["__complete", "--debug", "migration", "--de"], - }); - expect(tryCompletePassthrough(deps)).toBe(true); - expect(spawnCalls).toEqual([ - { cmd: "/path/to/supabase-go", args: ["__complete", "--debug", "migration", "--de"] }, - ]); - expect(exits).toEqual([0]); - }); - - it("forwards verbatim argv to the Go binary on __completeNoDesc (scripts generated with --no-descriptions)", () => { - const { deps, spawnCalls, exits } = makeDeps({ - argv: ["__completeNoDesc", "migration", "li"], - }); - expect(tryCompletePassthrough(deps)).toBe(true); - expect(spawnCalls).toEqual([ - { cmd: "/path/to/supabase-go", args: ["__completeNoDesc", "migration", "li"] }, - ]); - expect(exits).toEqual([0]); - }); - - it("propagates the child's non-zero exit code", () => { - const spawn = vi.fn(() => spawnResult(7)); - const { deps, exits } = makeDeps({ spawn }); - tryCompletePassthrough(deps); - expect(exits).toEqual([7]); - }); - - it("exits 1 when the child has a null status (e.g. signal-terminated)", () => { - const spawn = vi.fn(() => spawnResult(null)); - const { deps, exits } = makeDeps({ spawn }); - tryCompletePassthrough(deps); - expect(exits).toEqual([1]); - }); - - it("prints the diagnostic and exits 1 when the Go binary cannot be resolved", () => { - const { deps, spawnCalls, stderr, exits } = makeDeps({ - resolveBinary: () => ({ notFound: ["a", "b"] }), - }); - tryCompletePassthrough(deps); - expect(spawnCalls).toEqual([]); - expect(exits).toEqual([1]); - expect(stderr).toHaveLength(1); - expect(stderr[0]).toContain("Could not find the `supabase-go` binary."); - }); - - it("prints the spawn error and exits 1 when spawnSync surfaces an error", () => { - const spawn = vi.fn(() => spawnResult(0, new Error("ENOENT"))); - const { deps, stderr, exits } = makeDeps({ spawn }); - tryCompletePassthrough(deps); - expect(exits).toEqual([1]); - expect(stderr).toEqual(["ENOENT\n"]); - }); -}); diff --git a/apps/cli/src/legacy/cli/legacy-complete.e2e.test.ts b/apps/cli/src/legacy/cli/legacy-complete.e2e.test.ts new file mode 100644 index 0000000000..f3273f388a --- /dev/null +++ b/apps/cli/src/legacy/cli/legacy-complete.e2e.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vitest"; +import { runSupabase } from "../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +describe("supabase __complete (legacy)", () => { + test( + "migration li completes to list with a description and the NoFileComp directive", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout } = await runSupabase(["__complete", "migration", "li"], { + entrypoint: "legacy", + }); + expect(exitCode).toBe(0); + const lines = stdout.trim().split("\n"); + expect(lines[0]).toBe("list\tList local and remote migrations"); + expect(lines.at(-1)).toBe(":4"); + }, + ); + + test( + "__completeNoDesc strips the description from the same candidate", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout } = await runSupabase(["__completeNoDesc", "migration", "li"], { + entrypoint: "legacy", + }); + expect(exitCode).toBe(0); + const lines = stdout.trim().split("\n"); + expect(lines[0]).toBe("list"); + expect(lines.at(-1)).toBe(":4"); + }, + ); + + test("root-level flag-name completion offers --debug", { timeout: E2E_TIMEOUT_MS }, async () => { + const { exitCode, stdout } = await runSupabase(["__complete", "--d"], { + entrypoint: "legacy", + }); + expect(exitCode).toBe(0); + expect(stdout).toContain("--debug\tOutput debug logs to stderr."); + }); +}); diff --git a/apps/cli/src/legacy/cli/legacy-complete.ts b/apps/cli/src/legacy/cli/legacy-complete.ts new file mode 100644 index 0000000000..d0ac0ad3e2 --- /dev/null +++ b/apps/cli/src/legacy/cli/legacy-complete.ts @@ -0,0 +1,671 @@ +import { Option } from "effect"; +import { GlobalFlag } from "effect/unstable/cli"; +import type { Command, Param } from "effect/unstable/cli"; +import process from "node:process"; +import { legacyUnwrapParam } from "../shared/legacy-param-introspection.ts"; + +/** + * Native TypeScript reimplementation of cobra's dynamic-completion protocol + * (`spf13/cobra@v1.10.2/completions.go`), replacing the old Go-binary + * passthrough (`complete-passthrough.ts`, deleted by CLI-1965). Cobra-generated + * completion scripts (`supabase completion {bash,zsh,fish,powershell}`) call + * back into `supabase __complete ` on every tab press — or + * `supabase __completeNoDesc ` when the script was generated with + * `--no-descriptions` (cobra's alias for the same hidden command). This module + * bypasses Effect's structured argv parser entirely for that path (the args may + * include partial/malformed flag tokens, e.g. `--de` mid-completion, that the + * parser would reject) and instead reflects directly over `legacyRoot` — the + * live Effect CLI command tree — to compute candidates. + * + * Deliberate, documented simplifications relative to real cobra (verified + * empirically against a real `apps/cli-go` build during CLI-1965 review — see + * that PR for the differential-testing detail): + * - No `--help`-style multi-paragraph usage error for zero completion args + * (`MinimumNArgs(1)` failure) — real generated shell scripts always pass at + * least one arg, so this path is realistically unreachable by real + * completion traffic. + * - No "Completion ended with directive: ..." trailer or `[Debug] [Error] ...` + * diagnostics — both are cobra-side stderr-only text every real generated + * completion script discards (`2>/dev/null` or equivalent), so reproducing + * them has zero observable effect on any user. + * - Mutually-exclusive flag-group hiding (cobra's `enforceFlagGroupsForCompletion`, + * ~45 `MarkFlagsMutuallyExclusive` call sites in `apps/cli-go/cmd/`) is not + * reproduced — there is no equivalent flag-group annotation anywhere in this + * TS tree to mirror, and hand-building a ~45-entry shadow table carries a + * materially higher transcription-error risk than the small, stable tables + * below. Accepted as a documented gap. + * - Deprecated commands/flags (cobra's `IsAvailableCommand()`/`MarkDeprecated`) + * are not filtered out of candidates — this TS tree has no "deprecated" + * concept distinct from `hidden` today (deprecation is only reflected in + * description text), so filtering it out here would require tree-level + * metadata this port doesn't own. Accepted as a documented gap, expected to + * shrink as the tree's own deprecated-alias cleanup lands separately. + */ + +/* ========================================================================== */ +/* Types */ +/* ========================================================================== */ + +export interface LegacyCompletionCandidate { + readonly name: string; + readonly description: string | undefined; +} + +export interface LegacyCompletionResult { + readonly candidates: ReadonlyArray; + readonly directive: number; +} + +/** + * The subset of cobra's `ShellCompDirective` bit flags this port ever emits. + */ +export const LegacyCompletionDirective = { + Default: 0, + NoFileComp: 4, + FilterFileExt: 8, +} as const; + +export interface LegacyFlagDescriptor { + readonly name: string; + readonly aliases: ReadonlyArray; + readonly hidden: boolean; + readonly description: string | undefined; + readonly isVariadic: boolean; + readonly isBoolean: boolean; +} + +export interface LegacyCommandPathResolution { + readonly commandChain: ReadonlyArray; + readonly matchedPath: ReadonlyArray; + readonly leftoverArgs: ReadonlyArray; +} + +export interface LegacyClassifyCompletionInput { + readonly finalCommand: Command.Command.Any; + readonly matchedPath: ReadonlyArray; + readonly leftoverArgs: ReadonlyArray; + readonly trimmedArgs: ReadonlyArray; + readonly toComplete: string; + readonly inScopeFlags: ReadonlyArray; +} + +export interface LegacyCompleteDeps { + readonly root: Command.Command.Any; + readonly argv: ReadonlyArray; + readonly env: Readonly>; + readonly stdoutWrite: (message: string) => void; + readonly exit: (code: number) => void; +} + +/* ========================================================================== */ +/* Internal command field access (`next/docs/command-docs.ts` precedent) */ +/* ========================================================================== */ + +/** + * `.config.flags` (a command's own declared flags), `.contextConfig.flags` + * (flags inherited via `Command.withSharedFlags`), and `.globalFlags` (a + * command's own declared global flags) are genuinely absent from the public + * `Command`/`Command.Any` TypeScript interface — only `name`, `description`, + * `shortDescription`, `alias`, `examples`, `subcommands`, `annotations`, and + * `hidden` are public — but they exist at runtime (`internal/command.ts`'s + * `makeCommand`, via `Object.assign`). Accessed the same way + * `next/docs/command-docs.ts` already accesses `buildHelpDoc`. + */ +interface LegacyCommandInternal { + readonly config: { readonly flags: ReadonlyArray }; + readonly contextConfig: { readonly flags: ReadonlyArray }; + readonly globalFlags: ReadonlyArray>; +} + +function legacyInternalCommand(command: Command.Command.Any): LegacyCommandInternal { + return command as unknown as LegacyCommandInternal; +} + +function legacyFlattenSubcommands( + command: Command.Command.Any, +): ReadonlyArray { + return command.subcommands.flatMap((group) => group.commands); +} + +/* ========================================================================== */ +/* Flag descriptors */ +/* ========================================================================== */ + +function legacyFlagDescriptorFromParam(param: Param.AnyFlag): LegacyFlagDescriptor | undefined { + const unwrapped = legacyUnwrapParam(param); + if (unwrapped === undefined) return undefined; + const { single, isVariadic } = unwrapped; + return { + name: single.name, + aliases: single.aliases, + hidden: single.hidden, + description: Option.getOrUndefined(single.description), + isVariadic, + isBoolean: single.primitiveType._tag === "Boolean", + }; +} + +/** + * The full in-scope flag list for `commandChain`'s last element (the resolved + * command): every command in the chain's own declared global flags + * (`Command.withGlobalFlags` — not just `root`'s, since a non-root command can + * declare its own, e.g. `legacySeedCommand`'s `--linked`/`--local`), plus the + * always-available `--help` and (root only) `--version`, every ancestor's + * shared flags (`Command.withSharedFlags`), and the resolved command's own + * local flags. + * + * Later entries win on a canonical-name collision — e.g. a command's own local + * `--output` (`db diff`'s file-path flag) must shadow the global `--output` + * choice flag declared at root, mirroring pflag's `InheritedFlags()`, which + * skips any persistent flag shadowed by a same-named local one. + */ +export function legacyCollectInScopeFlags( + root: Command.Command.Any, + commandChain: ReadonlyArray, +): ReadonlyArray { + const finalCommand = commandChain[commandChain.length - 1] ?? root; + const ancestors = commandChain.slice(0, -1); + + const chainGlobalFlagParams = commandChain + .flatMap((command) => legacyInternalCommand(command).globalFlags) + // `GlobalFlag.Completions`/`GlobalFlag.LogLevel` are TS-only framework + // additions with no Go/cobra equivalent. They are normally only injected + // via `GlobalFlag.BuiltIns` at parse time (never stored on a command's own + // `.globalFlags`), so this filter is a defensive guard rather than + // something that changes today's output — kept explicit so it stays true + // if that ever changes. + .filter((entry) => entry !== GlobalFlag.Completions && entry !== GlobalFlag.LogLevel) + .map((entry) => entry.flag); + + const params: Array = [ + ...chainGlobalFlagParams, + GlobalFlag.Help.flag, + // Cobra's `InitDefaultVersionFlag` only registers `--version`, and only on + // the root command (gated on `c.Version != ""`, and non-persistent) — it + // is never inherited by subcommands the way `--help` is. + ...(commandChain.length === 1 ? [GlobalFlag.Version.flag] : []), + ...ancestors.flatMap((ancestor) => legacyInternalCommand(ancestor).contextConfig.flags), + ...legacyInternalCommand(finalCommand).config.flags, + ]; + + const byName = new Map(); + for (const param of params) { + const descriptor = legacyFlagDescriptorFromParam(param); + if (descriptor !== undefined) byName.set(descriptor.name, descriptor); + } + return Array.from(byName.values()); +} + +/* ========================================================================== */ +/* Flag-token resolution */ +/* ========================================================================== */ + +/** + * Resolves a bare flag token (`--project-ref`, `-p`, or a shorthand cluster + * like `-po`, where cobra's rule is "the character immediately before the + * value/`=`", i.e. the last character) to its owning in-scope flag. + */ +function legacyResolveFlagFromToken( + token: string, + inScopeFlags: ReadonlyArray, +): LegacyFlagDescriptor | undefined { + if (token.startsWith("--")) { + const name = token.slice(2); + return inScopeFlags.find((flag) => flag.name === name); + } + if (token.startsWith("-") && token.length > 1) { + const shorthand = token.charAt(token.length - 1); + return inScopeFlags.find((flag) => flag.aliases.includes(shorthand)); + } + return undefined; +} + +/* ========================================================================== */ +/* Command-path resolution */ +/* ========================================================================== */ + +/** + * Descends from `root` through `trimmedArgs`, matching each non-flag token + * against the current command's subcommand names/aliases (exact, + * case-sensitive — no prefix or fuzzy matching). A flag-shaped token — and, + * when it's a non-boolean flag with no embedded `=`, the single token + * immediately following it as its value — is skipped without stopping the + * descent, mirroring cobra's `Find()`, which strips flags before matching + * positional command names (`completions.go:340`). Descent stops at the first + * non-flag token that doesn't match a subcommand; that token and everything + * after it becomes `leftoverArgs` — the *positional* leftover cobra's + * `finalArgs` represents (`completions.go:397-399`), used to gate + * subcommand-name completion (`len(finalArgs) == 0`). Flag tokens and their + * consumed values are never part of `leftoverArgs`. + */ +export function legacyResolveCommandPath( + root: Command.Command.Any, + trimmedArgs: ReadonlyArray, +): LegacyCommandPathResolution { + const commandChain: Array = [root]; + const matchedPath: Array = []; + const consumedIndices = new Set(); + + let current = root; + let index = 0; + while (index < trimmedArgs.length) { + const token = trimmedArgs[index]; + if (token === undefined) { + index++; + continue; + } + + if (token.startsWith("-")) { + consumedIndices.add(index); + if (!token.includes("=")) { + // The flags visible at this point of the descent are enough to tell + // whether this token consumes the next one as its value. + const inScopeSoFar = legacyCollectInScopeFlags(root, commandChain); + const resolved = legacyResolveFlagFromToken(token, inScopeSoFar); + if (resolved !== undefined && !resolved.isBoolean && index + 1 < trimmedArgs.length) { + consumedIndices.add(index + 1); + index += 2; + continue; + } + } + index++; + continue; + } + + const match = legacyFlattenSubcommands(current).find( + (candidate) => candidate.name === token || candidate.alias === token, + ); + if (match === undefined) break; // stop descending; this and later tokens are leftover + current = match; + commandChain.push(match); + matchedPath.push(match.name); + consumedIndices.add(index); + index++; + } + + const leftoverArgs = trimmedArgs.filter((_, i) => !consumedIndices.has(i)); + return { commandChain, matchedPath, leftoverArgs }; +} + +/* ========================================================================== */ +/* Classification */ +/* ========================================================================== */ + +const LEGACY_HELP_TOKENS: ReadonlySet = new Set(["--help", "-h"]); +/** + * Only checked when the resolved command IS the root (`matchedPath.length === + * 0`) — cobra's `--version` flag lives on the root command only (see + * `legacyCollectInScopeFlags`'s comment), so a `--version`/`-v` token typed + * while completing a subcommand's own arguments (e.g. `migration squash + * --version `, a genuine local flag unrelated to cobra's built-in one) + * must not be mistaken for it. + */ +const LEGACY_VERSION_TOKENS: ReadonlySet = new Set(["--version", "-v"]); + +/** + * Mirrors cobra's `MarkFlagFilename` calls in `apps/cli-go/cmd/sso.go:166,167,181,182` + * — 4 individually hardcoded lines in Go, not derived from anything generic, + * so a small matching lookup table here is the right level of fidelity. Key = + * `:`. + */ +const LEGACY_COMPLETION_FLAG_FILE_EXTENSIONS: ReadonlyMap> = new Map([ + ["sso add:metadata-file", ["xml"]], + ["sso add:attribute-mapping-file", ["json"]], + ["sso update:metadata-file", ["xml"]], + ["sso update:attribute-mapping-file", ["json"]], +]); + +/** + * Mirrors cobra's unconditional, `init()`-time `MarkFlagRequired` calls — the + * ONLY ones active during `__complete`/`__completeNoDesc`, since cobra's + * `getCompletions` never runs `PreRun`/`PersistentPreRunE`/`RunE` + * (`completions.go` never calls `Execute()`). Several more `MarkFlagRequired` + * calls exist in `apps/cli-go/cmd/` but are scoped inside those hooks + * (conditional on other flags or TTY state) and therefore never apply to a + * real completion request — deliberately excluded here: `db dump:data-only` + * (`cmd/db.go:140`, inside `PreRun`), `init:experimental` (`cmd/init.go:34`, + * inside `PreRun`), `projects create:{org-id,db-password,region}` + * (`cmd/projects.go:64-66`, inside `PreRunE`), `link:project-ref` + * (`cmd/link.go:25`, inside `PreRunE`). + * + * Deliberately a hardcoded table, not derived from whether the TS flag is + * `Flag.optional`-wrapped: several of these TS flags are intentionally + * `Flag.optional` at parse time for validation-ordering reasons unrelated to + * completion (e.g. `vanity-subdomains activate --desired-subdomain` — see + * that command's own file comment), so "is this flag `Optional`-wrapped in + * TS" is not a faithful proxy for "does cobra mark it required." Key = + * `:`. + */ +const LEGACY_COMPLETION_REQUIRED_FLAGS: ReadonlySet = new Set([ + "domains create:custom-hostname", // cmd/domains.go:100 + "migration repair:status", // cmd/migration.go:122 + "gen bearer-jwt:role", // cmd/gen.go:175 + "sso add:type", // cmd/sso.go:165 + "vanity-subdomains activate:desired-subdomain", // cmd/vanitySubdomains.go:67 + "vanity-subdomains check-availability:desired-subdomain", // cmd/vanitySubdomains.go:69 +]); + +function legacyIsRequiredCompletionFlag( + matchedPath: ReadonlyArray, + flagName: string, +): boolean { + return LEGACY_COMPLETION_REQUIRED_FLAGS.has(`${matchedPath.join(" ")}:${flagName}`); +} + +function legacyFlagNameCandidates( + flag: LegacyFlagDescriptor, + toComplete: string, +): ReadonlyArray { + const candidates: Array = []; + const long = `--${flag.name}`; + if (long.startsWith(toComplete)) candidates.push({ name: long, description: flag.description }); + for (const alias of flag.aliases) { + if (alias.length !== 1) continue; + const short = `-${alias}`; + if (short.startsWith(toComplete)) + candidates.push({ name: short, description: flag.description }); + } + return candidates; +} + +/** + * A lightweight, string-only approximation of "which in-scope flags have + * already been provided" (not a real flag parser) — correct for the + * overwhelming majority of real completion inputs. + */ +function legacyChangedFlagNames( + trimmedArgs: ReadonlyArray, + inScopeFlags: ReadonlyArray, +): ReadonlySet { + const changed = new Set(); + for (const token of trimmedArgs) { + if (token.startsWith("--")) { + const rest = token.slice(2); + const equalsIndex = rest.indexOf("="); + const name = equalsIndex === -1 ? rest : rest.slice(0, equalsIndex); + if (name.length > 0) changed.add(name); + continue; + } + if (token.startsWith("-") && token !== "-") { + const equalsIndex = token.indexOf("="); + const shorthand = + equalsIndex === -1 ? token.charAt(token.length - 1) : token.charAt(equalsIndex - 1); + const owner = inScopeFlags.find((flag) => flag.aliases.includes(shorthand)); + if (owner !== undefined) changed.add(owner.name); + } + } + return changed; +} + +function legacyFlagValueCompletion( + matchedPath: ReadonlyArray, + flagName: string | undefined, +): LegacyCompletionResult { + const key = flagName === undefined ? undefined : `${matchedPath.join(" ")}:${flagName}`; + const extensions = + key === undefined ? undefined : LEGACY_COMPLETION_FLAG_FILE_EXTENSIONS.get(key); + if (extensions !== undefined) { + return { + candidates: extensions.map((extension) => ({ name: extension, description: undefined })), + directive: LegacyCompletionDirective.FilterFileExt, + }; + } + return { candidates: [], directive: LegacyCompletionDirective.Default }; +} + +/** + * Classifies a single completion request into candidates + directive, + * mirroring cobra's `checkIfFlagCompletion` and the branch in + * `getCompletions` that follows it: + * + * 1. `--help`/`-h` anywhere in `trimmedArgs` (or `--version`/`-v`, only when + * resolved to the root command) short-circuits to no candidates — these + * exit before any real completion runs. + * 2. `toComplete` is a bare flag with no `=` → flag-NAME completion. + * 3. `toComplete` (or the immediately preceding token) identifies a + * non-boolean flag's value slot → flag-VALUE completion. + * 4. Otherwise → subcommand-name + required-flag (noun) completion. + */ +export function legacyClassifyCompletion( + input: LegacyClassifyCompletionInput, +): LegacyCompletionResult { + const { finalCommand, matchedPath, leftoverArgs, trimmedArgs, toComplete, inScopeFlags } = input; + const isAtRoot = matchedPath.length === 0; + + if ( + trimmedArgs.some((token) => LEGACY_HELP_TOKENS.has(token)) || + (isAtRoot && trimmedArgs.some((token) => LEGACY_VERSION_TOKENS.has(token))) + ) { + return { candidates: [], directive: LegacyCompletionDirective.NoFileComp }; + } + + const changedFlagNames = legacyChangedFlagNames(trimmedArgs, inScopeFlags); + const requiredFlags = inScopeFlags.filter( + (flag) => + legacyIsRequiredCompletionFlag(matchedPath, flag.name) && !changedFlagNames.has(flag.name), + ); + + const toCompleteIsFlag = toComplete.startsWith("-"); + const toCompleteEqualsIndex = toComplete.indexOf("="); + + // Case 1: flag-NAME completion. + if (toCompleteIsFlag && toCompleteEqualsIndex === -1) { + const requiredCandidates = requiredFlags.flatMap((flag) => + legacyFlagNameCandidates(flag, toComplete), + ); + // Once ANY required flag is still unset, ONLY required flags are + // offered — this exactly mirrors cobra. + if (requiredCandidates.length > 0) { + return { candidates: requiredCandidates, directive: LegacyCompletionDirective.NoFileComp }; + } + const candidates = inScopeFlags + .filter((flag) => !flag.hidden && (!changedFlagNames.has(flag.name) || flag.isVariadic)) + .flatMap((flag) => legacyFlagNameCandidates(flag, toComplete)); + return { candidates, directive: LegacyCompletionDirective.NoFileComp }; + } + + // Case 2: flag-VALUE completion. + if (toCompleteIsFlag) { + // toCompleteEqualsIndex !== -1 here — the no-`=` branch above returns. + const resolved = legacyResolveFlagFromToken( + toComplete.slice(0, toCompleteEqualsIndex), + inScopeFlags, + ); + if (resolved === undefined || !resolved.isBoolean) { + return legacyFlagValueCompletion(matchedPath, resolved?.name); + } + // A boolean flag doesn't consume a following value — fall through to + // Case 3 with the ORIGINAL toComplete/trimmedArgs, unchanged. + } else { + const precedingToken = trimmedArgs[trimmedArgs.length - 1]; + if ( + precedingToken !== undefined && + precedingToken.startsWith("-") && + !precedingToken.includes("=") + ) { + const resolved = legacyResolveFlagFromToken(precedingToken, inScopeFlags); + if (resolved !== undefined && !resolved.isBoolean) { + return legacyFlagValueCompletion(matchedPath, resolved.name); + } + } + } + + // Case 3: subcommand-name + required-flag (bare noun) completion. + const candidates: Array = []; + let directive: number = LegacyCompletionDirective.Default; + + // Once any flag or extra positional token has already appeared before this + // position, subcommand-name completion is suppressed entirely (cobra's + // `len(finalArgs) == 0` gate) — including the directive it would otherwise + // set, which stays at `Default` in that case (`completions.go:489,499-522`). + if (leftoverArgs.length === 0) { + const visibleSubcommands = legacyFlattenSubcommands(finalCommand).filter((sub) => !sub.hidden); + if (visibleSubcommands.length > 0) { + directive = LegacyCompletionDirective.NoFileComp; + for (const sub of visibleSubcommands) { + if (sub.name.startsWith(toComplete)) { + candidates.push({ name: sub.name, description: sub.shortDescription ?? sub.description }); + } + } + } + } + + // Unconditional append in cobra — not gated on `leftoverArgs`. + for (const flag of requiredFlags) { + candidates.push(...legacyFlagNameCandidates(flag, toComplete)); + } + + return { candidates, directive }; +} + +/* ========================================================================== */ +/* Orchestration */ +/* ========================================================================== */ + +/** + * The pure, deps-free completion algorithm: resolves the command path, + * collects in-scope flags, and classifies the request. Returns `undefined` + * when `argv[0]` isn't a completion request, or when cobra's `args` (i.e. + * `argv.slice(1)`) is empty — mirroring cobra's own `MinimumNArgs(1)` failure + * (see the module doc comment for why that case isn't otherwise reproduced). + */ +export function legacyRespondToComplete( + root: Command.Command.Any, + argv: ReadonlyArray, +): LegacyCompletionResult | undefined { + if (argv[0] !== "__complete" && argv[0] !== "__completeNoDesc") return undefined; + + const args = argv.slice(1); + if (args.length === 0) return undefined; + + const toComplete = args[args.length - 1] ?? ""; + const trimmedArgs = args.slice(0, -1); + + const { commandChain, matchedPath, leftoverArgs } = legacyResolveCommandPath(root, trimmedArgs); + const finalCommand = commandChain[commandChain.length - 1] ?? root; + const inScopeFlags = legacyCollectInScopeFlags(root, commandChain); + + return legacyClassifyCompletion({ + finalCommand, + matchedPath, + leftoverArgs, + trimmedArgs, + toComplete, + inScopeFlags, + }); +} + +/* ========================================================================== */ +/* Response formatting */ +/* ========================================================================== */ + +const GO_TRUE_BOOL_SPELLINGS: ReadonlySet = new Set([ + "1", + "t", + "T", + "TRUE", + "true", + "True", +]); +const GO_FALSE_BOOL_SPELLINGS: ReadonlySet = new Set([ + "0", + "f", + "F", + "FALSE", + "false", + "False", +]); + +function legacyParseGoBool(value: string): boolean | undefined { + if (GO_TRUE_BOOL_SPELLINGS.has(value)) return true; + if (GO_FALSE_BOOL_SPELLINGS.has(value)) return false; + return undefined; +} + +/** + * Cobra's real, undocumented-to-users-but-real `getEnvConfig` behavior: + * `argv[0] === "__completeNoDesc"` always wins; otherwise + * `SUPABASE_COMPLETION_DESCRIPTIONS` (program-specific) is checked first, + * falling back to the generic `COBRA_COMPLETION_DESCRIPTIONS` when unset or + * empty. An unparseable value (per Go's `strconv.ParseBool` accepted + * spellings) is ignored, leaving the `argv[0]`-derived default in place. + */ +export function legacyResolveIncludeDescriptions( + argv0: string | undefined, + env: Readonly>, +): boolean { + let includeDescriptions = argv0 !== "__completeNoDesc"; + if (includeDescriptions) { + const raw = env.SUPABASE_COMPLETION_DESCRIPTIONS || env.COBRA_COMPLETION_DESCRIPTIONS || ""; + const parsed = legacyParseGoBool(raw); + if (parsed !== undefined) includeDescriptions = parsed; + } + return includeDescriptions; +} + +function legacyFormatCompletionLine( + candidate: LegacyCompletionCandidate, + includeDescriptions: boolean, +): string { + if (!includeDescriptions) return candidate.name.trim(); + const firstDescriptionLine = (candidate.description ?? "").split("\n")[0] ?? ""; + // `.trim()` on the whole joined string (not just the description) is what + // makes a candidate with no description end up as a bare name with no + // trailing tab, not `"name\t"` — reproduces cobra's exact `TrimSpace` step. + return `${candidate.name}\t${firstDescriptionLine}`.trim(); +} + +/** + * Formats a completion result the way cobra's generated shell scripts expect: + * one line per candidate (`name` or `name\tdescription`), then a final + * `:` line. Every line ends with `\n`. + */ +export function legacyFormatCompletionResponse( + response: LegacyCompletionResult, + includeDescriptions: boolean, +): string { + const lines = response.candidates.map((candidate) => + legacyFormatCompletionLine(candidate, includeDescriptions), + ); + lines.push(`:${response.directive}`); + return lines.map((line) => `${line}\n`).join(""); +} + +/* ========================================================================== */ +/* Entry point */ +/* ========================================================================== */ + +/** + * Entry-point interceptor with the same shape/contract as the old + * `tryCompletePassthrough`: runs before Effect's CLI argv parser, returns + * `false` immediately (no side effects) when `deps.argv[0]` isn't a + * completion request, otherwise fully handles it and returns `true`. + */ +export function legacyTryComplete(deps: LegacyCompleteDeps): boolean { + if (deps.argv[0] !== "__complete" && deps.argv[0] !== "__completeNoDesc") return false; + + const response = legacyRespondToComplete(deps.root, deps.argv); + if (response === undefined) { + deps.exit(1); + return true; + } + + const includeDescriptions = legacyResolveIncludeDescriptions(deps.argv[0], deps.env); + deps.stdoutWrite(legacyFormatCompletionResponse(response, includeDescriptions)); + deps.exit(0); + return true; +} + +export function legacyDefaultCompleteDeps(root: Command.Command.Any): LegacyCompleteDeps { + return { + root, + argv: process.argv.slice(2), + env: process.env, + stdoutWrite: (message) => { + process.stdout.write(message); + }, + exit: (code) => { + process.exit(code); + }, + }; +} diff --git a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts new file mode 100644 index 0000000000..59690a7c9d --- /dev/null +++ b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts @@ -0,0 +1,653 @@ +import { describe, expect, it, vi } from "vitest"; + +import { legacyRoot } from "./root.ts"; +import { + LegacyCompletionDirective, + type LegacyClassifyCompletionInput, + type LegacyCommandPathResolution, + type LegacyCompleteDeps, + type LegacyCompletionCandidate, + type LegacyCompletionResult, + type LegacyFlagDescriptor, + legacyClassifyCompletion, + legacyCollectInScopeFlags, + legacyDefaultCompleteDeps, + legacyFormatCompletionResponse, + legacyResolveCommandPath, + legacyResolveIncludeDescriptions, + legacyRespondToComplete, + legacyTryComplete, +} from "./legacy-complete.ts"; + +describe("legacyRespondToComplete", () => { + describe("subcommand-name completion", () => { + it("completes a subcommand-name prefix nested under a parent command", () => { + const result = legacyRespondToComplete(legacyRoot, ["__complete", "migration", "li"]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("list"); + }); + + it("completes a subcommand-name prefix at the root", () => { + const result = legacyRespondToComplete(legacyRoot, ["__complete", "br"]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("branches"); + }); + }); + + it("returns no candidates and the Default directive for a leaf command with no subcommands and no unset required flags", () => { + // `migration list` has no subcommands; `--db-url`/`--password` are optional, + // `--linked` defaults to true, and `--local` is a plain boolean, so nothing + // is left "required" once resolved (verified against migration/list/list.command.ts). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "migration", "list", ""]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("offers a global flag declared once at legacyRoot from a nested command path", () => { + // `--debug` is declared exactly once, via LEGACY_GLOBAL_FLAGS -> + // Command.withGlobalFlags on legacyRoot (shared/legacy/global-flags.ts, + // legacy/cli/root.ts) — it must still resolve from a resolved subcommand path. + const result = legacyRespondToComplete(legacyRoot, ["__complete", "branches", "--d"]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("--debug"); + }); + + it("offers an ancestor's shared flag (Command.withSharedFlags) from a resolved leaf command", () => { + // `--no-cache` is declared once on the `db schema declarative` group via + // Command.withSharedFlags (declarative.shared.ts) and must be visible from + // its `generate` leaf. + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "db", + "schema", + "declarative", + "generate", + "--no-c", + ]); + expect(result?.candidates.map((c) => c.name)).toContain("--no-cache"); + }); + + it("offers a non-root command's own declared global flags from a nested subcommand (Command.withGlobalFlags)", () => { + // seed.command.ts declares --linked/--local as scoped global flags via + // Command.withGlobalFlags on the `seed` group itself (Go's + // seedCmd.PersistentFlags()), not at legacyRoot — a collector that only + // reads root.globalFlags misses these entirely (CLI-1965 review finding). + const atGroup = legacyRespondToComplete(legacyRoot, ["__complete", "seed", "--l"]); + expect(atGroup?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["--linked", "--local"]), + ); + + const atLeaf = legacyRespondToComplete(legacyRoot, ["__complete", "seed", "buckets", "--l"]); + expect(atLeaf?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["--linked", "--local"]), + ); + }); + + it("does not duplicate a flag name that exists both globally and as a command's own local flag", () => { + // db diff declares its own local `output`/`-o` (a file path), shadowing + // the global `--output`/`-o` choice flag declared at root — pflag's + // InheritedFlags() skips a persistent flag shadowed by a same-named local + // one, so exactly one `--output` candidate (the local one) must appear, + // not two with contradictory descriptions. + const result = legacyRespondToComplete(legacyRoot, ["__complete", "db", "diff", "--o"]); + const outputCandidates = result?.candidates.filter((c) => c.name === "--output"); + expect(outputCandidates).toHaveLength(1); + expect(outputCandidates?.[0]?.description).toBe("Write explicit diff output to a file path."); + }); + + describe("subcommand completion is not blocked by a preceding global flag", () => { + it("lists root subcommands after a bare global flag with no value", () => { + // `--debug ""` used to return zero candidates entirely: the leftover-args + // computation counted `--debug` itself as "positional leftover," gating + // out subcommand-name completion the way cobra never does for a + // persistent flag (CLI-1965 review finding, empirically confirmed + // against a real apps/cli-go build: `__complete --debug ''` lists all 36 + // root commands there). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "--debug", ""]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("branches"); + }); + + it("lists subcommands after a value-taking global flag and its value", () => { + // `-o json ""` — "json" is `-o`'s consumed value, not a genuine extra + // positional argument, so it must not count as leftover either. + const result = legacyRespondToComplete(legacyRoot, ["__complete", "-o", "json", ""]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("migration"); + }); + + it("still resolves and lists subcommands when the global flag appears before the group", () => { + const result = legacyRespondToComplete(legacyRoot, ["__complete", "--debug", "db", ""]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("diff"); + }); + }); + + it("does not offer --version on any command other than the root", () => { + // Cobra's InitDefaultVersionFlag registers --version non-persistently on + // the root command only — it is never inherited by subcommands the way + // --help is (CLI-1965 review finding). + const atRoot = legacyRespondToComplete(legacyRoot, ["__complete", "--v"]); + expect(atRoot?.candidates.map((c) => c.name)).toContain("--version"); + + const atSubcommand = legacyRespondToComplete(legacyRoot, ["__complete", "db", "dump", "--v"]); + expect(atSubcommand?.candidates.map((c) => c.name)).not.toContain("--version"); + }); + + it("returns Default (not NoFileComp) when the resolved command doesn't match a real subcommand", () => { + // Mirrors cobra: the NoFileComp directive is only set INSIDE the + // `len(finalArgs) == 0` gate, alongside the subcommand loop — a bogus + // trailing token (which becomes non-empty leftover) must leave the + // directive at Default, not force NoFileComp just because `db` itself has + // subcommands (CLI-1965 review finding; verified against a real + // apps/cli-go build: `__complete db bogus ''` → `:0`). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "db", "bogus", ""]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + describe("help/version short-circuit", () => { + it("short-circuits to no candidates once --help/-h appears anywhere in the args", () => { + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "branches", + "--help", + "li", + ]); + expect(result).toEqual({ + candidates: [], + directive: LegacyCompletionDirective.NoFileComp, + }); + }); + + it("short-circuits on --version/-v only when resolved to the root command", () => { + const result = legacyRespondToComplete(legacyRoot, ["__complete", "--version", "br"]); + expect(result).toEqual({ + candidates: [], + directive: LegacyCompletionDirective.NoFileComp, + }); + }); + + it("does not short-circuit on a subcommand's own local --version flag away from the root", () => { + // migration/squash/squash.command.ts declares its own `--version` + // (a target migration version string) — unrelated to cobra's built-in + // root-only version flag. Typing it while completing a *different* + // flag on the same command must behave like normal flag-name + // completion, not trip the root-only help/version short-circuit + // (CLI-1965 review finding). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "migration", + "squash", + "--version", + "20240101000000", + "--l", + ]); + expect(result?.candidates.map((c) => c.name)).toContain("--linked"); + }); + }); + + describe("required-flag short-circuit", () => { + it("offers exactly the one required flag and nothing else for a command with a single required flag", () => { + // domains/create/create.command.ts: `customHostname: Flag.string("custom-hostname")` + // has no `.pipe(Flag.optional)`/`.pipe(Flag.withDefault(...))`, so it genuinely + // fails to parse when omitted — `projectRef` is optional and + // `includeRawOutput` is boolean, so neither is offered alongside it. + const result = legacyRespondToComplete(legacyRoot, ["__complete", "domains", "create", ""]); + expect(result).toEqual({ + candidates: [ + { + name: "--custom-hostname", + description: "The custom hostname to use for your Supabase project.", + }, + ], + directive: LegacyCompletionDirective.Default, + }); + }); + + it("short-circuits on a flag Go marks required even though this port made it optional at parse time", () => { + // vanity-subdomains/activate/activate.command.ts wraps `desiredSubdomain` + // in `.pipe(Flag.optional)` on purpose (presence is enforced later, in + // the handler, to let the --experimental gate and login check run + // first) — but cobra's completion-time required-flag annotation + // (`MarkFlagRequired`, `cmd/vanitySubdomains.go:67`) is independent of + // TS's parse-time validation ordering, so completion must still offer + // exactly this flag, matching real cobra (CLI-1965 review finding: + // structural inference from `Flag.optional` is not a faithful proxy for + // "does cobra mark it required" — `LEGACY_COMPLETION_REQUIRED_FLAGS` is + // the explicit table that fixes this). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "vanity-subdomains", + "activate", + "", + ]); + expect(result).toEqual({ + candidates: [ + { + name: "--desired-subdomain", + description: "The desired vanity subdomain to use for your Supabase project.", + }, + ], + directive: LegacyCompletionDirective.Default, + }); + }); + + it("does not treat a zero-minimum variadic flag (Flag.atLeast(0)) as required", () => { + // sso/add/add.command.ts: `domains: legacySsoAddDomainsFlag` builds on + // `legacyStringSliceFlag`, which is `Flag.string(...).pipe(..., Flag.atLeast(0))` + // — a `Variadic` param with `min: 0`. Go never calls + // `MarkFlagRequired("domains")` (only `type`), so `--domains` must not be + // force-offered here — only the genuinely required `--type`/`-t` should + // appear. (Required-ness now comes from the explicit + // `LEGACY_COMPLETION_REQUIRED_FLAGS` table, not structural inference, but + // this scenario is worth keeping as its own regression case.) + const result = legacyRespondToComplete(legacyRoot, ["__complete", "sso", "add", ""]); + expect(result?.candidates.map((c) => c.name)).not.toContain("--domains"); + expect(result).toEqual({ + candidates: [ + { name: "--type", description: expect.any(String) }, + { name: "-t", description: expect.any(String) }, + ], + directive: LegacyCompletionDirective.Default, + }); + }); + }); + + describe("flag-value completion", () => { + it.each([ + { command: "add", flag: "metadata-file", extension: "xml" }, + { command: "add", flag: "attribute-mapping-file", extension: "json" }, + { command: "update", flag: "metadata-file", extension: "xml" }, + { command: "update", flag: "attribute-mapping-file", extension: "json" }, + ])( + "restricts $flag on sso $command to the $extension file extension", + ({ command, flag, extension }) => { + // sso/add/add.command.ts and sso/update/update.command.ts both declare + // --metadata-file/--attribute-mapping-file; the Go CLI's cmd/sso.go + // MarkFlagFilename calls are ported as a small lookup table + // (LEGACY_COMPLETION_FLAG_FILE_EXTENSIONS) rather than derived generically. + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + command, + `--${flag}=`, + ]); + expect(result).toEqual({ + candidates: [{ name: extension, description: undefined }], + directive: LegacyCompletionDirective.FilterFileExt, + }); + }, + ); + + it("never completes a choice flag's value (sso add --type )", () => { + // sso/add/add.command.ts: `type: Flag.choice("type", ["saml"])` has no + // registered ValidArgsFunction equivalent in Go, so its value slot must + // resolve to empty candidates + Default, not an enumeration of "saml". + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + "add", + "--type", + "", + ]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("does not treat a boolean flag as consuming a following value", () => { + // sso/add/add.command.ts: `skipUrlValidation: Flag.boolean("skip-url-validation")`. + // A boolean flag must fall through to Case 3 (bare noun completion) with the + // unchanged toComplete/trimmedArgs, so this must equal the bare `sso add ""` + // response exactly rather than resolve to some flag-value result. + const withBooleanFlag = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + "add", + "--skip-url-validation", + "", + ]); + const bareNoun = legacyRespondToComplete(legacyRoot, ["__complete", "sso", "add", ""]); + expect(withBooleanFlag).toEqual(bareNoun); + expect(withBooleanFlag?.candidates.length).toBeGreaterThan(0); + }); + }); + + describe("changed-flag exclusion and the variadic exception", () => { + it("excludes an already-supplied, non-repeatable flag's own name from further completion", () => { + // sso/add/add.command.ts: `metadataUrl: Flag.string("metadata-url")` is not + // variadic, so once supplied it must not be offered again. + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + "add", + "--metadata-url", + "https://x", + "--m", + ]); + const names = result?.candidates.map((c) => c.name); + expect(names).toContain("--metadata-file"); + expect(names).not.toContain("--metadata-url"); + }); + + it("keeps offering a variadic flag's own name even after it has already been supplied", () => { + // sso/add/add.command.ts: `domains: legacySsoAddDomainsFlag` is built on + // `Flag.atLeast(0)` (repeatable) — Go's real doCompleteFlags keeps a + // Slice/Array-typed flag in the completion list even once `flag.Changed`. + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + "add", + "--domains", + "example.com", + "--d", + ]); + expect(result?.candidates.map((c) => c.name)).toContain("--domains"); + }); + }); + + it("returns undefined for zero completion args (mirrors cobra's MinimumNArgs(1) failure)", () => { + expect(legacyRespondToComplete(legacyRoot, ["__complete"])).toBeUndefined(); + }); + + it("returns undefined for non-completion argv", () => { + expect(legacyRespondToComplete(legacyRoot, ["migration", "list"])).toBeUndefined(); + }); +}); + +describe("legacyResolveCommandPath", () => { + it("resolves a nested subcommand path with no leftover args", () => { + const result: LegacyCommandPathResolution = legacyResolveCommandPath(legacyRoot, [ + "branches", + "list", + ]); + expect(result.matchedPath).toEqual(["branches", "list"]); + expect(result.leftoverArgs).toEqual([]); + expect(result.commandChain.map((command) => command.name)).toEqual([ + "supabase", + "branches", + "list", + ]); + }); + + it("stops descending at the first unmatched token and treats it and everything after as leftover", () => { + const result = legacyResolveCommandPath(legacyRoot, ["migration", "bogus", "--x"]); + expect(result.matchedPath).toEqual(["migration"]); + expect(result.leftoverArgs).toEqual(["bogus", "--x"]); + }); + + it("skips flag-shaped tokens without stopping descent, and excludes them from leftoverArgs", () => { + // `--debug` is a boolean global flag — it and every genuinely-consumed + // flag token are excluded from `leftoverArgs` entirely (not just skipped + // during subcommand matching), since `leftoverArgs` represents cobra's + // *positional* `finalArgs`, used to gate subcommand-name completion. + const result = legacyResolveCommandPath(legacyRoot, ["--debug", "migration", "list"]); + expect(result.matchedPath).toEqual(["migration", "list"]); + expect(result.leftoverArgs).toEqual([]); + }); + + it("also excludes a value-taking flag's consumed value token from leftoverArgs", () => { + // `-o` (the global --output choice flag) is non-boolean, so it consumes + // "json" as its value — "json" must not be treated as an extra + // positional token even though it isn't itself flag-shaped. + const result = legacyResolveCommandPath(legacyRoot, ["-o", "json", "migration", "list"]); + expect(result.matchedPath).toEqual(["migration", "list"]); + expect(result.leftoverArgs).toEqual([]); + }); + + it("returns just the root for an empty args list", () => { + const result = legacyResolveCommandPath(legacyRoot, []); + expect(result.matchedPath).toEqual([]); + expect(result.leftoverArgs).toEqual([]); + expect(result.commandChain.map((command) => command.name)).toEqual(["supabase"]); + }); +}); + +describe("legacyCollectInScopeFlags", () => { + it("merges root global flags with the resolved command's own local flags", () => { + const { commandChain } = legacyResolveCommandPath(legacyRoot, ["branches", "list"]); + const flags: ReadonlyArray = legacyCollectInScopeFlags( + legacyRoot, + commandChain, + ); + const names = flags.map((flag) => flag.name); + expect(names).toContain("debug"); // root global flag + expect(names).toContain("project-ref"); // branches list's own local flag + + const debugFlag = flags.find((flag) => flag.name === "debug"); + expect(debugFlag).toEqual({ + name: "debug", + aliases: [], + hidden: false, + description: "Output debug logs to stderr.", + isVariadic: false, + isBoolean: true, + }); + }); + + it("includes an ancestor's shared flags (Command.withSharedFlags)", () => { + const { commandChain } = legacyResolveCommandPath(legacyRoot, [ + "db", + "schema", + "declarative", + "generate", + ]); + const flags = legacyCollectInScopeFlags(legacyRoot, commandChain); + expect(flags.map((flag) => flag.name)).toContain("no-cache"); + }); + + it("includes a non-root command's own declared global flags across the whole chain", () => { + const { commandChain } = legacyResolveCommandPath(legacyRoot, ["seed", "buckets"]); + const flags = legacyCollectInScopeFlags(legacyRoot, commandChain); + expect(flags.map((flag) => flag.name)).toEqual(expect.arrayContaining(["linked", "local"])); + }); + + it("includes --version only when the chain resolves to the root command alone", () => { + const atRoot = legacyCollectInScopeFlags( + legacyRoot, + legacyResolveCommandPath(legacyRoot, []).commandChain, + ); + expect(atRoot.map((flag) => flag.name)).toContain("version"); + + const atSubcommand = legacyCollectInScopeFlags( + legacyRoot, + legacyResolveCommandPath(legacyRoot, ["db", "dump"]).commandChain, + ); + expect(atSubcommand.map((flag) => flag.name)).not.toContain("version"); + }); + + it("lets a command's own local flag shadow a same-named global flag (local wins, no duplicate)", () => { + const { commandChain } = legacyResolveCommandPath(legacyRoot, ["db", "diff"]); + const flags = legacyCollectInScopeFlags(legacyRoot, commandChain); + const outputFlags = flags.filter((flag) => flag.name === "output"); + expect(outputFlags).toHaveLength(1); + expect(outputFlags[0]?.description).toBe("Write explicit diff output to a file path."); + }); +}); + +describe("legacyClassifyCompletion", () => { + it("produces the same result legacyRespondToComplete does for the equivalent resolved input", () => { + const trimmedArgs = ["migration", "li"].slice(0, -1); + const { commandChain, matchedPath, leftoverArgs } = legacyResolveCommandPath( + legacyRoot, + trimmedArgs, + ); + const inScopeFlags = legacyCollectInScopeFlags(legacyRoot, commandChain); + const input: LegacyClassifyCompletionInput = { + finalCommand: commandChain[commandChain.length - 1] ?? legacyRoot, + matchedPath, + leftoverArgs, + trimmedArgs, + toComplete: "li", + inScopeFlags, + }; + const direct = legacyClassifyCompletion(input); + const viaRespondToComplete = legacyRespondToComplete(legacyRoot, [ + "__complete", + "migration", + "li", + ]); + expect(direct).toEqual(viaRespondToComplete); + }); +}); + +describe("legacyResolveIncludeDescriptions", () => { + it("defaults to true for __complete with no relevant env vars", () => { + expect(legacyResolveIncludeDescriptions("__complete", {})).toBe(true); + }); + + it("is always false for __completeNoDesc, regardless of env vars", () => { + expect(legacyResolveIncludeDescriptions("__completeNoDesc", {})).toBe(false); + expect( + legacyResolveIncludeDescriptions("__completeNoDesc", { + SUPABASE_COMPLETION_DESCRIPTIONS: "true", + COBRA_COMPLETION_DESCRIPTIONS: "true", + }), + ).toBe(false); + }); + + it("honors SUPABASE_COMPLETION_DESCRIPTIONS=false for __complete", () => { + expect( + legacyResolveIncludeDescriptions("__complete", { SUPABASE_COMPLETION_DESCRIPTIONS: "false" }), + ).toBe(false); + }); + + it("falls back to the generic COBRA_COMPLETION_DESCRIPTIONS when the program-specific var is unset", () => { + expect( + legacyResolveIncludeDescriptions("__complete", { COBRA_COMPLETION_DESCRIPTIONS: "0" }), + ).toBe(false); + }); + + it("ignores an unparseable value and preserves the argv0-derived default", () => { + expect( + legacyResolveIncludeDescriptions("__complete", { + SUPABASE_COMPLETION_DESCRIPTIONS: "nonsense", + }), + ).toBe(true); + }); + + it("prioritizes the program-specific var over the generic one when both are set and conflict", () => { + expect( + legacyResolveIncludeDescriptions("__complete", { + SUPABASE_COMPLETION_DESCRIPTIONS: "true", + COBRA_COMPLETION_DESCRIPTIONS: "false", + }), + ).toBe(true); + }); +}); + +describe("legacyFormatCompletionResponse", () => { + it("tab-joins a description when present and prints a bare name otherwise, followed by the directive line", () => { + const response: LegacyCompletionResult = { + candidates: [ + { name: "list", description: "List things" }, + { name: "new", description: undefined }, + ], + directive: LegacyCompletionDirective.NoFileComp, + }; + expect(legacyFormatCompletionResponse(response, true)).toBe("list\tList things\nnew\n:4\n"); + }); + + it("strips descriptions from every candidate when includeDescriptions is false", () => { + const response: LegacyCompletionResult = { + candidates: [ + { name: "list", description: "List things" }, + { name: "new", description: undefined }, + ], + directive: LegacyCompletionDirective.NoFileComp, + }; + expect(legacyFormatCompletionResponse(response, false)).toBe("list\nnew\n:4\n"); + }); + + it("keeps only the first line of a multi-line description", () => { + const candidate: LegacyCompletionCandidate = { + name: "flag", + description: "first line\nsecond line", + }; + const response: LegacyCompletionResult = { + candidates: [candidate], + directive: LegacyCompletionDirective.Default, + }; + expect(legacyFormatCompletionResponse(response, true)).toBe("flag\tfirst line\n:0\n"); + }); + + it("emits just the directive line for zero candidates", () => { + const response: LegacyCompletionResult = { + candidates: [], + directive: LegacyCompletionDirective.Default, + }; + expect(legacyFormatCompletionResponse(response, true)).toBe(":0\n"); + }); +}); + +describe("legacyTryComplete", () => { + function makeDeps(overrides: Partial = {}) { + const stdoutWrites: Array = []; + const exits: Array = []; + const deps: LegacyCompleteDeps = { + root: legacyRoot, + argv: ["__complete", "migration", "li"], + env: {}, + stdoutWrite: (message) => { + stdoutWrites.push(message); + }, + exit: (code) => { + exits.push(code); + }, + ...overrides, + }; + return { deps, stdoutWrites, exits }; + } + + it("returns false and does nothing for non-__complete argv", () => { + const { deps, stdoutWrites, exits } = makeDeps({ argv: ["migration", "list"] }); + expect(legacyTryComplete(deps)).toBe(false); + expect(stdoutWrites).toEqual([]); + expect(exits).toEqual([]); + }); + + it("writes the formatted response to stdout and exits 0 for a real completion request", () => { + const { deps, stdoutWrites, exits } = makeDeps(); + expect(legacyTryComplete(deps)).toBe(true); + expect(stdoutWrites).toHaveLength(1); + expect(stdoutWrites[0]).toContain("list\t"); + expect(stdoutWrites[0]).toMatch(/:4\n$/); + expect(exits).toEqual([0]); + }); + + it("respects __completeNoDesc by stripping descriptions from the written response", () => { + const { deps, stdoutWrites } = makeDeps({ argv: ["__completeNoDesc", "migration", "li"] }); + legacyTryComplete(deps); + expect(stdoutWrites[0]).toBe("list\n:4\n"); + }); + + it("exits 1 and does not write anything to stdout for zero completion args", () => { + const { deps, stdoutWrites, exits } = makeDeps({ argv: ["__complete"] }); + expect(legacyTryComplete(deps)).toBe(true); + expect(stdoutWrites).toEqual([]); + expect(exits).toEqual([1]); + }); +}); + +describe("legacyDefaultCompleteDeps", () => { + it("wires argv/env from the real process and delegates stdoutWrite/exit to process.stdout.write/process.exit", () => { + const originalArgv = process.argv; + process.argv = [...originalArgv.slice(0, 2), "__complete", "migration", "li"]; + const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const exit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + + try { + const deps = legacyDefaultCompleteDeps(legacyRoot); + expect(deps.root).toBe(legacyRoot); + expect(deps.argv).toEqual(["__complete", "migration", "li"]); + expect(deps.env).toBe(process.env); + + deps.stdoutWrite("hello"); + expect(stdoutWrite).toHaveBeenCalledWith("hello"); + + deps.exit(3); + expect(exit).toHaveBeenCalledWith(3); + } finally { + process.argv = originalArgv; + stdoutWrite.mockRestore(); + exit.mockRestore(); + } + }); +}); diff --git a/apps/cli/src/legacy/cli/main.ts b/apps/cli/src/legacy/cli/main.ts index 1866084cdc..1d62de342b 100644 --- a/apps/cli/src/legacy/cli/main.ts +++ b/apps/cli/src/legacy/cli/main.ts @@ -1,9 +1,9 @@ #!/usr/bin/env bun import { runCli } from "../../shared/cli/run.ts"; import { legacyAnalyticsLayer } from "../telemetry/legacy-analytics.layer.ts"; -import { defaultCompletePassthroughDeps, tryCompletePassthrough } from "./complete-passthrough.ts"; +import { legacyDefaultCompleteDeps, legacyTryComplete } from "./legacy-complete.ts"; import { legacyRoot } from "./root.ts"; -if (!tryCompletePassthrough(defaultCompletePassthroughDeps())) { +if (!legacyTryComplete(legacyDefaultCompleteDeps(legacyRoot))) { await runCli(legacyRoot, { analyticsLayer: legacyAnalyticsLayer }); } diff --git a/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md index ed1b76d1c2..04cbd5c69c 100644 --- a/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md @@ -20,41 +20,71 @@ ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| — | — | — | +These two are consumed by the dynamic `__complete`/`__completeNoDesc` responder +(`legacy/cli/legacy-complete.ts`, `legacyResolveIncludeDescriptions`), not by +`supabase completion ` itself — documented here because this is the only +`SIDE_EFFECTS.md` for the completion family, and the two hidden commands are only +ever reached via a script this family generates. + +| Variable | Purpose | Required? | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | +| `SUPABASE_COMPLETION_DESCRIPTIONS` | Program-specific override for whether `__complete` includes descriptions (Go `strconv.ParseBool` spellings: `1/t/T/TRUE/true/True` = include, `0/f/F/FALSE/false/False` = omit; anything else ignored). Checked before the generic var below. Has no effect on `__completeNoDesc`, which always omits descriptions regardless. | No | +| `COBRA_COMPLETION_DESCRIPTIONS` | Generic fallback for the above, checked only when `SUPABASE_COMPLETION_DESCRIPTIONS` is unset or empty (cobra's real `getEnvConfig` precedence). | No | ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------- | -| `0` | success — completion script for the chosen shell printed to stdout | -| `1` | unknown shell subcommand, or bare `completion` with no shell subcommand — **known divergence, see Notes (CLI-1906)** | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — completion script for the chosen shell printed to stdout; also `__complete`/`__completeNoDesc`'s normal case (candidates + `:` line, even when zero candidates match) | +| `1` | unknown shell subcommand, or bare `completion` with no shell subcommand — **known divergence, see Notes (CLI-1906)**; also `__complete`/`__completeNoDesc` invoked with no completion args at all (`supabase __complete` alone) — realistically unreachable, since every generated script always appends at least an empty trailing arg | ## Output `supabase completion ` prints a shell-specific autocompletion script to stdout. The subcommand tree mirrors the Go CLI exactly: `bash`, `fish`, `powershell`, `zsh`. -In the legacy shell every subcommand proxies verbatim to the bundled Go binary via -`LegacyGoProxy`, so the emitted scripts are byte-for-byte identical to what the Go -CLI produced. This matters because users who installed completions with the Go CLI -have those exact bytes cached in their `~/.zshrc` (`eval "$(supabase completion zsh)"`), +As of CLI-1965, each leaf is generated **natively in TypeScript** — no Go binary is +involved at all. `legacy/commands/completion/legacy-completion-scripts.ts` (`legacyGenerateCompletionScript`) +transcribes cobra v1.10.2's own static script templates byte-for-byte, read directly from +the vendored cobra source rather than reconstructed from memory: + +- `spf13/cobra@v1.10.2/bash_completionsV2.go` (`genBashComp`) +- `spf13/cobra@v1.10.2/zsh_completions.go` (`genZshComp`) +- `spf13/cobra@v1.10.2/fish_completions.go` (`genFishComp`) +- `spf13/cobra@v1.10.2/powershell_completions.go` (`genPowerShellComp`) + +This is safe to do byte-for-byte because cobra's completion scripts for all four +shells are 100% generic string templates that do **not** bake in the command tree — +the only variables are the program name (always the literal `"supabase"`, hardcoded +as `PROGRAM_NAME` — cobra itself derives it from a compile-time `Use: "supabase"` +constant, not `os.Argv[0]`), which hidden command the script calls back into +(`__complete` vs `__completeNoDesc`), the six `ShellCompDirective` bit values, and +the two activeHelp constants. Each handler prints the generated script verbatim via +`Output.raw` (no framing, spinner, or JSON envelope), matching what the Go binary's +raw stdout used to be piped through. The scripts are byte-for-byte identical to what +the Go CLI produced (verified via a scripted round-trip against Go's own +`fmt.Sprintf` semantics while porting), so users who installed completions with the +Go CLI — cached bytes in their `~/.zshrc` (`eval "$(supabase completion zsh)"`), brew-managed `_supabase` files in their `fpath`, or analogous bash/fish/powershell -artifacts. Drift would break tab completion for those users. +artifacts — see no behavior change. The generated scripts call back to `supabase __complete ` on every tab press to fetch dynamic completion candidates, or `supabase __completeNoDesc ` when the script was generated with `--no-descriptions` (cobra's alias for the same hidden -command) — see `apps/cli/src/legacy/cli/complete-passthrough.ts`, which intercepts -both `__complete` and `__completeNoDesc` before Effect's argv parser and proxies them -straight to the Go binary. +command) — see `apps/cli/src/legacy/cli/legacy-complete.ts`, which intercepts both +`__complete` and `__completeNoDesc` before Effect's argv parser and natively +reimplements cobra's dynamic-completion protocol by reflecting over `legacyRoot` +(this repo's own Effect CLI command tree) rather than proxying to the Go binary +(CLI-1965, separate port; its internal candidate/directive algorithm is out of scope +for this doc — see that file's own doc comments — but its externally-visible wire +format, env vars, and exit codes are documented here since it has no `SIDE_EFFECTS.md` +of its own). The wire format written to stdout: one line per candidate (`name` or, when +descriptions are enabled and present, `name\t`), followed by +a final `:` line (an integer — `0` default, `4` "no file completion", `8` +"filter by file extension", matching a subset of cobra's `ShellCompDirective` bits). ## Notes -- No native TS reimplementation is attempted. Effect's `Completions.generate` API - emits a static `_arguments`-based zsh function that diverges from Cobra's runtime- - callback shape; using it here would break the existing user setups described above. - Effect CLI's `--completions` global flag remains exposed at the root for `next/` users; it does not satisfy the legacy parity contract and is not what this subcommand routes through. @@ -72,6 +102,15 @@ bogus-shell` both exit `0`). The legacy TS shell currently exits `1` for the fix; this doc describes current (buggy) behavior, not the intended target. - Each of `bash`/`zsh`/`fish`/`powershell` declares `--no-descriptions` (cobra's - auto-registered flag, `completions.go` in `spf13/cobra`) and forwards it to the - Go binary, so the emitted script omits completion descriptions exactly as it - would with the Go CLI. + auto-registered flag, `completions.go` in `spf13/cobra`) and forwards it into the + native generator (selecting the `__completeNoDesc` token instead of `__complete`), + so the emitted script omits completion descriptions exactly as it would with the + Go CLI. +- **Accepted `__complete` divergences from real cobra** (see `legacy-complete.ts`'s + module doc comment for the full, current list and rationale): mutually-exclusive + flag-group hiding (`MarkFlagsMutuallyExclusive`, ~45 sites in `apps/cli-go/cmd/`) + is not reproduced — hand-building a shadow table at that scale was judged + higher-risk than the small, stable tables this module does maintain (file + extensions, required flags). Deprecated commands/flags are not filtered out of + candidates either — this TS tree has no "deprecated" concept distinct from + `hidden` today. diff --git a/apps/cli/src/legacy/commands/completion/__fixtures__/bash.desc.txt b/apps/cli/src/legacy/commands/completion/__fixtures__/bash.desc.txt new file mode 100644 index 0000000000..d041d44223 --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/__fixtures__/bash.desc.txt @@ -0,0 +1,426 @@ +# bash completion V2 for supabase -*- shell-script -*- + +__supabase_debug() +{ + if [[ -n ${BASH_COMP_DEBUG_FILE-} ]]; then + echo "$*" >> "${BASH_COMP_DEBUG_FILE}" + fi +} + +# Macs have bash3 for which the bash-completion package doesn't include +# _init_completion. This is a minimal version of that function. +__supabase_init_completion() +{ + COMPREPLY=() + _get_comp_words_by_ref "$@" cur prev words cword +} + +# This function calls the supabase program to obtain the completion +# results and the directive. It fills the 'out' and 'directive' vars. +__supabase_get_completion_results() { + local requestComp lastParam lastChar args + + # Prepare the command to request completions for the program. + # Calling ${words[0]} instead of directly supabase allows handling aliases + args=("${words[@]:1}") + requestComp="${words[0]} __complete ${args[*]}" + + lastParam=${words[$((${#words[@]}-1))]} + lastChar=${lastParam:$((${#lastParam}-1)):1} + __supabase_debug "lastParam ${lastParam}, lastChar ${lastChar}" + + if [[ -z ${cur} && ${lastChar} != = ]]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go method. + __supabase_debug "Adding extra empty parameter" + requestComp="${requestComp} ''" + fi + + # When completing a flag with an = (e.g., supabase -n=) + # bash focuses on the part after the =, so we need to remove + # the flag part from $cur + if [[ ${cur} == -*=* ]]; then + cur="${cur#*=}" + fi + + __supabase_debug "Calling ${requestComp}" + # Use eval to handle any environment variables and such + out=$(eval "${requestComp}" 2>/dev/null) + + # Extract the directive integer at the very end of the output following a colon (:) + directive=${out##*:} + # Remove the directive + out=${out%:*} + if [[ ${directive} == "${out}" ]]; then + # There is not directive specified + directive=0 + fi + __supabase_debug "The completion directive is: ${directive}" + __supabase_debug "The completions are: ${out}" +} + +__supabase_process_completion_results() { + local shellCompDirectiveError=1 + local shellCompDirectiveNoSpace=2 + local shellCompDirectiveNoFileComp=4 + local shellCompDirectiveFilterFileExt=8 + local shellCompDirectiveFilterDirs=16 + local shellCompDirectiveKeepOrder=32 + + if (((directive & shellCompDirectiveError) != 0)); then + # Error code. No completion. + __supabase_debug "Received error from custom completion go code" + return + else + if (((directive & shellCompDirectiveNoSpace) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + __supabase_debug "Activating no space" + compopt -o nospace + else + __supabase_debug "No space directive not supported in this version of bash" + fi + fi + if (((directive & shellCompDirectiveKeepOrder) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + # no sort isn't supported for bash less than < 4.4 + if [[ ${BASH_VERSINFO[0]} -lt 4 || ( ${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -lt 4 ) ]]; then + __supabase_debug "No sort directive not supported in this version of bash" + else + __supabase_debug "Activating keep order" + compopt -o nosort + fi + else + __supabase_debug "No sort directive not supported in this version of bash" + fi + fi + if (((directive & shellCompDirectiveNoFileComp) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + __supabase_debug "Activating no file completion" + compopt +o default + else + __supabase_debug "No file completion directive not supported in this version of bash" + fi + fi + fi + + # Separate activeHelp from normal completions + local completions=() + local activeHelp=() + __supabase_extract_activeHelp + + if (((directive & shellCompDirectiveFilterFileExt) != 0)); then + # File extension filtering + local fullFilter="" filter filteringCmd + + # Do not use quotes around the $completions variable or else newline + # characters will be kept. + for filter in ${completions[*]}; do + fullFilter+="$filter|" + done + + filteringCmd="_filedir $fullFilter" + __supabase_debug "File filtering command: $filteringCmd" + $filteringCmd + elif (((directive & shellCompDirectiveFilterDirs) != 0)); then + # File completion for directories only + + local subdir + subdir=${completions[0]} + if [[ -n $subdir ]]; then + __supabase_debug "Listing directories in $subdir" + pushd "$subdir" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return + else + __supabase_debug "Listing directories in ." + _filedir -d + fi + else + __supabase_handle_completion_types + fi + + __supabase_handle_special_char "$cur" : + __supabase_handle_special_char "$cur" = + + # Print the activeHelp statements before we finish + __supabase_handle_activeHelp +} + +__supabase_handle_activeHelp() { + # Print the activeHelp statements + if ((${#activeHelp[*]} != 0)); then + if [ -z $COMP_TYPE ]; then + # Bash v3 does not set the COMP_TYPE variable. + printf "\n"; + printf "%s\n" "${activeHelp[@]}" + printf "\n" + __supabase_reprint_commandLine + return + fi + + # Only print ActiveHelp on the second TAB press + if [ $COMP_TYPE -eq 63 ]; then + printf "\n" + printf "%s\n" "${activeHelp[@]}" + + if ((${#COMPREPLY[*]} == 0)); then + # When there are no completion choices from the program, file completion + # may kick in if the program has not disabled it; in such a case, we want + # to know if any files will match what the user typed, so that we know if + # there will be completions presented, so that we know how to handle ActiveHelp. + # To find out, we actually trigger the file completion ourselves; + # the call to _filedir will fill COMPREPLY if files match. + if (((directive & shellCompDirectiveNoFileComp) == 0)); then + __supabase_debug "Listing files" + _filedir + fi + fi + + if ((${#COMPREPLY[*]} != 0)); then + # If there are completion choices to be shown, print a delimiter. + # Re-printing the command-line will automatically be done + # by the shell when it prints the completion choices. + printf -- "--" + else + # When there are no completion choices at all, we need + # to re-print the command-line since the shell will + # not be doing it itself. + __supabase_reprint_commandLine + fi + elif [ $COMP_TYPE -eq 37 ] || [ $COMP_TYPE -eq 42 ]; then + # For completion type: menu-complete/menu-complete-backward and insert-completions + # the completions are immediately inserted into the command-line, so we first + # print the activeHelp message and reprint the command-line since the shell won't. + printf "\n" + printf "%s\n" "${activeHelp[@]}" + + __supabase_reprint_commandLine + fi + fi +} + +__supabase_reprint_commandLine() { + # The prompt format is only available from bash 4.4. + # We test if it is available before using it. + if (x=${PS1@P}) 2> /dev/null; then + printf "%s" "${PS1@P}${COMP_LINE[@]}" + else + # Can't print the prompt. Just print the + # text the user had typed, it is workable enough. + printf "%s" "${COMP_LINE[@]}" + fi +} + +# Separate activeHelp lines from real completions. +# Fills the $activeHelp and $completions arrays. +__supabase_extract_activeHelp() { + local activeHelpMarker="_activeHelp_ " + local endIndex=${#activeHelpMarker} + + while IFS='' read -r comp; do + [[ -z $comp ]] && continue + + if [[ ${comp:0:endIndex} == $activeHelpMarker ]]; then + comp=${comp:endIndex} + __supabase_debug "ActiveHelp found: $comp" + if [[ -n $comp ]]; then + activeHelp+=("$comp") + fi + else + # Not an activeHelp line but a normal completion + completions+=("$comp") + fi + done <<<"${out}" +} + +__supabase_handle_completion_types() { + __supabase_debug "__supabase_handle_completion_types: COMP_TYPE is $COMP_TYPE" + + case $COMP_TYPE in + 37|42) + # Type: menu-complete/menu-complete-backward and insert-completions + # If the user requested inserting one completion at a time, or all + # completions at once on the command-line we must remove the descriptions. + # https://github.com/spf13/cobra/issues/1508 + + # If there are no completions, we don't need to do anything + (( ${#completions[@]} == 0 )) && return 0 + + local tab=$'\t' + + # Strip any description and escape the completion to handled special characters + IFS=$'\n' read -ra completions -d '' < <(printf "%q\n" "${completions[@]%%$tab*}") + + # Only consider the completions that match + IFS=$'\n' read -ra COMPREPLY -d '' < <(IFS=$'\n'; compgen -W "${completions[*]}" -- "${cur}") + + # compgen looses the escaping so we need to escape all completions again since they will + # all be inserted on the command-line. + IFS=$'\n' read -ra COMPREPLY -d '' < <(printf "%q\n" "${COMPREPLY[@]}") + ;; + + *) + # Type: complete (normal completion) + __supabase_handle_standard_completion_case + ;; + esac +} + +__supabase_handle_standard_completion_case() { + local tab=$'\t' + + # If there are no completions, we don't need to do anything + (( ${#completions[@]} == 0 )) && return 0 + + # Short circuit to optimize if we don't have descriptions + if [[ "${completions[*]}" != *$tab* ]]; then + # First, escape the completions to handle special characters + IFS=$'\n' read -ra completions -d '' < <(printf "%q\n" "${completions[@]}") + # Only consider the completions that match what the user typed + IFS=$'\n' read -ra COMPREPLY -d '' < <(IFS=$'\n'; compgen -W "${completions[*]}" -- "${cur}") + + # compgen looses the escaping so, if there is only a single completion, we need to + # escape it again because it will be inserted on the command-line. If there are multiple + # completions, we don't want to escape them because they will be printed in a list + # and we don't want to show escape characters in that list. + if (( ${#COMPREPLY[@]} == 1 )); then + COMPREPLY[0]=$(printf "%q" "${COMPREPLY[0]}") + fi + return 0 + fi + + local longest=0 + local compline + # Look for the longest completion so that we can format things nicely + while IFS='' read -r compline; do + [[ -z $compline ]] && continue + + # Before checking if the completion matches what the user typed, + # we need to strip any description and escape the completion to handle special + # characters because those escape characters are part of what the user typed. + # Don't call "printf" in a sub-shell because it will be much slower + # since we are in a loop. + printf -v comp "%q" "${compline%%$tab*}" &>/dev/null || comp=$(printf "%q" "${compline%%$tab*}") + + # Only consider the completions that match + [[ $comp == "$cur"* ]] || continue + + # The completions matches. Add it to the list of full completions including + # its description. We don't escape the completion because it may get printed + # in a list if there are more than one and we don't want show escape characters + # in that list. + COMPREPLY+=("$compline") + + # Strip any description before checking the length, and again, don't escape + # the completion because this length is only used when printing the completions + # in a list and we don't want show escape characters in that list. + comp=${compline%%$tab*} + if ((${#comp}>longest)); then + longest=${#comp} + fi + done < <(printf "%s\n" "${completions[@]}") + + # If there is a single completion left, remove the description text and escape any special characters + if ((${#COMPREPLY[*]} == 1)); then + __supabase_debug "COMPREPLY[0]: ${COMPREPLY[0]}" + COMPREPLY[0]=$(printf "%q" "${COMPREPLY[0]%%$tab*}") + __supabase_debug "Removed description from single completion, which is now: ${COMPREPLY[0]}" + else + # Format the descriptions + __supabase_format_comp_descriptions $longest + fi +} + +__supabase_handle_special_char() +{ + local comp="$1" + local char=$2 + if [[ "$comp" == *${char}* && "$COMP_WORDBREAKS" == *${char}* ]]; then + local word=${comp%"${comp##*${char}}"} + local idx=${#COMPREPLY[*]} + while ((--idx >= 0)); do + COMPREPLY[idx]=${COMPREPLY[idx]#"$word"} + done + fi +} + +__supabase_format_comp_descriptions() +{ + local tab=$'\t' + local comp desc maxdesclength + local longest=$1 + + local i ci + for ci in ${!COMPREPLY[*]}; do + comp=${COMPREPLY[ci]} + # Properly format the description string which follows a tab character if there is one + if [[ "$comp" == *$tab* ]]; then + __supabase_debug "Original comp: $comp" + desc=${comp#*$tab} + comp=${comp%%$tab*} + + # $COLUMNS stores the current shell width. + # Remove an extra 4 because we add 2 spaces and 2 parentheses. + maxdesclength=$(( COLUMNS - longest - 4 )) + + # Make sure we can fit a description of at least 8 characters + # if we are to align the descriptions. + if ((maxdesclength > 8)); then + # Add the proper number of spaces to align the descriptions + for ((i = ${#comp} ; i < longest ; i++)); do + comp+=" " + done + else + # Don't pad the descriptions so we can fit more text after the completion + maxdesclength=$(( COLUMNS - ${#comp} - 4 )) + fi + + # If there is enough space for any description text, + # truncate the descriptions that are too long for the shell width + if ((maxdesclength > 0)); then + if ((${#desc} > maxdesclength)); then + desc=${desc:0:$(( maxdesclength - 1 ))} + desc+="…" + fi + comp+=" ($desc)" + fi + COMPREPLY[ci]=$comp + __supabase_debug "Final comp: $comp" + fi + done +} + +__start_supabase() +{ + local cur prev words cword split + + COMPREPLY=() + + # Call _init_completion from the bash-completion package + # to prepare the arguments properly + if declare -F _init_completion >/dev/null 2>&1; then + _init_completion -n =: || return + else + __supabase_init_completion -n =: || return + fi + + __supabase_debug + __supabase_debug "========= starting completion logic ==========" + __supabase_debug "cur is ${cur}, words[*] is ${words[*]}, #words[@] is ${#words[@]}, cword is $cword" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $cword location, so we need + # to truncate the command-line ($words) up to the $cword location. + words=("${words[@]:0:$cword+1}") + __supabase_debug "Truncated words[*]: ${words[*]}," + + local out directive + __supabase_get_completion_results + __supabase_process_completion_results +} + +if [[ $(type -t compopt) = "builtin" ]]; then + complete -o default -F __start_supabase supabase +else + complete -o default -o nospace -F __start_supabase supabase +fi + +# ex: ts=4 sw=4 et filetype=sh diff --git a/apps/cli/src/legacy/commands/completion/__fixtures__/bash.nodesc.txt b/apps/cli/src/legacy/commands/completion/__fixtures__/bash.nodesc.txt new file mode 100644 index 0000000000..1306d33dd2 --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/__fixtures__/bash.nodesc.txt @@ -0,0 +1,426 @@ +# bash completion V2 for supabase -*- shell-script -*- + +__supabase_debug() +{ + if [[ -n ${BASH_COMP_DEBUG_FILE-} ]]; then + echo "$*" >> "${BASH_COMP_DEBUG_FILE}" + fi +} + +# Macs have bash3 for which the bash-completion package doesn't include +# _init_completion. This is a minimal version of that function. +__supabase_init_completion() +{ + COMPREPLY=() + _get_comp_words_by_ref "$@" cur prev words cword +} + +# This function calls the supabase program to obtain the completion +# results and the directive. It fills the 'out' and 'directive' vars. +__supabase_get_completion_results() { + local requestComp lastParam lastChar args + + # Prepare the command to request completions for the program. + # Calling ${words[0]} instead of directly supabase allows handling aliases + args=("${words[@]:1}") + requestComp="${words[0]} __completeNoDesc ${args[*]}" + + lastParam=${words[$((${#words[@]}-1))]} + lastChar=${lastParam:$((${#lastParam}-1)):1} + __supabase_debug "lastParam ${lastParam}, lastChar ${lastChar}" + + if [[ -z ${cur} && ${lastChar} != = ]]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go method. + __supabase_debug "Adding extra empty parameter" + requestComp="${requestComp} ''" + fi + + # When completing a flag with an = (e.g., supabase -n=) + # bash focuses on the part after the =, so we need to remove + # the flag part from $cur + if [[ ${cur} == -*=* ]]; then + cur="${cur#*=}" + fi + + __supabase_debug "Calling ${requestComp}" + # Use eval to handle any environment variables and such + out=$(eval "${requestComp}" 2>/dev/null) + + # Extract the directive integer at the very end of the output following a colon (:) + directive=${out##*:} + # Remove the directive + out=${out%:*} + if [[ ${directive} == "${out}" ]]; then + # There is not directive specified + directive=0 + fi + __supabase_debug "The completion directive is: ${directive}" + __supabase_debug "The completions are: ${out}" +} + +__supabase_process_completion_results() { + local shellCompDirectiveError=1 + local shellCompDirectiveNoSpace=2 + local shellCompDirectiveNoFileComp=4 + local shellCompDirectiveFilterFileExt=8 + local shellCompDirectiveFilterDirs=16 + local shellCompDirectiveKeepOrder=32 + + if (((directive & shellCompDirectiveError) != 0)); then + # Error code. No completion. + __supabase_debug "Received error from custom completion go code" + return + else + if (((directive & shellCompDirectiveNoSpace) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + __supabase_debug "Activating no space" + compopt -o nospace + else + __supabase_debug "No space directive not supported in this version of bash" + fi + fi + if (((directive & shellCompDirectiveKeepOrder) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + # no sort isn't supported for bash less than < 4.4 + if [[ ${BASH_VERSINFO[0]} -lt 4 || ( ${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -lt 4 ) ]]; then + __supabase_debug "No sort directive not supported in this version of bash" + else + __supabase_debug "Activating keep order" + compopt -o nosort + fi + else + __supabase_debug "No sort directive not supported in this version of bash" + fi + fi + if (((directive & shellCompDirectiveNoFileComp) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + __supabase_debug "Activating no file completion" + compopt +o default + else + __supabase_debug "No file completion directive not supported in this version of bash" + fi + fi + fi + + # Separate activeHelp from normal completions + local completions=() + local activeHelp=() + __supabase_extract_activeHelp + + if (((directive & shellCompDirectiveFilterFileExt) != 0)); then + # File extension filtering + local fullFilter="" filter filteringCmd + + # Do not use quotes around the $completions variable or else newline + # characters will be kept. + for filter in ${completions[*]}; do + fullFilter+="$filter|" + done + + filteringCmd="_filedir $fullFilter" + __supabase_debug "File filtering command: $filteringCmd" + $filteringCmd + elif (((directive & shellCompDirectiveFilterDirs) != 0)); then + # File completion for directories only + + local subdir + subdir=${completions[0]} + if [[ -n $subdir ]]; then + __supabase_debug "Listing directories in $subdir" + pushd "$subdir" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return + else + __supabase_debug "Listing directories in ." + _filedir -d + fi + else + __supabase_handle_completion_types + fi + + __supabase_handle_special_char "$cur" : + __supabase_handle_special_char "$cur" = + + # Print the activeHelp statements before we finish + __supabase_handle_activeHelp +} + +__supabase_handle_activeHelp() { + # Print the activeHelp statements + if ((${#activeHelp[*]} != 0)); then + if [ -z $COMP_TYPE ]; then + # Bash v3 does not set the COMP_TYPE variable. + printf "\n"; + printf "%s\n" "${activeHelp[@]}" + printf "\n" + __supabase_reprint_commandLine + return + fi + + # Only print ActiveHelp on the second TAB press + if [ $COMP_TYPE -eq 63 ]; then + printf "\n" + printf "%s\n" "${activeHelp[@]}" + + if ((${#COMPREPLY[*]} == 0)); then + # When there are no completion choices from the program, file completion + # may kick in if the program has not disabled it; in such a case, we want + # to know if any files will match what the user typed, so that we know if + # there will be completions presented, so that we know how to handle ActiveHelp. + # To find out, we actually trigger the file completion ourselves; + # the call to _filedir will fill COMPREPLY if files match. + if (((directive & shellCompDirectiveNoFileComp) == 0)); then + __supabase_debug "Listing files" + _filedir + fi + fi + + if ((${#COMPREPLY[*]} != 0)); then + # If there are completion choices to be shown, print a delimiter. + # Re-printing the command-line will automatically be done + # by the shell when it prints the completion choices. + printf -- "--" + else + # When there are no completion choices at all, we need + # to re-print the command-line since the shell will + # not be doing it itself. + __supabase_reprint_commandLine + fi + elif [ $COMP_TYPE -eq 37 ] || [ $COMP_TYPE -eq 42 ]; then + # For completion type: menu-complete/menu-complete-backward and insert-completions + # the completions are immediately inserted into the command-line, so we first + # print the activeHelp message and reprint the command-line since the shell won't. + printf "\n" + printf "%s\n" "${activeHelp[@]}" + + __supabase_reprint_commandLine + fi + fi +} + +__supabase_reprint_commandLine() { + # The prompt format is only available from bash 4.4. + # We test if it is available before using it. + if (x=${PS1@P}) 2> /dev/null; then + printf "%s" "${PS1@P}${COMP_LINE[@]}" + else + # Can't print the prompt. Just print the + # text the user had typed, it is workable enough. + printf "%s" "${COMP_LINE[@]}" + fi +} + +# Separate activeHelp lines from real completions. +# Fills the $activeHelp and $completions arrays. +__supabase_extract_activeHelp() { + local activeHelpMarker="_activeHelp_ " + local endIndex=${#activeHelpMarker} + + while IFS='' read -r comp; do + [[ -z $comp ]] && continue + + if [[ ${comp:0:endIndex} == $activeHelpMarker ]]; then + comp=${comp:endIndex} + __supabase_debug "ActiveHelp found: $comp" + if [[ -n $comp ]]; then + activeHelp+=("$comp") + fi + else + # Not an activeHelp line but a normal completion + completions+=("$comp") + fi + done <<<"${out}" +} + +__supabase_handle_completion_types() { + __supabase_debug "__supabase_handle_completion_types: COMP_TYPE is $COMP_TYPE" + + case $COMP_TYPE in + 37|42) + # Type: menu-complete/menu-complete-backward and insert-completions + # If the user requested inserting one completion at a time, or all + # completions at once on the command-line we must remove the descriptions. + # https://github.com/spf13/cobra/issues/1508 + + # If there are no completions, we don't need to do anything + (( ${#completions[@]} == 0 )) && return 0 + + local tab=$'\t' + + # Strip any description and escape the completion to handled special characters + IFS=$'\n' read -ra completions -d '' < <(printf "%q\n" "${completions[@]%%$tab*}") + + # Only consider the completions that match + IFS=$'\n' read -ra COMPREPLY -d '' < <(IFS=$'\n'; compgen -W "${completions[*]}" -- "${cur}") + + # compgen looses the escaping so we need to escape all completions again since they will + # all be inserted on the command-line. + IFS=$'\n' read -ra COMPREPLY -d '' < <(printf "%q\n" "${COMPREPLY[@]}") + ;; + + *) + # Type: complete (normal completion) + __supabase_handle_standard_completion_case + ;; + esac +} + +__supabase_handle_standard_completion_case() { + local tab=$'\t' + + # If there are no completions, we don't need to do anything + (( ${#completions[@]} == 0 )) && return 0 + + # Short circuit to optimize if we don't have descriptions + if [[ "${completions[*]}" != *$tab* ]]; then + # First, escape the completions to handle special characters + IFS=$'\n' read -ra completions -d '' < <(printf "%q\n" "${completions[@]}") + # Only consider the completions that match what the user typed + IFS=$'\n' read -ra COMPREPLY -d '' < <(IFS=$'\n'; compgen -W "${completions[*]}" -- "${cur}") + + # compgen looses the escaping so, if there is only a single completion, we need to + # escape it again because it will be inserted on the command-line. If there are multiple + # completions, we don't want to escape them because they will be printed in a list + # and we don't want to show escape characters in that list. + if (( ${#COMPREPLY[@]} == 1 )); then + COMPREPLY[0]=$(printf "%q" "${COMPREPLY[0]}") + fi + return 0 + fi + + local longest=0 + local compline + # Look for the longest completion so that we can format things nicely + while IFS='' read -r compline; do + [[ -z $compline ]] && continue + + # Before checking if the completion matches what the user typed, + # we need to strip any description and escape the completion to handle special + # characters because those escape characters are part of what the user typed. + # Don't call "printf" in a sub-shell because it will be much slower + # since we are in a loop. + printf -v comp "%q" "${compline%%$tab*}" &>/dev/null || comp=$(printf "%q" "${compline%%$tab*}") + + # Only consider the completions that match + [[ $comp == "$cur"* ]] || continue + + # The completions matches. Add it to the list of full completions including + # its description. We don't escape the completion because it may get printed + # in a list if there are more than one and we don't want show escape characters + # in that list. + COMPREPLY+=("$compline") + + # Strip any description before checking the length, and again, don't escape + # the completion because this length is only used when printing the completions + # in a list and we don't want show escape characters in that list. + comp=${compline%%$tab*} + if ((${#comp}>longest)); then + longest=${#comp} + fi + done < <(printf "%s\n" "${completions[@]}") + + # If there is a single completion left, remove the description text and escape any special characters + if ((${#COMPREPLY[*]} == 1)); then + __supabase_debug "COMPREPLY[0]: ${COMPREPLY[0]}" + COMPREPLY[0]=$(printf "%q" "${COMPREPLY[0]%%$tab*}") + __supabase_debug "Removed description from single completion, which is now: ${COMPREPLY[0]}" + else + # Format the descriptions + __supabase_format_comp_descriptions $longest + fi +} + +__supabase_handle_special_char() +{ + local comp="$1" + local char=$2 + if [[ "$comp" == *${char}* && "$COMP_WORDBREAKS" == *${char}* ]]; then + local word=${comp%"${comp##*${char}}"} + local idx=${#COMPREPLY[*]} + while ((--idx >= 0)); do + COMPREPLY[idx]=${COMPREPLY[idx]#"$word"} + done + fi +} + +__supabase_format_comp_descriptions() +{ + local tab=$'\t' + local comp desc maxdesclength + local longest=$1 + + local i ci + for ci in ${!COMPREPLY[*]}; do + comp=${COMPREPLY[ci]} + # Properly format the description string which follows a tab character if there is one + if [[ "$comp" == *$tab* ]]; then + __supabase_debug "Original comp: $comp" + desc=${comp#*$tab} + comp=${comp%%$tab*} + + # $COLUMNS stores the current shell width. + # Remove an extra 4 because we add 2 spaces and 2 parentheses. + maxdesclength=$(( COLUMNS - longest - 4 )) + + # Make sure we can fit a description of at least 8 characters + # if we are to align the descriptions. + if ((maxdesclength > 8)); then + # Add the proper number of spaces to align the descriptions + for ((i = ${#comp} ; i < longest ; i++)); do + comp+=" " + done + else + # Don't pad the descriptions so we can fit more text after the completion + maxdesclength=$(( COLUMNS - ${#comp} - 4 )) + fi + + # If there is enough space for any description text, + # truncate the descriptions that are too long for the shell width + if ((maxdesclength > 0)); then + if ((${#desc} > maxdesclength)); then + desc=${desc:0:$(( maxdesclength - 1 ))} + desc+="…" + fi + comp+=" ($desc)" + fi + COMPREPLY[ci]=$comp + __supabase_debug "Final comp: $comp" + fi + done +} + +__start_supabase() +{ + local cur prev words cword split + + COMPREPLY=() + + # Call _init_completion from the bash-completion package + # to prepare the arguments properly + if declare -F _init_completion >/dev/null 2>&1; then + _init_completion -n =: || return + else + __supabase_init_completion -n =: || return + fi + + __supabase_debug + __supabase_debug "========= starting completion logic ==========" + __supabase_debug "cur is ${cur}, words[*] is ${words[*]}, #words[@] is ${#words[@]}, cword is $cword" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $cword location, so we need + # to truncate the command-line ($words) up to the $cword location. + words=("${words[@]:0:$cword+1}") + __supabase_debug "Truncated words[*]: ${words[*]}," + + local out directive + __supabase_get_completion_results + __supabase_process_completion_results +} + +if [[ $(type -t compopt) = "builtin" ]]; then + complete -o default -F __start_supabase supabase +else + complete -o default -o nospace -F __start_supabase supabase +fi + +# ex: ts=4 sw=4 et filetype=sh diff --git a/apps/cli/src/legacy/commands/completion/__fixtures__/fish.desc.txt b/apps/cli/src/legacy/commands/completion/__fixtures__/fish.desc.txt new file mode 100644 index 0000000000..b051026cc9 --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/__fixtures__/fish.desc.txt @@ -0,0 +1,235 @@ +# fish completion for supabase -*- shell-script -*- + +function __supabase_debug + set -l file "$BASH_COMP_DEBUG_FILE" + if test -n "$file" + echo "$argv" >> $file + end +end + +function __supabase_perform_completion + __supabase_debug "Starting __supabase_perform_completion" + + # Extract all args except the last one + set -l args (commandline -opc) + # Extract the last arg and escape it in case it is a space + set -l lastArg (string escape -- (commandline -ct)) + + __supabase_debug "args: $args" + __supabase_debug "last arg: $lastArg" + + # Disable ActiveHelp which is not supported for fish shell + set -l requestComp "SUPABASE_ACTIVE_HELP=0 $args[1] __complete $args[2..-1] $lastArg" + + __supabase_debug "Calling $requestComp" + set -l results (eval $requestComp 2> /dev/null) + + # Some programs may output extra empty lines after the directive. + # Let's ignore them or else it will break completion. + # Ref: https://github.com/spf13/cobra/issues/1279 + for line in $results[-1..1] + if test (string trim -- $line) = "" + # Found an empty line, remove it + set results $results[1..-2] + else + # Found non-empty line, we have our proper output + break + end + end + + set -l comps $results[1..-2] + set -l directiveLine $results[-1] + + # For Fish, when completing a flag with an = (e.g., -n=) + # completions must be prefixed with the flag + set -l flagPrefix (string match -r -- '-.*=' "$lastArg") + + __supabase_debug "Comps: $comps" + __supabase_debug "DirectiveLine: $directiveLine" + __supabase_debug "flagPrefix: $flagPrefix" + + for comp in $comps + printf "%s%s\n" "$flagPrefix" "$comp" + end + + printf "%s\n" "$directiveLine" +end + +# this function limits calls to __supabase_perform_completion, by caching the result behind $__supabase_perform_completion_once_result +function __supabase_perform_completion_once + __supabase_debug "Starting __supabase_perform_completion_once" + + if test -n "$__supabase_perform_completion_once_result" + __supabase_debug "Seems like a valid result already exists, skipping __supabase_perform_completion" + return 0 + end + + set --global __supabase_perform_completion_once_result (__supabase_perform_completion) + if test -z "$__supabase_perform_completion_once_result" + __supabase_debug "No completions, probably due to a failure" + return 1 + end + + __supabase_debug "Performed completions and set __supabase_perform_completion_once_result" + return 0 +end + +# this function is used to clear the $__supabase_perform_completion_once_result variable after completions are run +function __supabase_clear_perform_completion_once_result + __supabase_debug "" + __supabase_debug "========= clearing previously set __supabase_perform_completion_once_result variable ==========" + set --erase __supabase_perform_completion_once_result + __supabase_debug "Successfully erased the variable __supabase_perform_completion_once_result" +end + +function __supabase_requires_order_preservation + __supabase_debug "" + __supabase_debug "========= checking if order preservation is required ==========" + + __supabase_perform_completion_once + if test -z "$__supabase_perform_completion_once_result" + __supabase_debug "Error determining if order preservation is required" + return 1 + end + + set -l directive (string sub --start 2 $__supabase_perform_completion_once_result[-1]) + __supabase_debug "Directive is: $directive" + + set -l shellCompDirectiveKeepOrder 32 + set -l keeporder (math (math --scale 0 $directive / $shellCompDirectiveKeepOrder) % 2) + __supabase_debug "Keeporder is: $keeporder" + + if test $keeporder -ne 0 + __supabase_debug "This does require order preservation" + return 0 + end + + __supabase_debug "This doesn't require order preservation" + return 1 +end + + +# This function does two things: +# - Obtain the completions and store them in the global __supabase_comp_results +# - Return false if file completion should be performed +function __supabase_prepare_completions + __supabase_debug "" + __supabase_debug "========= starting completion logic ==========" + + # Start fresh + set --erase __supabase_comp_results + + __supabase_perform_completion_once + __supabase_debug "Completion results: $__supabase_perform_completion_once_result" + + if test -z "$__supabase_perform_completion_once_result" + __supabase_debug "No completion, probably due to a failure" + # Might as well do file completion, in case it helps + return 1 + end + + set -l directive (string sub --start 2 $__supabase_perform_completion_once_result[-1]) + set --global __supabase_comp_results $__supabase_perform_completion_once_result[1..-2] + + __supabase_debug "Completions are: $__supabase_comp_results" + __supabase_debug "Directive is: $directive" + + set -l shellCompDirectiveError 1 + set -l shellCompDirectiveNoSpace 2 + set -l shellCompDirectiveNoFileComp 4 + set -l shellCompDirectiveFilterFileExt 8 + set -l shellCompDirectiveFilterDirs 16 + + if test -z "$directive" + set directive 0 + end + + set -l compErr (math (math --scale 0 $directive / $shellCompDirectiveError) % 2) + if test $compErr -eq 1 + __supabase_debug "Received error directive: aborting." + # Might as well do file completion, in case it helps + return 1 + end + + set -l filefilter (math (math --scale 0 $directive / $shellCompDirectiveFilterFileExt) % 2) + set -l dirfilter (math (math --scale 0 $directive / $shellCompDirectiveFilterDirs) % 2) + if test $filefilter -eq 1; or test $dirfilter -eq 1 + __supabase_debug "File extension filtering or directory filtering not supported" + # Do full file completion instead + return 1 + end + + set -l nospace (math (math --scale 0 $directive / $shellCompDirectiveNoSpace) % 2) + set -l nofiles (math (math --scale 0 $directive / $shellCompDirectiveNoFileComp) % 2) + + __supabase_debug "nospace: $nospace, nofiles: $nofiles" + + # If we want to prevent a space, or if file completion is NOT disabled, + # we need to count the number of valid completions. + # To do so, we will filter on prefix as the completions we have received + # may not already be filtered so as to allow fish to match on different + # criteria than the prefix. + if test $nospace -ne 0; or test $nofiles -eq 0 + set -l prefix (commandline -t | string escape --style=regex) + __supabase_debug "prefix: $prefix" + + set -l completions (string match -r -- "^$prefix.*" $__supabase_comp_results) + set --global __supabase_comp_results $completions + __supabase_debug "Filtered completions are: $__supabase_comp_results" + + # Important not to quote the variable for count to work + set -l numComps (count $__supabase_comp_results) + __supabase_debug "numComps: $numComps" + + if test $numComps -eq 1; and test $nospace -ne 0 + # We must first split on \t to get rid of the descriptions to be + # able to check what the actual completion will be. + # We don't need descriptions anyway since there is only a single + # real completion which the shell will expand immediately. + set -l split (string split --max 1 \t $__supabase_comp_results[1]) + + # Fish won't add a space if the completion ends with any + # of the following characters: @=/:., + set -l lastChar (string sub -s -1 -- $split) + if not string match -r -q "[@=/:.,]" -- "$lastChar" + # In other cases, to support the "nospace" directive we trick the shell + # by outputting an extra, longer completion. + __supabase_debug "Adding second completion to perform nospace directive" + set --global __supabase_comp_results $split[1] $split[1]. + __supabase_debug "Completions are now: $__supabase_comp_results" + end + end + + if test $numComps -eq 0; and test $nofiles -eq 0 + # To be consistent with bash and zsh, we only trigger file + # completion when there are no other completions + __supabase_debug "Requesting file completion" + return 1 + end + end + + return 0 +end + +# Since Fish completions are only loaded once the user triggers them, we trigger them ourselves +# so we can properly delete any completions provided by another script. +# Only do this if the program can be found, or else fish may print some errors; besides, +# the existing completions will only be loaded if the program can be found. +if type -q "supabase" + # The space after the program name is essential to trigger completion for the program + # and not completion of the program name itself. + # Also, we use '> /dev/null 2>&1' since '&>' is not supported in older versions of fish. + complete --do-complete "supabase " > /dev/null 2>&1 +end + +# Remove any pre-existing completions for the program since we will be handling all of them. +complete -c supabase -e + +# this will get called after the two calls below and clear the $__supabase_perform_completion_once_result global +complete -c supabase -n '__supabase_clear_perform_completion_once_result' +# The call to __supabase_prepare_completions will setup __supabase_comp_results +# which provides the program's completion choices. +# If this doesn't require order preservation, we don't use the -k flag +complete -c supabase -n 'not __supabase_requires_order_preservation && __supabase_prepare_completions' -f -a '$__supabase_comp_results' +# otherwise we use the -k flag +complete -k -c supabase -n '__supabase_requires_order_preservation && __supabase_prepare_completions' -f -a '$__supabase_comp_results' diff --git a/apps/cli/src/legacy/commands/completion/__fixtures__/fish.nodesc.txt b/apps/cli/src/legacy/commands/completion/__fixtures__/fish.nodesc.txt new file mode 100644 index 0000000000..40e78e579a --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/__fixtures__/fish.nodesc.txt @@ -0,0 +1,235 @@ +# fish completion for supabase -*- shell-script -*- + +function __supabase_debug + set -l file "$BASH_COMP_DEBUG_FILE" + if test -n "$file" + echo "$argv" >> $file + end +end + +function __supabase_perform_completion + __supabase_debug "Starting __supabase_perform_completion" + + # Extract all args except the last one + set -l args (commandline -opc) + # Extract the last arg and escape it in case it is a space + set -l lastArg (string escape -- (commandline -ct)) + + __supabase_debug "args: $args" + __supabase_debug "last arg: $lastArg" + + # Disable ActiveHelp which is not supported for fish shell + set -l requestComp "SUPABASE_ACTIVE_HELP=0 $args[1] __completeNoDesc $args[2..-1] $lastArg" + + __supabase_debug "Calling $requestComp" + set -l results (eval $requestComp 2> /dev/null) + + # Some programs may output extra empty lines after the directive. + # Let's ignore them or else it will break completion. + # Ref: https://github.com/spf13/cobra/issues/1279 + for line in $results[-1..1] + if test (string trim -- $line) = "" + # Found an empty line, remove it + set results $results[1..-2] + else + # Found non-empty line, we have our proper output + break + end + end + + set -l comps $results[1..-2] + set -l directiveLine $results[-1] + + # For Fish, when completing a flag with an = (e.g., -n=) + # completions must be prefixed with the flag + set -l flagPrefix (string match -r -- '-.*=' "$lastArg") + + __supabase_debug "Comps: $comps" + __supabase_debug "DirectiveLine: $directiveLine" + __supabase_debug "flagPrefix: $flagPrefix" + + for comp in $comps + printf "%s%s\n" "$flagPrefix" "$comp" + end + + printf "%s\n" "$directiveLine" +end + +# this function limits calls to __supabase_perform_completion, by caching the result behind $__supabase_perform_completion_once_result +function __supabase_perform_completion_once + __supabase_debug "Starting __supabase_perform_completion_once" + + if test -n "$__supabase_perform_completion_once_result" + __supabase_debug "Seems like a valid result already exists, skipping __supabase_perform_completion" + return 0 + end + + set --global __supabase_perform_completion_once_result (__supabase_perform_completion) + if test -z "$__supabase_perform_completion_once_result" + __supabase_debug "No completions, probably due to a failure" + return 1 + end + + __supabase_debug "Performed completions and set __supabase_perform_completion_once_result" + return 0 +end + +# this function is used to clear the $__supabase_perform_completion_once_result variable after completions are run +function __supabase_clear_perform_completion_once_result + __supabase_debug "" + __supabase_debug "========= clearing previously set __supabase_perform_completion_once_result variable ==========" + set --erase __supabase_perform_completion_once_result + __supabase_debug "Successfully erased the variable __supabase_perform_completion_once_result" +end + +function __supabase_requires_order_preservation + __supabase_debug "" + __supabase_debug "========= checking if order preservation is required ==========" + + __supabase_perform_completion_once + if test -z "$__supabase_perform_completion_once_result" + __supabase_debug "Error determining if order preservation is required" + return 1 + end + + set -l directive (string sub --start 2 $__supabase_perform_completion_once_result[-1]) + __supabase_debug "Directive is: $directive" + + set -l shellCompDirectiveKeepOrder 32 + set -l keeporder (math (math --scale 0 $directive / $shellCompDirectiveKeepOrder) % 2) + __supabase_debug "Keeporder is: $keeporder" + + if test $keeporder -ne 0 + __supabase_debug "This does require order preservation" + return 0 + end + + __supabase_debug "This doesn't require order preservation" + return 1 +end + + +# This function does two things: +# - Obtain the completions and store them in the global __supabase_comp_results +# - Return false if file completion should be performed +function __supabase_prepare_completions + __supabase_debug "" + __supabase_debug "========= starting completion logic ==========" + + # Start fresh + set --erase __supabase_comp_results + + __supabase_perform_completion_once + __supabase_debug "Completion results: $__supabase_perform_completion_once_result" + + if test -z "$__supabase_perform_completion_once_result" + __supabase_debug "No completion, probably due to a failure" + # Might as well do file completion, in case it helps + return 1 + end + + set -l directive (string sub --start 2 $__supabase_perform_completion_once_result[-1]) + set --global __supabase_comp_results $__supabase_perform_completion_once_result[1..-2] + + __supabase_debug "Completions are: $__supabase_comp_results" + __supabase_debug "Directive is: $directive" + + set -l shellCompDirectiveError 1 + set -l shellCompDirectiveNoSpace 2 + set -l shellCompDirectiveNoFileComp 4 + set -l shellCompDirectiveFilterFileExt 8 + set -l shellCompDirectiveFilterDirs 16 + + if test -z "$directive" + set directive 0 + end + + set -l compErr (math (math --scale 0 $directive / $shellCompDirectiveError) % 2) + if test $compErr -eq 1 + __supabase_debug "Received error directive: aborting." + # Might as well do file completion, in case it helps + return 1 + end + + set -l filefilter (math (math --scale 0 $directive / $shellCompDirectiveFilterFileExt) % 2) + set -l dirfilter (math (math --scale 0 $directive / $shellCompDirectiveFilterDirs) % 2) + if test $filefilter -eq 1; or test $dirfilter -eq 1 + __supabase_debug "File extension filtering or directory filtering not supported" + # Do full file completion instead + return 1 + end + + set -l nospace (math (math --scale 0 $directive / $shellCompDirectiveNoSpace) % 2) + set -l nofiles (math (math --scale 0 $directive / $shellCompDirectiveNoFileComp) % 2) + + __supabase_debug "nospace: $nospace, nofiles: $nofiles" + + # If we want to prevent a space, or if file completion is NOT disabled, + # we need to count the number of valid completions. + # To do so, we will filter on prefix as the completions we have received + # may not already be filtered so as to allow fish to match on different + # criteria than the prefix. + if test $nospace -ne 0; or test $nofiles -eq 0 + set -l prefix (commandline -t | string escape --style=regex) + __supabase_debug "prefix: $prefix" + + set -l completions (string match -r -- "^$prefix.*" $__supabase_comp_results) + set --global __supabase_comp_results $completions + __supabase_debug "Filtered completions are: $__supabase_comp_results" + + # Important not to quote the variable for count to work + set -l numComps (count $__supabase_comp_results) + __supabase_debug "numComps: $numComps" + + if test $numComps -eq 1; and test $nospace -ne 0 + # We must first split on \t to get rid of the descriptions to be + # able to check what the actual completion will be. + # We don't need descriptions anyway since there is only a single + # real completion which the shell will expand immediately. + set -l split (string split --max 1 \t $__supabase_comp_results[1]) + + # Fish won't add a space if the completion ends with any + # of the following characters: @=/:., + set -l lastChar (string sub -s -1 -- $split) + if not string match -r -q "[@=/:.,]" -- "$lastChar" + # In other cases, to support the "nospace" directive we trick the shell + # by outputting an extra, longer completion. + __supabase_debug "Adding second completion to perform nospace directive" + set --global __supabase_comp_results $split[1] $split[1]. + __supabase_debug "Completions are now: $__supabase_comp_results" + end + end + + if test $numComps -eq 0; and test $nofiles -eq 0 + # To be consistent with bash and zsh, we only trigger file + # completion when there are no other completions + __supabase_debug "Requesting file completion" + return 1 + end + end + + return 0 +end + +# Since Fish completions are only loaded once the user triggers them, we trigger them ourselves +# so we can properly delete any completions provided by another script. +# Only do this if the program can be found, or else fish may print some errors; besides, +# the existing completions will only be loaded if the program can be found. +if type -q "supabase" + # The space after the program name is essential to trigger completion for the program + # and not completion of the program name itself. + # Also, we use '> /dev/null 2>&1' since '&>' is not supported in older versions of fish. + complete --do-complete "supabase " > /dev/null 2>&1 +end + +# Remove any pre-existing completions for the program since we will be handling all of them. +complete -c supabase -e + +# this will get called after the two calls below and clear the $__supabase_perform_completion_once_result global +complete -c supabase -n '__supabase_clear_perform_completion_once_result' +# The call to __supabase_prepare_completions will setup __supabase_comp_results +# which provides the program's completion choices. +# If this doesn't require order preservation, we don't use the -k flag +complete -c supabase -n 'not __supabase_requires_order_preservation && __supabase_prepare_completions' -f -a '$__supabase_comp_results' +# otherwise we use the -k flag +complete -k -c supabase -n '__supabase_requires_order_preservation && __supabase_prepare_completions' -f -a '$__supabase_comp_results' diff --git a/apps/cli/src/legacy/commands/completion/__fixtures__/powershell.desc.txt b/apps/cli/src/legacy/commands/completion/__fixtures__/powershell.desc.txt new file mode 100644 index 0000000000..9ae33607e4 --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/__fixtures__/powershell.desc.txt @@ -0,0 +1,270 @@ +# powershell completion for supabase -*- shell-script -*- + +function __supabase_debug { + if ($env:BASH_COMP_DEBUG_FILE) { + "$args" | Out-File -Append -FilePath "$env:BASH_COMP_DEBUG_FILE" + } +} + +filter __supabase_escapeStringWithSpecialChars { + $_ -replace '\s|#|@|\$|;|,|''|\{|\}|\(|\)|"|`|\||<|>|&','`$&' +} + +[scriptblock]${__supabaseCompleterBlock} = { + param( + $WordToComplete, + $CommandAst, + $CursorPosition + ) + + # Get the current command line and convert into a string + $Command = $CommandAst.CommandElements + $Command = "$Command" + + __supabase_debug "" + __supabase_debug "========= starting completion logic ==========" + __supabase_debug "WordToComplete: $WordToComplete Command: $Command CursorPosition: $CursorPosition" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CursorPosition location, so we need + # to truncate the command-line ($Command) up to the $CursorPosition location. + # Make sure the $Command is longer then the $CursorPosition before we truncate. + # This happens because the $Command does not include the last space. + if ($Command.Length -gt $CursorPosition) { + $Command=$Command.Substring(0,$CursorPosition) + } + __supabase_debug "Truncated command: $Command" + + $ShellCompDirectiveError=1 + $ShellCompDirectiveNoSpace=2 + $ShellCompDirectiveNoFileComp=4 + $ShellCompDirectiveFilterFileExt=8 + $ShellCompDirectiveFilterDirs=16 + $ShellCompDirectiveKeepOrder=32 + + # Prepare the command to request completions for the program. + # Split the command at the first space to separate the program and arguments. + $Program,$Arguments = $Command.Split(" ",2) + + $RequestComp="$Program __complete $Arguments" + __supabase_debug "RequestComp: $RequestComp" + + # we cannot use $WordToComplete because it + # has the wrong values if the cursor was moved + # so use the last argument + if ($WordToComplete -ne "" ) { + $WordToComplete = $Arguments.Split(" ")[-1] + } + __supabase_debug "New WordToComplete: $WordToComplete" + + + # Check for flag with equal sign + $IsEqualFlag = ($WordToComplete -Like "--*=*" ) + if ( $IsEqualFlag ) { + __supabase_debug "Completing equal sign flag" + # Remove the flag part + $Flag,$WordToComplete = $WordToComplete.Split("=",2) + } + + if ( $WordToComplete -eq "" -And ( -Not $IsEqualFlag )) { + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go method. + __supabase_debug "Adding extra empty parameter" + # PowerShell 7.2+ changed the way how the arguments are passed to executables, + # so for pre-7.2 or when Legacy argument passing is enabled we need to use + # `"`" to pass an empty argument, a "" or '' does not work!!! + if ($PSVersionTable.PsVersion -lt [version]'7.2.0' -or + ($PSVersionTable.PsVersion -lt [version]'7.3.0' -and -not [ExperimentalFeature]::IsEnabled("PSNativeCommandArgumentPassing")) -or + (($PSVersionTable.PsVersion -ge [version]'7.3.0' -or [ExperimentalFeature]::IsEnabled("PSNativeCommandArgumentPassing")) -and + $PSNativeCommandArgumentPassing -eq 'Legacy')) { + $RequestComp="$RequestComp" + ' `"`"' + } else { + $RequestComp="$RequestComp" + ' ""' + } + } + + __supabase_debug "Calling $RequestComp" + # First disable ActiveHelp which is not supported for Powershell + ${env:SUPABASE_ACTIVE_HELP}=0 + + #call the command store the output in $out and redirect stderr and stdout to null + # $Out is an array contains each line per element + Invoke-Expression -OutVariable out "$RequestComp" 2>&1 | Out-Null + + # get directive from last line + [int]$Directive = $Out[-1].TrimStart(':') + if ($Directive -eq "") { + # There is no directive specified + $Directive = 0 + } + __supabase_debug "The completion directive is: $Directive" + + # remove directive (last element) from out + $Out = $Out | Where-Object { $_ -ne $Out[-1] } + __supabase_debug "The completions are: $Out" + + if (($Directive -band $ShellCompDirectiveError) -ne 0 ) { + # Error code. No completion. + __supabase_debug "Received error from custom completion go code" + return + } + + $Longest = 0 + [Array]$Values = $Out | ForEach-Object { + #Split the output in name and description + $Name, $Description = $_.Split("`t",2) + __supabase_debug "Name: $Name Description: $Description" + + # Look for the longest completion so that we can format things nicely + if ($Longest -lt $Name.Length) { + $Longest = $Name.Length + } + + # Set the description to a one space string if there is none set. + # This is needed because the CompletionResult does not accept an empty string as argument + if (-Not $Description) { + $Description = " " + } + New-Object -TypeName PSCustomObject -Property @{ + Name = "$Name" + Description = "$Description" + } + } + + + $Space = " " + if (($Directive -band $ShellCompDirectiveNoSpace) -ne 0 ) { + # remove the space here + __supabase_debug "ShellCompDirectiveNoSpace is called" + $Space = "" + } + + if ((($Directive -band $ShellCompDirectiveFilterFileExt) -ne 0 ) -or + (($Directive -band $ShellCompDirectiveFilterDirs) -ne 0 )) { + __supabase_debug "ShellCompDirectiveFilterFileExt ShellCompDirectiveFilterDirs are not supported" + + # return here to prevent the completion of the extensions + return + } + + $Values = $Values | Where-Object { + # filter the result + $_.Name -like "$WordToComplete*" + + # Join the flag back if we have an equal sign flag + if ( $IsEqualFlag ) { + __supabase_debug "Join the equal sign flag back to the completion value" + $_.Name = $Flag + "=" + $_.Name + } + } + + # we sort the values in ascending order by name if keep order isn't passed + if (($Directive -band $ShellCompDirectiveKeepOrder) -eq 0 ) { + $Values = $Values | Sort-Object -Property Name + } + + if (($Directive -band $ShellCompDirectiveNoFileComp) -ne 0 ) { + __supabase_debug "ShellCompDirectiveNoFileComp is called" + + if ($Values.Length -eq 0) { + # Just print an empty string here so the + # shell does not start to complete paths. + # We cannot use CompletionResult here because + # it does not accept an empty string as argument. + "" + return + } + } + + # Get the current mode + $Mode = (Get-PSReadLineKeyHandler | Where-Object {$_.Key -eq "Tab" }).Function + __supabase_debug "Mode: $Mode" + + $Values | ForEach-Object { + + # store temporary because switch will overwrite $_ + $comp = $_ + + # PowerShell supports three different completion modes + # - TabCompleteNext (default windows style - on each key press the next option is displayed) + # - Complete (works like bash) + # - MenuComplete (works like zsh) + # You set the mode with Set-PSReadLineKeyHandler -Key Tab -Function + + # CompletionResult Arguments: + # 1) CompletionText text to be used as the auto completion result + # 2) ListItemText text to be displayed in the suggestion list + # 3) ResultType type of completion result + # 4) ToolTip text for the tooltip with details about the object + + switch ($Mode) { + + # bash like + "Complete" { + + if ($Values.Length -eq 1) { + __supabase_debug "Only one completion left" + + # insert space after value + $CompletionText = $($comp.Name | __supabase_escapeStringWithSpecialChars) + $Space + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + + } else { + # Add the proper number of spaces to align the descriptions + while($comp.Name.Length -lt $Longest) { + $comp.Name = $comp.Name + " " + } + + # Check for empty description and only add parentheses if needed + if ($($comp.Description) -eq " " ) { + $Description = "" + } else { + $Description = " ($($comp.Description))" + } + + $CompletionText = "$($comp.Name)$Description" + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)$Description", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + } + } + + # zsh like + "MenuComplete" { + # insert space after value + # MenuComplete will automatically show the ToolTip of + # the highlighted value at the bottom of the suggestions. + + $CompletionText = $($comp.Name | __supabase_escapeStringWithSpecialChars) + $Space + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + } + + # TabCompleteNext and in case we get something unknown + Default { + # Like MenuComplete but we don't want to add a space here because + # the user need to press space anyway to get the completion. + # Description will not be shown because that's not possible with TabCompleteNext + + $CompletionText = $($comp.Name | __supabase_escapeStringWithSpecialChars) + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + } + } + + } +} + +Register-ArgumentCompleter -CommandName 'supabase' -ScriptBlock ${__supabaseCompleterBlock} diff --git a/apps/cli/src/legacy/commands/completion/__fixtures__/powershell.nodesc.txt b/apps/cli/src/legacy/commands/completion/__fixtures__/powershell.nodesc.txt new file mode 100644 index 0000000000..f023822b54 --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/__fixtures__/powershell.nodesc.txt @@ -0,0 +1,270 @@ +# powershell completion for supabase -*- shell-script -*- + +function __supabase_debug { + if ($env:BASH_COMP_DEBUG_FILE) { + "$args" | Out-File -Append -FilePath "$env:BASH_COMP_DEBUG_FILE" + } +} + +filter __supabase_escapeStringWithSpecialChars { + $_ -replace '\s|#|@|\$|;|,|''|\{|\}|\(|\)|"|`|\||<|>|&','`$&' +} + +[scriptblock]${__supabaseCompleterBlock} = { + param( + $WordToComplete, + $CommandAst, + $CursorPosition + ) + + # Get the current command line and convert into a string + $Command = $CommandAst.CommandElements + $Command = "$Command" + + __supabase_debug "" + __supabase_debug "========= starting completion logic ==========" + __supabase_debug "WordToComplete: $WordToComplete Command: $Command CursorPosition: $CursorPosition" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CursorPosition location, so we need + # to truncate the command-line ($Command) up to the $CursorPosition location. + # Make sure the $Command is longer then the $CursorPosition before we truncate. + # This happens because the $Command does not include the last space. + if ($Command.Length -gt $CursorPosition) { + $Command=$Command.Substring(0,$CursorPosition) + } + __supabase_debug "Truncated command: $Command" + + $ShellCompDirectiveError=1 + $ShellCompDirectiveNoSpace=2 + $ShellCompDirectiveNoFileComp=4 + $ShellCompDirectiveFilterFileExt=8 + $ShellCompDirectiveFilterDirs=16 + $ShellCompDirectiveKeepOrder=32 + + # Prepare the command to request completions for the program. + # Split the command at the first space to separate the program and arguments. + $Program,$Arguments = $Command.Split(" ",2) + + $RequestComp="$Program __completeNoDesc $Arguments" + __supabase_debug "RequestComp: $RequestComp" + + # we cannot use $WordToComplete because it + # has the wrong values if the cursor was moved + # so use the last argument + if ($WordToComplete -ne "" ) { + $WordToComplete = $Arguments.Split(" ")[-1] + } + __supabase_debug "New WordToComplete: $WordToComplete" + + + # Check for flag with equal sign + $IsEqualFlag = ($WordToComplete -Like "--*=*" ) + if ( $IsEqualFlag ) { + __supabase_debug "Completing equal sign flag" + # Remove the flag part + $Flag,$WordToComplete = $WordToComplete.Split("=",2) + } + + if ( $WordToComplete -eq "" -And ( -Not $IsEqualFlag )) { + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go method. + __supabase_debug "Adding extra empty parameter" + # PowerShell 7.2+ changed the way how the arguments are passed to executables, + # so for pre-7.2 or when Legacy argument passing is enabled we need to use + # `"`" to pass an empty argument, a "" or '' does not work!!! + if ($PSVersionTable.PsVersion -lt [version]'7.2.0' -or + ($PSVersionTable.PsVersion -lt [version]'7.3.0' -and -not [ExperimentalFeature]::IsEnabled("PSNativeCommandArgumentPassing")) -or + (($PSVersionTable.PsVersion -ge [version]'7.3.0' -or [ExperimentalFeature]::IsEnabled("PSNativeCommandArgumentPassing")) -and + $PSNativeCommandArgumentPassing -eq 'Legacy')) { + $RequestComp="$RequestComp" + ' `"`"' + } else { + $RequestComp="$RequestComp" + ' ""' + } + } + + __supabase_debug "Calling $RequestComp" + # First disable ActiveHelp which is not supported for Powershell + ${env:SUPABASE_ACTIVE_HELP}=0 + + #call the command store the output in $out and redirect stderr and stdout to null + # $Out is an array contains each line per element + Invoke-Expression -OutVariable out "$RequestComp" 2>&1 | Out-Null + + # get directive from last line + [int]$Directive = $Out[-1].TrimStart(':') + if ($Directive -eq "") { + # There is no directive specified + $Directive = 0 + } + __supabase_debug "The completion directive is: $Directive" + + # remove directive (last element) from out + $Out = $Out | Where-Object { $_ -ne $Out[-1] } + __supabase_debug "The completions are: $Out" + + if (($Directive -band $ShellCompDirectiveError) -ne 0 ) { + # Error code. No completion. + __supabase_debug "Received error from custom completion go code" + return + } + + $Longest = 0 + [Array]$Values = $Out | ForEach-Object { + #Split the output in name and description + $Name, $Description = $_.Split("`t",2) + __supabase_debug "Name: $Name Description: $Description" + + # Look for the longest completion so that we can format things nicely + if ($Longest -lt $Name.Length) { + $Longest = $Name.Length + } + + # Set the description to a one space string if there is none set. + # This is needed because the CompletionResult does not accept an empty string as argument + if (-Not $Description) { + $Description = " " + } + New-Object -TypeName PSCustomObject -Property @{ + Name = "$Name" + Description = "$Description" + } + } + + + $Space = " " + if (($Directive -band $ShellCompDirectiveNoSpace) -ne 0 ) { + # remove the space here + __supabase_debug "ShellCompDirectiveNoSpace is called" + $Space = "" + } + + if ((($Directive -band $ShellCompDirectiveFilterFileExt) -ne 0 ) -or + (($Directive -band $ShellCompDirectiveFilterDirs) -ne 0 )) { + __supabase_debug "ShellCompDirectiveFilterFileExt ShellCompDirectiveFilterDirs are not supported" + + # return here to prevent the completion of the extensions + return + } + + $Values = $Values | Where-Object { + # filter the result + $_.Name -like "$WordToComplete*" + + # Join the flag back if we have an equal sign flag + if ( $IsEqualFlag ) { + __supabase_debug "Join the equal sign flag back to the completion value" + $_.Name = $Flag + "=" + $_.Name + } + } + + # we sort the values in ascending order by name if keep order isn't passed + if (($Directive -band $ShellCompDirectiveKeepOrder) -eq 0 ) { + $Values = $Values | Sort-Object -Property Name + } + + if (($Directive -band $ShellCompDirectiveNoFileComp) -ne 0 ) { + __supabase_debug "ShellCompDirectiveNoFileComp is called" + + if ($Values.Length -eq 0) { + # Just print an empty string here so the + # shell does not start to complete paths. + # We cannot use CompletionResult here because + # it does not accept an empty string as argument. + "" + return + } + } + + # Get the current mode + $Mode = (Get-PSReadLineKeyHandler | Where-Object {$_.Key -eq "Tab" }).Function + __supabase_debug "Mode: $Mode" + + $Values | ForEach-Object { + + # store temporary because switch will overwrite $_ + $comp = $_ + + # PowerShell supports three different completion modes + # - TabCompleteNext (default windows style - on each key press the next option is displayed) + # - Complete (works like bash) + # - MenuComplete (works like zsh) + # You set the mode with Set-PSReadLineKeyHandler -Key Tab -Function + + # CompletionResult Arguments: + # 1) CompletionText text to be used as the auto completion result + # 2) ListItemText text to be displayed in the suggestion list + # 3) ResultType type of completion result + # 4) ToolTip text for the tooltip with details about the object + + switch ($Mode) { + + # bash like + "Complete" { + + if ($Values.Length -eq 1) { + __supabase_debug "Only one completion left" + + # insert space after value + $CompletionText = $($comp.Name | __supabase_escapeStringWithSpecialChars) + $Space + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + + } else { + # Add the proper number of spaces to align the descriptions + while($comp.Name.Length -lt $Longest) { + $comp.Name = $comp.Name + " " + } + + # Check for empty description and only add parentheses if needed + if ($($comp.Description) -eq " " ) { + $Description = "" + } else { + $Description = " ($($comp.Description))" + } + + $CompletionText = "$($comp.Name)$Description" + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)$Description", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + } + } + + # zsh like + "MenuComplete" { + # insert space after value + # MenuComplete will automatically show the ToolTip of + # the highlighted value at the bottom of the suggestions. + + $CompletionText = $($comp.Name | __supabase_escapeStringWithSpecialChars) + $Space + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + } + + # TabCompleteNext and in case we get something unknown + Default { + # Like MenuComplete but we don't want to add a space here because + # the user need to press space anyway to get the completion. + # Description will not be shown because that's not possible with TabCompleteNext + + $CompletionText = $($comp.Name | __supabase_escapeStringWithSpecialChars) + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + } + } + + } +} + +Register-ArgumentCompleter -CommandName 'supabase' -ScriptBlock ${__supabaseCompleterBlock} diff --git a/apps/cli/src/legacy/commands/completion/__fixtures__/zsh.desc.txt b/apps/cli/src/legacy/commands/completion/__fixtures__/zsh.desc.txt new file mode 100644 index 0000000000..68ab1ac253 --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/__fixtures__/zsh.desc.txt @@ -0,0 +1,212 @@ +#compdef supabase +compdef _supabase supabase + +# zsh completion for supabase -*- shell-script -*- + +__supabase_debug() +{ + local file="$BASH_COMP_DEBUG_FILE" + if [[ -n ${file} ]]; then + echo "$*" >> "${file}" + fi +} + +_supabase() +{ + local shellCompDirectiveError=1 + local shellCompDirectiveNoSpace=2 + local shellCompDirectiveNoFileComp=4 + local shellCompDirectiveFilterFileExt=8 + local shellCompDirectiveFilterDirs=16 + local shellCompDirectiveKeepOrder=32 + + local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder + local -a completions + + __supabase_debug "\n========= starting completion logic ==========" + __supabase_debug "CURRENT: ${CURRENT}, words[*]: ${words[*]}" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CURRENT location, so we need + # to truncate the command-line ($words) up to the $CURRENT location. + # (We cannot use $CURSOR as its value does not work when a command is an alias.) + words=("${=words[1,CURRENT]}") + __supabase_debug "Truncated words[*]: ${words[*]}," + + lastParam=${words[-1]} + lastChar=${lastParam[-1]} + __supabase_debug "lastParam: ${lastParam}, lastChar: ${lastChar}" + + # For zsh, when completing a flag with an = (e.g., supabase -n=) + # completions must be prefixed with the flag + setopt local_options BASH_REMATCH + if [[ "${lastParam}" =~ '-.*=' ]]; then + # We are dealing with a flag with an = + flagPrefix="-P ${BASH_REMATCH}" + fi + + # Prepare the command to obtain completions + requestComp="${words[1]} __complete ${words[2,-1]}" + if [ "${lastChar}" = "" ]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go completion code. + __supabase_debug "Adding extra empty parameter" + requestComp="${requestComp} \"\"" + fi + + __supabase_debug "About to call: eval ${requestComp}" + + # Use eval to handle any environment variables and such + out=$(eval ${requestComp} 2>/dev/null) + __supabase_debug "completion output: ${out}" + + # Extract the directive integer following a : from the last line + local lastLine + while IFS='\n' read -r line; do + lastLine=${line} + done < <(printf "%s\n" "${out[@]}") + __supabase_debug "last line: ${lastLine}" + + if [ "${lastLine[1]}" = : ]; then + directive=${lastLine[2,-1]} + # Remove the directive including the : and the newline + local suffix + (( suffix=${#lastLine}+2)) + out=${out[1,-$suffix]} + else + # There is no directive specified. Leave $out as is. + __supabase_debug "No directive found. Setting do default" + directive=0 + fi + + __supabase_debug "directive: ${directive}" + __supabase_debug "completions: ${out}" + __supabase_debug "flagPrefix: ${flagPrefix}" + + if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then + __supabase_debug "Completion received error. Ignoring completions." + return + fi + + local activeHelpMarker="_activeHelp_ " + local endIndex=${#activeHelpMarker} + local startIndex=$((${#activeHelpMarker}+1)) + local hasActiveHelp=0 + while IFS='\n' read -r comp; do + # Check if this is an activeHelp statement (i.e., prefixed with $activeHelpMarker) + if [ "${comp[1,$endIndex]}" = "$activeHelpMarker" ];then + __supabase_debug "ActiveHelp found: $comp" + comp="${comp[$startIndex,-1]}" + if [ -n "$comp" ]; then + compadd -x "${comp}" + __supabase_debug "ActiveHelp will need delimiter" + hasActiveHelp=1 + fi + + continue + fi + + if [ -n "$comp" ]; then + # If requested, completions are returned with a description. + # The description is preceded by a TAB character. + # For zsh's _describe, we need to use a : instead of a TAB. + # We first need to escape any : as part of the completion itself. + comp=${comp//:/\\:} + + local tab="$(printf '\t')" + comp=${comp//$tab/:} + + __supabase_debug "Adding completion: ${comp}" + completions+=${comp} + lastComp=$comp + fi + done < <(printf "%s\n" "${out[@]}") + + # Add a delimiter after the activeHelp statements, but only if: + # - there are completions following the activeHelp statements, or + # - file completion will be performed (so there will be choices after the activeHelp) + if [ $hasActiveHelp -eq 1 ]; then + if [ ${#completions} -ne 0 ] || [ $((directive & shellCompDirectiveNoFileComp)) -eq 0 ]; then + __supabase_debug "Adding activeHelp delimiter" + compadd -x "--" + hasActiveHelp=0 + fi + fi + + if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then + __supabase_debug "Activating nospace." + noSpace="-S ''" + fi + + if [ $((directive & shellCompDirectiveKeepOrder)) -ne 0 ]; then + __supabase_debug "Activating keep order." + keepOrder="-V" + fi + + if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then + # File extension filtering + local filteringCmd + filteringCmd='_files' + for filter in ${completions[@]}; do + if [ ${filter[1]} != '*' ]; then + # zsh requires a glob pattern to do file filtering + filter="\*.$filter" + fi + filteringCmd+=" -g $filter" + done + filteringCmd+=" ${flagPrefix}" + + __supabase_debug "File filtering command: $filteringCmd" + _arguments '*:filename:'"$filteringCmd" + elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then + # File completion for directories only + local subdir + subdir="${completions[1]}" + if [ -n "$subdir" ]; then + __supabase_debug "Listing directories in $subdir" + pushd "${subdir}" >/dev/null 2>&1 + else + __supabase_debug "Listing directories in ." + fi + + local result + _arguments '*:dirname:_files -/'" ${flagPrefix}" + result=$? + if [ -n "$subdir" ]; then + popd >/dev/null 2>&1 + fi + return $result + else + __supabase_debug "Calling _describe" + if eval _describe $keepOrder "completions" completions $flagPrefix $noSpace; then + __supabase_debug "_describe found some completions" + + # Return the success of having called _describe + return 0 + else + __supabase_debug "_describe did not find completions." + __supabase_debug "Checking if we should do file completion." + if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then + __supabase_debug "deactivating file completion" + + # We must return an error code here to let zsh know that there were no + # completions found by _describe; this is what will trigger other + # matching algorithms to attempt to find completions. + # For example zsh can match letters in the middle of words. + return 1 + else + # Perform file completion + __supabase_debug "Activating file completion" + + # We must return the result of this command, so it must be the + # last command, or else we must store its result to return it. + _arguments '*:filename:_files'" ${flagPrefix}" + fi + fi + fi +} + +# don't run the completion function when being source-ed or eval-ed +if [ "$funcstack[1]" = "_supabase" ]; then + _supabase +fi diff --git a/apps/cli/src/legacy/commands/completion/__fixtures__/zsh.nodesc.txt b/apps/cli/src/legacy/commands/completion/__fixtures__/zsh.nodesc.txt new file mode 100644 index 0000000000..1b4be7db2e --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/__fixtures__/zsh.nodesc.txt @@ -0,0 +1,212 @@ +#compdef supabase +compdef _supabase supabase + +# zsh completion for supabase -*- shell-script -*- + +__supabase_debug() +{ + local file="$BASH_COMP_DEBUG_FILE" + if [[ -n ${file} ]]; then + echo "$*" >> "${file}" + fi +} + +_supabase() +{ + local shellCompDirectiveError=1 + local shellCompDirectiveNoSpace=2 + local shellCompDirectiveNoFileComp=4 + local shellCompDirectiveFilterFileExt=8 + local shellCompDirectiveFilterDirs=16 + local shellCompDirectiveKeepOrder=32 + + local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder + local -a completions + + __supabase_debug "\n========= starting completion logic ==========" + __supabase_debug "CURRENT: ${CURRENT}, words[*]: ${words[*]}" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CURRENT location, so we need + # to truncate the command-line ($words) up to the $CURRENT location. + # (We cannot use $CURSOR as its value does not work when a command is an alias.) + words=("${=words[1,CURRENT]}") + __supabase_debug "Truncated words[*]: ${words[*]}," + + lastParam=${words[-1]} + lastChar=${lastParam[-1]} + __supabase_debug "lastParam: ${lastParam}, lastChar: ${lastChar}" + + # For zsh, when completing a flag with an = (e.g., supabase -n=) + # completions must be prefixed with the flag + setopt local_options BASH_REMATCH + if [[ "${lastParam}" =~ '-.*=' ]]; then + # We are dealing with a flag with an = + flagPrefix="-P ${BASH_REMATCH}" + fi + + # Prepare the command to obtain completions + requestComp="${words[1]} __completeNoDesc ${words[2,-1]}" + if [ "${lastChar}" = "" ]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go completion code. + __supabase_debug "Adding extra empty parameter" + requestComp="${requestComp} \"\"" + fi + + __supabase_debug "About to call: eval ${requestComp}" + + # Use eval to handle any environment variables and such + out=$(eval ${requestComp} 2>/dev/null) + __supabase_debug "completion output: ${out}" + + # Extract the directive integer following a : from the last line + local lastLine + while IFS='\n' read -r line; do + lastLine=${line} + done < <(printf "%s\n" "${out[@]}") + __supabase_debug "last line: ${lastLine}" + + if [ "${lastLine[1]}" = : ]; then + directive=${lastLine[2,-1]} + # Remove the directive including the : and the newline + local suffix + (( suffix=${#lastLine}+2)) + out=${out[1,-$suffix]} + else + # There is no directive specified. Leave $out as is. + __supabase_debug "No directive found. Setting do default" + directive=0 + fi + + __supabase_debug "directive: ${directive}" + __supabase_debug "completions: ${out}" + __supabase_debug "flagPrefix: ${flagPrefix}" + + if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then + __supabase_debug "Completion received error. Ignoring completions." + return + fi + + local activeHelpMarker="_activeHelp_ " + local endIndex=${#activeHelpMarker} + local startIndex=$((${#activeHelpMarker}+1)) + local hasActiveHelp=0 + while IFS='\n' read -r comp; do + # Check if this is an activeHelp statement (i.e., prefixed with $activeHelpMarker) + if [ "${comp[1,$endIndex]}" = "$activeHelpMarker" ];then + __supabase_debug "ActiveHelp found: $comp" + comp="${comp[$startIndex,-1]}" + if [ -n "$comp" ]; then + compadd -x "${comp}" + __supabase_debug "ActiveHelp will need delimiter" + hasActiveHelp=1 + fi + + continue + fi + + if [ -n "$comp" ]; then + # If requested, completions are returned with a description. + # The description is preceded by a TAB character. + # For zsh's _describe, we need to use a : instead of a TAB. + # We first need to escape any : as part of the completion itself. + comp=${comp//:/\\:} + + local tab="$(printf '\t')" + comp=${comp//$tab/:} + + __supabase_debug "Adding completion: ${comp}" + completions+=${comp} + lastComp=$comp + fi + done < <(printf "%s\n" "${out[@]}") + + # Add a delimiter after the activeHelp statements, but only if: + # - there are completions following the activeHelp statements, or + # - file completion will be performed (so there will be choices after the activeHelp) + if [ $hasActiveHelp -eq 1 ]; then + if [ ${#completions} -ne 0 ] || [ $((directive & shellCompDirectiveNoFileComp)) -eq 0 ]; then + __supabase_debug "Adding activeHelp delimiter" + compadd -x "--" + hasActiveHelp=0 + fi + fi + + if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then + __supabase_debug "Activating nospace." + noSpace="-S ''" + fi + + if [ $((directive & shellCompDirectiveKeepOrder)) -ne 0 ]; then + __supabase_debug "Activating keep order." + keepOrder="-V" + fi + + if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then + # File extension filtering + local filteringCmd + filteringCmd='_files' + for filter in ${completions[@]}; do + if [ ${filter[1]} != '*' ]; then + # zsh requires a glob pattern to do file filtering + filter="\*.$filter" + fi + filteringCmd+=" -g $filter" + done + filteringCmd+=" ${flagPrefix}" + + __supabase_debug "File filtering command: $filteringCmd" + _arguments '*:filename:'"$filteringCmd" + elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then + # File completion for directories only + local subdir + subdir="${completions[1]}" + if [ -n "$subdir" ]; then + __supabase_debug "Listing directories in $subdir" + pushd "${subdir}" >/dev/null 2>&1 + else + __supabase_debug "Listing directories in ." + fi + + local result + _arguments '*:dirname:_files -/'" ${flagPrefix}" + result=$? + if [ -n "$subdir" ]; then + popd >/dev/null 2>&1 + fi + return $result + else + __supabase_debug "Calling _describe" + if eval _describe $keepOrder "completions" completions $flagPrefix $noSpace; then + __supabase_debug "_describe found some completions" + + # Return the success of having called _describe + return 0 + else + __supabase_debug "_describe did not find completions." + __supabase_debug "Checking if we should do file completion." + if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then + __supabase_debug "deactivating file completion" + + # We must return an error code here to let zsh know that there were no + # completions found by _describe; this is what will trigger other + # matching algorithms to attempt to find completions. + # For example zsh can match letters in the middle of words. + return 1 + else + # Perform file completion + __supabase_debug "Activating file completion" + + # We must return the result of this command, so it must be the + # last command, or else we must store its result to return it. + _arguments '*:filename:_files'" ${flagPrefix}" + fi + fi + fi +} + +# don't run the completion function when being source-ed or eval-ed +if [ "$funcstack[1]" = "_supabase" ]; then + _supabase +fi diff --git a/apps/cli/src/legacy/commands/completion/bash/bash.command.ts b/apps/cli/src/legacy/commands/completion/bash/bash.command.ts index a2810b19b7..99e181c88c 100644 --- a/apps/cli/src/legacy/commands/completion/bash/bash.command.ts +++ b/apps/cli/src/legacy/commands/completion/bash/bash.command.ts @@ -9,7 +9,19 @@ const config = { export type LegacyCompletionBashFlags = CliCommand.Command.Config.Infer; export const legacyCompletionBashCommand = Command.make("bash", config).pipe( - Command.withDescription("Generate the autocompletion script for bash"), + Command.withDescription( + "Generate the autocompletion script for the bash shell.\n\n" + + "This script depends on the 'bash-completion' package.\n" + + "If it is not installed already, you can install it via your OS's package manager.\n\n" + + "To load completions in your current shell session:\n\n" + + "\tsource <(supabase completion bash)\n\n" + + "To load completions for every new session, execute once:\n\n" + + "#### Linux:\n\n" + + "\tsupabase completion bash > /etc/bash_completion.d/supabase\n\n" + + "#### macOS:\n\n" + + "\tsupabase completion bash > $(brew --prefix)/etc/bash_completion.d/supabase\n\n" + + "You will need to start a new shell for this setup to take effect.", + ), Command.withShortDescription("Generate the autocompletion script for bash"), Command.withHandler((flags) => legacyCompletionBash(flags)), ); diff --git a/apps/cli/src/legacy/commands/completion/bash/bash.handler.ts b/apps/cli/src/legacy/commands/completion/bash/bash.handler.ts index 9ff371dd44..e28ae7e975 100644 --- a/apps/cli/src/legacy/commands/completion/bash/bash.handler.ts +++ b/apps/cli/src/legacy/commands/completion/bash/bash.handler.ts @@ -1,12 +1,13 @@ import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyGenerateCompletionScript } from "../legacy-completion-scripts.ts"; import type { LegacyCompletionBashFlags } from "./bash.command.ts"; export const legacyCompletionBash = Effect.fn("legacy.completion.bash")(function* ( flags: LegacyCompletionBashFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["completion", "bash"]; - if (flags.noDescriptions) args.push("--no-descriptions"); - yield* proxy.exec(args); + const output = yield* Output; + yield* output.raw( + legacyGenerateCompletionScript("bash", { noDescriptions: flags.noDescriptions }), + ); }); diff --git a/apps/cli/src/legacy/commands/completion/bash/bash.integration.test.ts b/apps/cli/src/legacy/commands/completion/bash/bash.integration.test.ts index 3bb8f31137..1c8c7dd9e1 100644 --- a/apps/cli/src/legacy/commands/completion/bash/bash.integration.test.ts +++ b/apps/cli/src/legacy/commands/completion/bash/bash.integration.test.ts @@ -1,20 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect } from "effect"; import { Command } from "effect/unstable/cli"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { legacyCompletionBashCommand } from "./bash.command.ts"; import { legacyCompletionBash } from "./bash.handler.ts"; function setupLegacyCompletionBash() { - const calls: Array> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args) => - Effect.sync(() => { - calls.push(args); - }), - execCapture: () => Effect.succeed(""), - }); - return { layer, calls }; + return mockOutput(); } function legacyTestRoot() { @@ -22,30 +14,38 @@ function legacyTestRoot() { } describe("legacy completion bash", () => { - it.live("forwards `completion bash` to the Go binary", () => { - const { layer, calls } = setupLegacyCompletionBash(); + it.live("prints the native bash completion script", () => { + const out = setupLegacyCompletionBash(); return Effect.gen(function* () { yield* legacyCompletionBash({ noDescriptions: false }); - expect(calls).toEqual([["completion", "bash"]]); - }).pipe(Effect.provide(layer)); + expect(out.stdoutText).toContain("# bash completion V2 for supabase"); + expect(out.stdoutText).not.toContain("__completeNoDesc"); + expect(out.stdoutText).toContain("__complete"); + }).pipe(Effect.provide(out.layer)); }); - it.live("forwards --no-descriptions when set", () => { - const { layer, calls } = setupLegacyCompletionBash(); - return Effect.gen(function* () { - yield* legacyCompletionBash({ noDescriptions: true }); - expect(calls).toEqual([["completion", "bash", "--no-descriptions"]]); - }).pipe(Effect.provide(layer)); - }); + it.live( + "prints the native bash completion script without descriptions when --no-descriptions is set", + () => { + const out = setupLegacyCompletionBash(); + return Effect.gen(function* () { + yield* legacyCompletionBash({ noDescriptions: true }); + expect(out.stdoutText).toContain("__completeNoDesc"); + }).pipe(Effect.provide(out.layer)); + }, + ); - it.live("accepts --no-descriptions from real argv via the command parser", () => { - const { layer, calls } = setupLegacyCompletionBash(); - return Effect.gen(function* () { - yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ - "bash", - "--no-descriptions", - ]); - expect(calls).toEqual([["completion", "bash", "--no-descriptions"]]); - }).pipe(Effect.provide(layer)) as Effect.Effect; - }); + it.live( + "accepts --no-descriptions from real argv via the command parser and still prints the no-desc script", + () => { + const out = setupLegacyCompletionBash(); + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ + "bash", + "--no-descriptions", + ]); + expect(out.stdoutText).toContain("__completeNoDesc"); + }).pipe(Effect.provide(out.layer)) as Effect.Effect; + }, + ); }); diff --git a/apps/cli/src/legacy/commands/completion/completion.command.ts b/apps/cli/src/legacy/commands/completion/completion.command.ts index 38b7b53f88..056f62c3f9 100644 --- a/apps/cli/src/legacy/commands/completion/completion.command.ts +++ b/apps/cli/src/legacy/commands/completion/completion.command.ts @@ -9,7 +9,7 @@ export const legacyCompletionCommand = Command.make("completion").pipe( "Generate the autocompletion script for supabase for the specified shell.\n" + "See each sub-command's help for details on how to use the generated script.", ), - Command.withShortDescription("Generate autocompletion scripts"), + Command.withShortDescription("Generate the autocompletion script for the specified shell"), Command.withSubcommands([ legacyCompletionBashCommand, legacyCompletionFishCommand, diff --git a/apps/cli/src/legacy/commands/completion/completion.e2e.test.ts b/apps/cli/src/legacy/commands/completion/completion.e2e.test.ts index 56c721e5f3..3e23959686 100644 --- a/apps/cli/src/legacy/commands/completion/completion.e2e.test.ts +++ b/apps/cli/src/legacy/commands/completion/completion.e2e.test.ts @@ -4,15 +4,14 @@ import { runSupabase } from "../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; describe("supabase completion (legacy)", () => { - // Golden-path e2e for CLI-1858: `--no-descriptions` used to be rejected by - // Effect's argv parser (`UnrecognizedOption`) before the request ever - // reached the Go binary, because the flag wasn't declared on the TS leaf - // command. Only a real subprocess run proves both halves of the fix: the - // TS parser accepts the flag, and the Go binary actually receives it — it - // switches the generated script's completion callback from `__complete` to - // `__completeNoDesc` only when the flag is forwarded. + // Golden-path e2e for CLI-1858 / CLI-1965: `--no-descriptions` used to be + // rejected by Effect's argv parser (`UnrecognizedOption`) before the flag + // reached the completion command at all. As of CLI-1965 the script is + // generated natively in TS (no Go binary involved) — only a real + // subprocess run proves the TS parser accepts the flag AND that the + // handler actually selects the no-desc variant of the native template. test( - "bash --no-descriptions is accepted and forwarded to the Go binary", + "bash --no-descriptions is accepted and produces the native no-descriptions script", { timeout: E2E_TIMEOUT_MS }, async () => { const { exitCode, stdout } = await runSupabase(["completion", "bash", "--no-descriptions"], { @@ -22,4 +21,20 @@ describe("supabase completion (legacy)", () => { expect(stdout).toContain("__completeNoDesc"); }, ); + + // Minimal cross-shell smoke coverage: proves the default (with-descriptions) + // code path also works end-to-end through a real subprocess, for a shell + // other than bash. + test( + "zsh with no flags produces the native default script", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout } = await runSupabase(["completion", "zsh"], { + entrypoint: "legacy", + }); + expect(exitCode).toBe(0); + expect(stdout).toContain("#compdef supabase"); + expect(stdout).toContain("__complete"); + }, + ); }); diff --git a/apps/cli/src/legacy/commands/completion/fish/fish.command.ts b/apps/cli/src/legacy/commands/completion/fish/fish.command.ts index 33ef664d5f..a38838172f 100644 --- a/apps/cli/src/legacy/commands/completion/fish/fish.command.ts +++ b/apps/cli/src/legacy/commands/completion/fish/fish.command.ts @@ -9,7 +9,14 @@ const config = { export type LegacyCompletionFishFlags = CliCommand.Command.Config.Infer; export const legacyCompletionFishCommand = Command.make("fish", config).pipe( - Command.withDescription("Generate the autocompletion script for fish"), + Command.withDescription( + "Generate the autocompletion script for the fish shell.\n\n" + + "To load completions in your current shell session:\n\n" + + "\tsupabase completion fish | source\n\n" + + "To load completions for every new session, execute once:\n\n" + + "\tsupabase completion fish > ~/.config/fish/completions/supabase.fish\n\n" + + "You will need to start a new shell for this setup to take effect.", + ), Command.withShortDescription("Generate the autocompletion script for fish"), Command.withHandler((flags) => legacyCompletionFish(flags)), ); diff --git a/apps/cli/src/legacy/commands/completion/fish/fish.handler.ts b/apps/cli/src/legacy/commands/completion/fish/fish.handler.ts index 0157d9cc6a..5deabc1557 100644 --- a/apps/cli/src/legacy/commands/completion/fish/fish.handler.ts +++ b/apps/cli/src/legacy/commands/completion/fish/fish.handler.ts @@ -1,12 +1,13 @@ import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyGenerateCompletionScript } from "../legacy-completion-scripts.ts"; import type { LegacyCompletionFishFlags } from "./fish.command.ts"; export const legacyCompletionFish = Effect.fn("legacy.completion.fish")(function* ( flags: LegacyCompletionFishFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["completion", "fish"]; - if (flags.noDescriptions) args.push("--no-descriptions"); - yield* proxy.exec(args); + const output = yield* Output; + yield* output.raw( + legacyGenerateCompletionScript("fish", { noDescriptions: flags.noDescriptions }), + ); }); diff --git a/apps/cli/src/legacy/commands/completion/fish/fish.integration.test.ts b/apps/cli/src/legacy/commands/completion/fish/fish.integration.test.ts index ba22430863..786278d06b 100644 --- a/apps/cli/src/legacy/commands/completion/fish/fish.integration.test.ts +++ b/apps/cli/src/legacy/commands/completion/fish/fish.integration.test.ts @@ -1,20 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect } from "effect"; import { Command } from "effect/unstable/cli"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { legacyCompletionFishCommand } from "./fish.command.ts"; import { legacyCompletionFish } from "./fish.handler.ts"; function setupLegacyCompletionFish() { - const calls: Array> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args) => - Effect.sync(() => { - calls.push(args); - }), - execCapture: () => Effect.succeed(""), - }); - return { layer, calls }; + return mockOutput(); } function legacyTestRoot() { @@ -22,30 +14,38 @@ function legacyTestRoot() { } describe("legacy completion fish", () => { - it.live("forwards `completion fish` to the Go binary", () => { - const { layer, calls } = setupLegacyCompletionFish(); + it.live("prints the native fish completion script", () => { + const out = setupLegacyCompletionFish(); return Effect.gen(function* () { yield* legacyCompletionFish({ noDescriptions: false }); - expect(calls).toEqual([["completion", "fish"]]); - }).pipe(Effect.provide(layer)); + expect(out.stdoutText).toContain("# fish completion for supabase"); + expect(out.stdoutText).not.toContain("__completeNoDesc"); + expect(out.stdoutText).toContain("__complete"); + }).pipe(Effect.provide(out.layer)); }); - it.live("forwards --no-descriptions when set", () => { - const { layer, calls } = setupLegacyCompletionFish(); - return Effect.gen(function* () { - yield* legacyCompletionFish({ noDescriptions: true }); - expect(calls).toEqual([["completion", "fish", "--no-descriptions"]]); - }).pipe(Effect.provide(layer)); - }); + it.live( + "prints the native fish completion script without descriptions when --no-descriptions is set", + () => { + const out = setupLegacyCompletionFish(); + return Effect.gen(function* () { + yield* legacyCompletionFish({ noDescriptions: true }); + expect(out.stdoutText).toContain("__completeNoDesc"); + }).pipe(Effect.provide(out.layer)); + }, + ); - it.live("accepts --no-descriptions from real argv via the command parser", () => { - const { layer, calls } = setupLegacyCompletionFish(); - return Effect.gen(function* () { - yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ - "fish", - "--no-descriptions", - ]); - expect(calls).toEqual([["completion", "fish", "--no-descriptions"]]); - }).pipe(Effect.provide(layer)) as Effect.Effect; - }); + it.live( + "accepts --no-descriptions from real argv via the command parser and still prints the no-desc script", + () => { + const out = setupLegacyCompletionFish(); + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ + "fish", + "--no-descriptions", + ]); + expect(out.stdoutText).toContain("__completeNoDesc"); + }).pipe(Effect.provide(out.layer)) as Effect.Effect; + }, + ); }); diff --git a/apps/cli/src/legacy/commands/completion/legacy-completion-scripts.ts b/apps/cli/src/legacy/commands/completion/legacy-completion-scripts.ts new file mode 100644 index 0000000000..9d0bdb30e9 --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/legacy-completion-scripts.ts @@ -0,0 +1,1258 @@ +/** + * Native, byte-for-byte reproductions of cobra v1.10.2's static shell + * completion script templates. + * + * cobra's `bash`/`zsh`/`fish`/`powershell` completion scripts are 100% + * generic string templates — they do NOT bake in the command tree. Every + * tab press, the generated script shells back out to the running + * `supabase` binary's hidden `__complete`/`__completeNoDesc` command (see + * `legacy/cli/legacy-complete.ts`, CLI-1965) to get live candidates. The + * only variables in the whole template are the program name (always the + * literal `"supabase"` — cobra derives it from `Use: "supabase"` in + * `apps/cli-go/cmd/root.go`, a compile-time constant, not `os.Argv[0]`), + * which hidden command the script calls back into (`__complete` by + * default, `__completeNoDesc` when generated with `--no-descriptions`), + * the six `ShellCompDirective` bit values, and the two activeHelp + * constants. + * + * Transcribed directly from the cobra v1.10.2 source (verified byte-exact + * via a scripted round-trip against Go's own `fmt.Sprintf` semantics): + * - bash_completionsV2.go (genBashComp) + * - zsh_completions.go (genZshComp) + * - fish_completions.go (genFishComp) + * - powershell_completions.go (genPowerShellComp) + * - completions.go (ShellCompDirective / ShellCompRequestCmd constants) + */ + +const PROGRAM_NAME = "supabase"; + +const SHELL_COMP_REQUEST_CMD = "__complete"; +const SHELL_COMP_NO_DESC_REQUEST_CMD = "__completeNoDesc"; +type CompletionRequestCmd = typeof SHELL_COMP_REQUEST_CMD | typeof SHELL_COMP_NO_DESC_REQUEST_CMD; + +/** `ShellCompDirective` bit values (`spf13/cobra@v1.10.2/completions.go:56-96`). */ +const SHELL_COMP_DIRECTIVE_ERROR = 1; +const SHELL_COMP_DIRECTIVE_NO_SPACE = 2; +const SHELL_COMP_DIRECTIVE_NO_FILE_COMP = 4; +const SHELL_COMP_DIRECTIVE_FILTER_FILE_EXT = 8; +const SHELL_COMP_DIRECTIVE_FILTER_DIRS = 16; +const SHELL_COMP_DIRECTIVE_KEEP_ORDER = 32; + +/** `activeHelpMarker` (`spf13/cobra@v1.10.2/active_help.go:23`). */ +const ACTIVE_HELP_MARKER = "_activeHelp_ "; +/** `activeHelpEnvVar("supabase")` (`spf13/cobra@v1.10.2/active_help.go:58`). */ +const ACTIVE_HELP_ENV_VAR = "SUPABASE_ACTIVE_HELP"; + +/** + * Transcribed from `genBashComp` (`spf13/cobra@v1.10.2/bash_completionsV2.go:31-467`). + * Backing both `GenBashCompletionV2(w, true)` and `GenBashCompletionV2(w, false)` — + * cobra funnels both through the same template; only the `compCmd` token differs. + */ +function genBashCompletionScript(programName: string, compCmd: CompletionRequestCmd): string { + return `# bash completion V2 for ${programName.padEnd(36)} -*- shell-script -*- + +__${programName}_debug() +{ + if [[ -n \${BASH_COMP_DEBUG_FILE-} ]]; then + echo "$*" >> "\${BASH_COMP_DEBUG_FILE}" + fi +} + +# Macs have bash3 for which the bash-completion package doesn't include +# _init_completion. This is a minimal version of that function. +__${programName}_init_completion() +{ + COMPREPLY=() + _get_comp_words_by_ref "$@" cur prev words cword +} + +# This function calls the ${programName} program to obtain the completion +# results and the directive. It fills the 'out' and 'directive' vars. +__${programName}_get_completion_results() { + local requestComp lastParam lastChar args + + # Prepare the command to request completions for the program. + # Calling \${words[0]} instead of directly ${programName} allows handling aliases + args=("\${words[@]:1}") + requestComp="\${words[0]} ${compCmd} \${args[*]}" + + lastParam=\${words[$((\${#words[@]}-1))]} + lastChar=\${lastParam:$((\${#lastParam}-1)):1} + __${programName}_debug "lastParam \${lastParam}, lastChar \${lastChar}" + + if [[ -z \${cur} && \${lastChar} != = ]]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go method. + __${programName}_debug "Adding extra empty parameter" + requestComp="\${requestComp} ''" + fi + + # When completing a flag with an = (e.g., ${programName} -n=) + # bash focuses on the part after the =, so we need to remove + # the flag part from $cur + if [[ \${cur} == -*=* ]]; then + cur="\${cur#*=}" + fi + + __${programName}_debug "Calling \${requestComp}" + # Use eval to handle any environment variables and such + out=$(eval "\${requestComp}" 2>/dev/null) + + # Extract the directive integer at the very end of the output following a colon (:) + directive=\${out##*:} + # Remove the directive + out=\${out%:*} + if [[ \${directive} == "\${out}" ]]; then + # There is not directive specified + directive=0 + fi + __${programName}_debug "The completion directive is: \${directive}" + __${programName}_debug "The completions are: \${out}" +} + +__${programName}_process_completion_results() { + local shellCompDirectiveError=${SHELL_COMP_DIRECTIVE_ERROR} + local shellCompDirectiveNoSpace=${SHELL_COMP_DIRECTIVE_NO_SPACE} + local shellCompDirectiveNoFileComp=${SHELL_COMP_DIRECTIVE_NO_FILE_COMP} + local shellCompDirectiveFilterFileExt=${SHELL_COMP_DIRECTIVE_FILTER_FILE_EXT} + local shellCompDirectiveFilterDirs=${SHELL_COMP_DIRECTIVE_FILTER_DIRS} + local shellCompDirectiveKeepOrder=${SHELL_COMP_DIRECTIVE_KEEP_ORDER} + + if (((directive & shellCompDirectiveError) != 0)); then + # Error code. No completion. + __${programName}_debug "Received error from custom completion go code" + return + else + if (((directive & shellCompDirectiveNoSpace) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + __${programName}_debug "Activating no space" + compopt -o nospace + else + __${programName}_debug "No space directive not supported in this version of bash" + fi + fi + if (((directive & shellCompDirectiveKeepOrder) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + # no sort isn't supported for bash less than < 4.4 + if [[ \${BASH_VERSINFO[0]} -lt 4 || ( \${BASH_VERSINFO[0]} -eq 4 && \${BASH_VERSINFO[1]} -lt 4 ) ]]; then + __${programName}_debug "No sort directive not supported in this version of bash" + else + __${programName}_debug "Activating keep order" + compopt -o nosort + fi + else + __${programName}_debug "No sort directive not supported in this version of bash" + fi + fi + if (((directive & shellCompDirectiveNoFileComp) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + __${programName}_debug "Activating no file completion" + compopt +o default + else + __${programName}_debug "No file completion directive not supported in this version of bash" + fi + fi + fi + + # Separate activeHelp from normal completions + local completions=() + local activeHelp=() + __${programName}_extract_activeHelp + + if (((directive & shellCompDirectiveFilterFileExt) != 0)); then + # File extension filtering + local fullFilter="" filter filteringCmd + + # Do not use quotes around the $completions variable or else newline + # characters will be kept. + for filter in \${completions[*]}; do + fullFilter+="$filter|" + done + + filteringCmd="_filedir $fullFilter" + __${programName}_debug "File filtering command: $filteringCmd" + $filteringCmd + elif (((directive & shellCompDirectiveFilterDirs) != 0)); then + # File completion for directories only + + local subdir + subdir=\${completions[0]} + if [[ -n $subdir ]]; then + __${programName}_debug "Listing directories in $subdir" + pushd "$subdir" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return + else + __${programName}_debug "Listing directories in ." + _filedir -d + fi + else + __${programName}_handle_completion_types + fi + + __${programName}_handle_special_char "$cur" : + __${programName}_handle_special_char "$cur" = + + # Print the activeHelp statements before we finish + __${programName}_handle_activeHelp +} + +__${programName}_handle_activeHelp() { + # Print the activeHelp statements + if ((\${#activeHelp[*]} != 0)); then + if [ -z $COMP_TYPE ]; then + # Bash v3 does not set the COMP_TYPE variable. + printf "\\n"; + printf "%s\\n" "\${activeHelp[@]}" + printf "\\n" + __${programName}_reprint_commandLine + return + fi + + # Only print ActiveHelp on the second TAB press + if [ $COMP_TYPE -eq 63 ]; then + printf "\\n" + printf "%s\\n" "\${activeHelp[@]}" + + if ((\${#COMPREPLY[*]} == 0)); then + # When there are no completion choices from the program, file completion + # may kick in if the program has not disabled it; in such a case, we want + # to know if any files will match what the user typed, so that we know if + # there will be completions presented, so that we know how to handle ActiveHelp. + # To find out, we actually trigger the file completion ourselves; + # the call to _filedir will fill COMPREPLY if files match. + if (((directive & shellCompDirectiveNoFileComp) == 0)); then + __${programName}_debug "Listing files" + _filedir + fi + fi + + if ((\${#COMPREPLY[*]} != 0)); then + # If there are completion choices to be shown, print a delimiter. + # Re-printing the command-line will automatically be done + # by the shell when it prints the completion choices. + printf -- "--" + else + # When there are no completion choices at all, we need + # to re-print the command-line since the shell will + # not be doing it itself. + __${programName}_reprint_commandLine + fi + elif [ $COMP_TYPE -eq 37 ] || [ $COMP_TYPE -eq 42 ]; then + # For completion type: menu-complete/menu-complete-backward and insert-completions + # the completions are immediately inserted into the command-line, so we first + # print the activeHelp message and reprint the command-line since the shell won't. + printf "\\n" + printf "%s\\n" "\${activeHelp[@]}" + + __${programName}_reprint_commandLine + fi + fi +} + +__${programName}_reprint_commandLine() { + # The prompt format is only available from bash 4.4. + # We test if it is available before using it. + if (x=\${PS1@P}) 2> /dev/null; then + printf "%s" "\${PS1@P}\${COMP_LINE[@]}" + else + # Can't print the prompt. Just print the + # text the user had typed, it is workable enough. + printf "%s" "\${COMP_LINE[@]}" + fi +} + +# Separate activeHelp lines from real completions. +# Fills the $activeHelp and $completions arrays. +__${programName}_extract_activeHelp() { + local activeHelpMarker="${ACTIVE_HELP_MARKER}" + local endIndex=\${#activeHelpMarker} + + while IFS='' read -r comp; do + [[ -z $comp ]] && continue + + if [[ \${comp:0:endIndex} == $activeHelpMarker ]]; then + comp=\${comp:endIndex} + __${programName}_debug "ActiveHelp found: $comp" + if [[ -n $comp ]]; then + activeHelp+=("$comp") + fi + else + # Not an activeHelp line but a normal completion + completions+=("$comp") + fi + done <<<"\${out}" +} + +__${programName}_handle_completion_types() { + __${programName}_debug "__${programName}_handle_completion_types: COMP_TYPE is $COMP_TYPE" + + case $COMP_TYPE in + 37|42) + # Type: menu-complete/menu-complete-backward and insert-completions + # If the user requested inserting one completion at a time, or all + # completions at once on the command-line we must remove the descriptions. + # https://github.com/spf13/cobra/issues/1508 + + # If there are no completions, we don't need to do anything + (( \${#completions[@]} == 0 )) && return 0 + + local tab=$'\\t' + + # Strip any description and escape the completion to handled special characters + IFS=$'\\n' read -ra completions -d '' < <(printf "%q\\n" "\${completions[@]%%$tab*}") + + # Only consider the completions that match + IFS=$'\\n' read -ra COMPREPLY -d '' < <(IFS=$'\\n'; compgen -W "\${completions[*]}" -- "\${cur}") + + # compgen looses the escaping so we need to escape all completions again since they will + # all be inserted on the command-line. + IFS=$'\\n' read -ra COMPREPLY -d '' < <(printf "%q\\n" "\${COMPREPLY[@]}") + ;; + + *) + # Type: complete (normal completion) + __${programName}_handle_standard_completion_case + ;; + esac +} + +__${programName}_handle_standard_completion_case() { + local tab=$'\\t' + + # If there are no completions, we don't need to do anything + (( \${#completions[@]} == 0 )) && return 0 + + # Short circuit to optimize if we don't have descriptions + if [[ "\${completions[*]}" != *$tab* ]]; then + # First, escape the completions to handle special characters + IFS=$'\\n' read -ra completions -d '' < <(printf "%q\\n" "\${completions[@]}") + # Only consider the completions that match what the user typed + IFS=$'\\n' read -ra COMPREPLY -d '' < <(IFS=$'\\n'; compgen -W "\${completions[*]}" -- "\${cur}") + + # compgen looses the escaping so, if there is only a single completion, we need to + # escape it again because it will be inserted on the command-line. If there are multiple + # completions, we don't want to escape them because they will be printed in a list + # and we don't want to show escape characters in that list. + if (( \${#COMPREPLY[@]} == 1 )); then + COMPREPLY[0]=$(printf "%q" "\${COMPREPLY[0]}") + fi + return 0 + fi + + local longest=0 + local compline + # Look for the longest completion so that we can format things nicely + while IFS='' read -r compline; do + [[ -z $compline ]] && continue + + # Before checking if the completion matches what the user typed, + # we need to strip any description and escape the completion to handle special + # characters because those escape characters are part of what the user typed. + # Don't call "printf" in a sub-shell because it will be much slower + # since we are in a loop. + printf -v comp "%q" "\${compline%%$tab*}" &>/dev/null || comp=$(printf "%q" "\${compline%%$tab*}") + + # Only consider the completions that match + [[ $comp == "$cur"* ]] || continue + + # The completions matches. Add it to the list of full completions including + # its description. We don't escape the completion because it may get printed + # in a list if there are more than one and we don't want show escape characters + # in that list. + COMPREPLY+=("$compline") + + # Strip any description before checking the length, and again, don't escape + # the completion because this length is only used when printing the completions + # in a list and we don't want show escape characters in that list. + comp=\${compline%%$tab*} + if ((\${#comp}>longest)); then + longest=\${#comp} + fi + done < <(printf "%s\\n" "\${completions[@]}") + + # If there is a single completion left, remove the description text and escape any special characters + if ((\${#COMPREPLY[*]} == 1)); then + __${programName}_debug "COMPREPLY[0]: \${COMPREPLY[0]}" + COMPREPLY[0]=$(printf "%q" "\${COMPREPLY[0]%%$tab*}") + __${programName}_debug "Removed description from single completion, which is now: \${COMPREPLY[0]}" + else + # Format the descriptions + __${programName}_format_comp_descriptions $longest + fi +} + +__${programName}_handle_special_char() +{ + local comp="$1" + local char=$2 + if [[ "$comp" == *\${char}* && "$COMP_WORDBREAKS" == *\${char}* ]]; then + local word=\${comp%"\${comp##*\${char}}"} + local idx=\${#COMPREPLY[*]} + while ((--idx >= 0)); do + COMPREPLY[idx]=\${COMPREPLY[idx]#"$word"} + done + fi +} + +__${programName}_format_comp_descriptions() +{ + local tab=$'\\t' + local comp desc maxdesclength + local longest=$1 + + local i ci + for ci in \${!COMPREPLY[*]}; do + comp=\${COMPREPLY[ci]} + # Properly format the description string which follows a tab character if there is one + if [[ "$comp" == *$tab* ]]; then + __${programName}_debug "Original comp: $comp" + desc=\${comp#*$tab} + comp=\${comp%%$tab*} + + # $COLUMNS stores the current shell width. + # Remove an extra 4 because we add 2 spaces and 2 parentheses. + maxdesclength=$(( COLUMNS - longest - 4 )) + + # Make sure we can fit a description of at least 8 characters + # if we are to align the descriptions. + if ((maxdesclength > 8)); then + # Add the proper number of spaces to align the descriptions + for ((i = \${#comp} ; i < longest ; i++)); do + comp+=" " + done + else + # Don't pad the descriptions so we can fit more text after the completion + maxdesclength=$(( COLUMNS - \${#comp} - 4 )) + fi + + # If there is enough space for any description text, + # truncate the descriptions that are too long for the shell width + if ((maxdesclength > 0)); then + if ((\${#desc} > maxdesclength)); then + desc=\${desc:0:$(( maxdesclength - 1 ))} + desc+="…" + fi + comp+=" ($desc)" + fi + COMPREPLY[ci]=$comp + __${programName}_debug "Final comp: $comp" + fi + done +} + +__start_${programName}() +{ + local cur prev words cword split + + COMPREPLY=() + + # Call _init_completion from the bash-completion package + # to prepare the arguments properly + if declare -F _init_completion >/dev/null 2>&1; then + _init_completion -n =: || return + else + __${programName}_init_completion -n =: || return + fi + + __${programName}_debug + __${programName}_debug "========= starting completion logic ==========" + __${programName}_debug "cur is \${cur}, words[*] is \${words[*]}, #words[@] is \${#words[@]}, cword is $cword" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $cword location, so we need + # to truncate the command-line ($words) up to the $cword location. + words=("\${words[@]:0:$cword+1}") + __${programName}_debug "Truncated words[*]: \${words[*]}," + + local out directive + __${programName}_get_completion_results + __${programName}_process_completion_results +} + +if [[ $(type -t compopt) = "builtin" ]]; then + complete -o default -F __start_${programName} ${programName} +else + complete -o default -o nospace -F __start_${programName} ${programName} +fi + +# ex: ts=4 sw=4 et filetype=sh +`; +} + +/** + * Transcribed from `genZshComp` (`spf13/cobra@v1.10.2/zsh_completions.go:87-308`). + * `GenZshCompletion` and `GenZshCompletionNoDesc` both call this exact function — + * verified there is no other divergence between the desc/no-desc variants beyond + * the `compCmd` token. + */ +function genZshCompletionScript(programName: string, compCmd: CompletionRequestCmd): string { + return `#compdef ${programName} +compdef _${programName} ${programName} + +# zsh completion for ${programName.padEnd(36)} -*- shell-script -*- + +__${programName}_debug() +{ + local file="$BASH_COMP_DEBUG_FILE" + if [[ -n \${file} ]]; then + echo "$*" >> "\${file}" + fi +} + +_${programName}() +{ + local shellCompDirectiveError=${SHELL_COMP_DIRECTIVE_ERROR} + local shellCompDirectiveNoSpace=${SHELL_COMP_DIRECTIVE_NO_SPACE} + local shellCompDirectiveNoFileComp=${SHELL_COMP_DIRECTIVE_NO_FILE_COMP} + local shellCompDirectiveFilterFileExt=${SHELL_COMP_DIRECTIVE_FILTER_FILE_EXT} + local shellCompDirectiveFilterDirs=${SHELL_COMP_DIRECTIVE_FILTER_DIRS} + local shellCompDirectiveKeepOrder=${SHELL_COMP_DIRECTIVE_KEEP_ORDER} + + local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder + local -a completions + + __${programName}_debug "\\n========= starting completion logic ==========" + __${programName}_debug "CURRENT: \${CURRENT}, words[*]: \${words[*]}" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CURRENT location, so we need + # to truncate the command-line ($words) up to the $CURRENT location. + # (We cannot use $CURSOR as its value does not work when a command is an alias.) + words=("\${=words[1,CURRENT]}") + __${programName}_debug "Truncated words[*]: \${words[*]}," + + lastParam=\${words[-1]} + lastChar=\${lastParam[-1]} + __${programName}_debug "lastParam: \${lastParam}, lastChar: \${lastChar}" + + # For zsh, when completing a flag with an = (e.g., ${programName} -n=) + # completions must be prefixed with the flag + setopt local_options BASH_REMATCH + if [[ "\${lastParam}" =~ '-.*=' ]]; then + # We are dealing with a flag with an = + flagPrefix="-P \${BASH_REMATCH}" + fi + + # Prepare the command to obtain completions + requestComp="\${words[1]} ${compCmd} \${words[2,-1]}" + if [ "\${lastChar}" = "" ]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go completion code. + __${programName}_debug "Adding extra empty parameter" + requestComp="\${requestComp} \\"\\"" + fi + + __${programName}_debug "About to call: eval \${requestComp}" + + # Use eval to handle any environment variables and such + out=$(eval \${requestComp} 2>/dev/null) + __${programName}_debug "completion output: \${out}" + + # Extract the directive integer following a : from the last line + local lastLine + while IFS='\\n' read -r line; do + lastLine=\${line} + done < <(printf "%s\\n" "\${out[@]}") + __${programName}_debug "last line: \${lastLine}" + + if [ "\${lastLine[1]}" = : ]; then + directive=\${lastLine[2,-1]} + # Remove the directive including the : and the newline + local suffix + (( suffix=\${#lastLine}+2)) + out=\${out[1,-$suffix]} + else + # There is no directive specified. Leave $out as is. + __${programName}_debug "No directive found. Setting do default" + directive=0 + fi + + __${programName}_debug "directive: \${directive}" + __${programName}_debug "completions: \${out}" + __${programName}_debug "flagPrefix: \${flagPrefix}" + + if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then + __${programName}_debug "Completion received error. Ignoring completions." + return + fi + + local activeHelpMarker="${ACTIVE_HELP_MARKER}" + local endIndex=\${#activeHelpMarker} + local startIndex=$((\${#activeHelpMarker}+1)) + local hasActiveHelp=0 + while IFS='\\n' read -r comp; do + # Check if this is an activeHelp statement (i.e., prefixed with $activeHelpMarker) + if [ "\${comp[1,$endIndex]}" = "$activeHelpMarker" ];then + __${programName}_debug "ActiveHelp found: $comp" + comp="\${comp[$startIndex,-1]}" + if [ -n "$comp" ]; then + compadd -x "\${comp}" + __${programName}_debug "ActiveHelp will need delimiter" + hasActiveHelp=1 + fi + + continue + fi + + if [ -n "$comp" ]; then + # If requested, completions are returned with a description. + # The description is preceded by a TAB character. + # For zsh's _describe, we need to use a : instead of a TAB. + # We first need to escape any : as part of the completion itself. + comp=\${comp//:/\\\\:} + + local tab="$(printf '\\t')" + comp=\${comp//$tab/:} + + __${programName}_debug "Adding completion: \${comp}" + completions+=\${comp} + lastComp=$comp + fi + done < <(printf "%s\\n" "\${out[@]}") + + # Add a delimiter after the activeHelp statements, but only if: + # - there are completions following the activeHelp statements, or + # - file completion will be performed (so there will be choices after the activeHelp) + if [ $hasActiveHelp -eq 1 ]; then + if [ \${#completions} -ne 0 ] || [ $((directive & shellCompDirectiveNoFileComp)) -eq 0 ]; then + __${programName}_debug "Adding activeHelp delimiter" + compadd -x "--" + hasActiveHelp=0 + fi + fi + + if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then + __${programName}_debug "Activating nospace." + noSpace="-S ''" + fi + + if [ $((directive & shellCompDirectiveKeepOrder)) -ne 0 ]; then + __${programName}_debug "Activating keep order." + keepOrder="-V" + fi + + if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then + # File extension filtering + local filteringCmd + filteringCmd='_files' + for filter in \${completions[@]}; do + if [ \${filter[1]} != '*' ]; then + # zsh requires a glob pattern to do file filtering + filter="\\*.$filter" + fi + filteringCmd+=" -g $filter" + done + filteringCmd+=" \${flagPrefix}" + + __${programName}_debug "File filtering command: $filteringCmd" + _arguments '*:filename:'"$filteringCmd" + elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then + # File completion for directories only + local subdir + subdir="\${completions[1]}" + if [ -n "$subdir" ]; then + __${programName}_debug "Listing directories in $subdir" + pushd "\${subdir}" >/dev/null 2>&1 + else + __${programName}_debug "Listing directories in ." + fi + + local result + _arguments '*:dirname:_files -/'" \${flagPrefix}" + result=$? + if [ -n "$subdir" ]; then + popd >/dev/null 2>&1 + fi + return $result + else + __${programName}_debug "Calling _describe" + if eval _describe $keepOrder "completions" completions $flagPrefix $noSpace; then + __${programName}_debug "_describe found some completions" + + # Return the success of having called _describe + return 0 + else + __${programName}_debug "_describe did not find completions." + __${programName}_debug "Checking if we should do file completion." + if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then + __${programName}_debug "deactivating file completion" + + # We must return an error code here to let zsh know that there were no + # completions found by _describe; this is what will trigger other + # matching algorithms to attempt to find completions. + # For example zsh can match letters in the middle of words. + return 1 + else + # Perform file completion + __${programName}_debug "Activating file completion" + + # We must return the result of this command, so it must be the + # last command, or else we must store its result to return it. + _arguments '*:filename:_files'" \${flagPrefix}" + fi + fi + fi +} + +# don't run the completion function when being source-ed or eval-ed +if [ "$funcstack[1]" = "_${programName}" ]; then + _${programName} +fi +`; +} + +/** + * Transcribed from `genFishComp` (`spf13/cobra@v1.10.2/fish_completions.go:25-273`). + * cobra emits the header comment via a separate `fmt.Sprintf` call before the main + * template; reproduced here as a plain string concatenation of the two pieces. + */ +function genFishCompletionScript(programName: string, compCmd: CompletionRequestCmd): string { + return ( + `# fish completion for ${programName.padEnd(36)} -*- shell-script -*-\n` + + ` +function __${programName}_debug + set -l file "$BASH_COMP_DEBUG_FILE" + if test -n "$file" + echo "$argv" >> $file + end +end + +function __${programName}_perform_completion + __${programName}_debug "Starting __${programName}_perform_completion" + + # Extract all args except the last one + set -l args (commandline -opc) + # Extract the last arg and escape it in case it is a space + set -l lastArg (string escape -- (commandline -ct)) + + __${programName}_debug "args: $args" + __${programName}_debug "last arg: $lastArg" + + # Disable ActiveHelp which is not supported for fish shell + set -l requestComp "${ACTIVE_HELP_ENV_VAR}=0 $args[1] ${compCmd} $args[2..-1] $lastArg" + + __${programName}_debug "Calling $requestComp" + set -l results (eval $requestComp 2> /dev/null) + + # Some programs may output extra empty lines after the directive. + # Let's ignore them or else it will break completion. + # Ref: https://github.com/spf13/cobra/issues/1279 + for line in $results[-1..1] + if test (string trim -- $line) = "" + # Found an empty line, remove it + set results $results[1..-2] + else + # Found non-empty line, we have our proper output + break + end + end + + set -l comps $results[1..-2] + set -l directiveLine $results[-1] + + # For Fish, when completing a flag with an = (e.g., -n=) + # completions must be prefixed with the flag + set -l flagPrefix (string match -r -- '-.*=' "$lastArg") + + __${programName}_debug "Comps: $comps" + __${programName}_debug "DirectiveLine: $directiveLine" + __${programName}_debug "flagPrefix: $flagPrefix" + + for comp in $comps + printf "%s%s\\n" "$flagPrefix" "$comp" + end + + printf "%s\\n" "$directiveLine" +end + +# this function limits calls to __${programName}_perform_completion, by caching the result behind $__${programName}_perform_completion_once_result +function __${programName}_perform_completion_once + __${programName}_debug "Starting __${programName}_perform_completion_once" + + if test -n "$__${programName}_perform_completion_once_result" + __${programName}_debug "Seems like a valid result already exists, skipping __${programName}_perform_completion" + return 0 + end + + set --global __${programName}_perform_completion_once_result (__${programName}_perform_completion) + if test -z "$__${programName}_perform_completion_once_result" + __${programName}_debug "No completions, probably due to a failure" + return 1 + end + + __${programName}_debug "Performed completions and set __${programName}_perform_completion_once_result" + return 0 +end + +# this function is used to clear the $__${programName}_perform_completion_once_result variable after completions are run +function __${programName}_clear_perform_completion_once_result + __${programName}_debug "" + __${programName}_debug "========= clearing previously set __${programName}_perform_completion_once_result variable ==========" + set --erase __${programName}_perform_completion_once_result + __${programName}_debug "Successfully erased the variable __${programName}_perform_completion_once_result" +end + +function __${programName}_requires_order_preservation + __${programName}_debug "" + __${programName}_debug "========= checking if order preservation is required ==========" + + __${programName}_perform_completion_once + if test -z "$__${programName}_perform_completion_once_result" + __${programName}_debug "Error determining if order preservation is required" + return 1 + end + + set -l directive (string sub --start 2 $__${programName}_perform_completion_once_result[-1]) + __${programName}_debug "Directive is: $directive" + + set -l shellCompDirectiveKeepOrder ${SHELL_COMP_DIRECTIVE_KEEP_ORDER} + set -l keeporder (math (math --scale 0 $directive / $shellCompDirectiveKeepOrder) % 2) + __${programName}_debug "Keeporder is: $keeporder" + + if test $keeporder -ne 0 + __${programName}_debug "This does require order preservation" + return 0 + end + + __${programName}_debug "This doesn't require order preservation" + return 1 +end + + +# This function does two things: +# - Obtain the completions and store them in the global __${programName}_comp_results +# - Return false if file completion should be performed +function __${programName}_prepare_completions + __${programName}_debug "" + __${programName}_debug "========= starting completion logic ==========" + + # Start fresh + set --erase __${programName}_comp_results + + __${programName}_perform_completion_once + __${programName}_debug "Completion results: $__${programName}_perform_completion_once_result" + + if test -z "$__${programName}_perform_completion_once_result" + __${programName}_debug "No completion, probably due to a failure" + # Might as well do file completion, in case it helps + return 1 + end + + set -l directive (string sub --start 2 $__${programName}_perform_completion_once_result[-1]) + set --global __${programName}_comp_results $__${programName}_perform_completion_once_result[1..-2] + + __${programName}_debug "Completions are: $__${programName}_comp_results" + __${programName}_debug "Directive is: $directive" + + set -l shellCompDirectiveError ${SHELL_COMP_DIRECTIVE_ERROR} + set -l shellCompDirectiveNoSpace ${SHELL_COMP_DIRECTIVE_NO_SPACE} + set -l shellCompDirectiveNoFileComp ${SHELL_COMP_DIRECTIVE_NO_FILE_COMP} + set -l shellCompDirectiveFilterFileExt ${SHELL_COMP_DIRECTIVE_FILTER_FILE_EXT} + set -l shellCompDirectiveFilterDirs ${SHELL_COMP_DIRECTIVE_FILTER_DIRS} + + if test -z "$directive" + set directive 0 + end + + set -l compErr (math (math --scale 0 $directive / $shellCompDirectiveError) % 2) + if test $compErr -eq 1 + __${programName}_debug "Received error directive: aborting." + # Might as well do file completion, in case it helps + return 1 + end + + set -l filefilter (math (math --scale 0 $directive / $shellCompDirectiveFilterFileExt) % 2) + set -l dirfilter (math (math --scale 0 $directive / $shellCompDirectiveFilterDirs) % 2) + if test $filefilter -eq 1; or test $dirfilter -eq 1 + __${programName}_debug "File extension filtering or directory filtering not supported" + # Do full file completion instead + return 1 + end + + set -l nospace (math (math --scale 0 $directive / $shellCompDirectiveNoSpace) % 2) + set -l nofiles (math (math --scale 0 $directive / $shellCompDirectiveNoFileComp) % 2) + + __${programName}_debug "nospace: $nospace, nofiles: $nofiles" + + # If we want to prevent a space, or if file completion is NOT disabled, + # we need to count the number of valid completions. + # To do so, we will filter on prefix as the completions we have received + # may not already be filtered so as to allow fish to match on different + # criteria than the prefix. + if test $nospace -ne 0; or test $nofiles -eq 0 + set -l prefix (commandline -t | string escape --style=regex) + __${programName}_debug "prefix: $prefix" + + set -l completions (string match -r -- "^$prefix.*" $__${programName}_comp_results) + set --global __${programName}_comp_results $completions + __${programName}_debug "Filtered completions are: $__${programName}_comp_results" + + # Important not to quote the variable for count to work + set -l numComps (count $__${programName}_comp_results) + __${programName}_debug "numComps: $numComps" + + if test $numComps -eq 1; and test $nospace -ne 0 + # We must first split on \\t to get rid of the descriptions to be + # able to check what the actual completion will be. + # We don't need descriptions anyway since there is only a single + # real completion which the shell will expand immediately. + set -l split (string split --max 1 \\t $__${programName}_comp_results[1]) + + # Fish won't add a space if the completion ends with any + # of the following characters: @=/:., + set -l lastChar (string sub -s -1 -- $split) + if not string match -r -q "[@=/:.,]" -- "$lastChar" + # In other cases, to support the "nospace" directive we trick the shell + # by outputting an extra, longer completion. + __${programName}_debug "Adding second completion to perform nospace directive" + set --global __${programName}_comp_results $split[1] $split[1]. + __${programName}_debug "Completions are now: $__${programName}_comp_results" + end + end + + if test $numComps -eq 0; and test $nofiles -eq 0 + # To be consistent with bash and zsh, we only trigger file + # completion when there are no other completions + __${programName}_debug "Requesting file completion" + return 1 + end + end + + return 0 +end + +# Since Fish completions are only loaded once the user triggers them, we trigger them ourselves +# so we can properly delete any completions provided by another script. +# Only do this if the program can be found, or else fish may print some errors; besides, +# the existing completions will only be loaded if the program can be found. +if type -q "${programName}" + # The space after the program name is essential to trigger completion for the program + # and not completion of the program name itself. + # Also, we use '> /dev/null 2>&1' since '&>' is not supported in older versions of fish. + complete --do-complete "${programName} " > /dev/null 2>&1 +end + +# Remove any pre-existing completions for the program since we will be handling all of them. +complete -c ${programName} -e + +# this will get called after the two calls below and clear the $__${programName}_perform_completion_once_result global +complete -c ${programName} -n '__${programName}_clear_perform_completion_once_result' +# The call to __${programName}_prepare_completions will setup __${programName}_comp_results +# which provides the program's completion choices. +# If this doesn't require order preservation, we don't use the -k flag +complete -c ${programName} -n 'not __${programName}_requires_order_preservation && __${programName}_prepare_completions' -f -a '$__${programName}_comp_results' +# otherwise we use the -k flag +complete -k -c ${programName} -n '__${programName}_requires_order_preservation && __${programName}_prepare_completions' -f -a '$__${programName}_comp_results' +` + ); +} + +/** + * Transcribed from `genPowerShellComp` (`spf13/cobra@v1.10.2/powershell_completions.go:28-311`). + * `GenPowerShellCompletion` (no desc) and `GenPowerShellCompletionWithDesc` both call + * this exact function — verified there is no other divergence between the desc/no-desc + * variants beyond the `compCmd` token. The Go source builds this template by + * concatenating raw-string segments with a handful of interpreted (`"..."`) + * segments so it can embed literal PowerShell backticks (Go raw strings cannot + * contain a backtick); reproduced here as one TS template literal with those + * backticks escaped directly, which TS supports natively. + */ +function genPowerShellCompletionScript(programName: string, compCmd: CompletionRequestCmd): string { + return `# powershell completion for ${programName.padEnd(36)} -*- shell-script -*- + +function __${programName}_debug { + if ($env:BASH_COMP_DEBUG_FILE) { + "$args" | Out-File -Append -FilePath "$env:BASH_COMP_DEBUG_FILE" + } +} + +filter __${programName}_escapeStringWithSpecialChars { + $_ -replace '\\s|#|@|\\$|;|,|''|\\{|\\}|\\(|\\)|"|\`|\\||<|>|&','\`$&' +} + +[scriptblock]\${__${programName}CompleterBlock} = { + param( + $WordToComplete, + $CommandAst, + $CursorPosition + ) + + # Get the current command line and convert into a string + $Command = $CommandAst.CommandElements + $Command = "$Command" + + __${programName}_debug "" + __${programName}_debug "========= starting completion logic ==========" + __${programName}_debug "WordToComplete: $WordToComplete Command: $Command CursorPosition: $CursorPosition" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CursorPosition location, so we need + # to truncate the command-line ($Command) up to the $CursorPosition location. + # Make sure the $Command is longer then the $CursorPosition before we truncate. + # This happens because the $Command does not include the last space. + if ($Command.Length -gt $CursorPosition) { + $Command=$Command.Substring(0,$CursorPosition) + } + __${programName}_debug "Truncated command: $Command" + + $ShellCompDirectiveError=${SHELL_COMP_DIRECTIVE_ERROR} + $ShellCompDirectiveNoSpace=${SHELL_COMP_DIRECTIVE_NO_SPACE} + $ShellCompDirectiveNoFileComp=${SHELL_COMP_DIRECTIVE_NO_FILE_COMP} + $ShellCompDirectiveFilterFileExt=${SHELL_COMP_DIRECTIVE_FILTER_FILE_EXT} + $ShellCompDirectiveFilterDirs=${SHELL_COMP_DIRECTIVE_FILTER_DIRS} + $ShellCompDirectiveKeepOrder=${SHELL_COMP_DIRECTIVE_KEEP_ORDER} + + # Prepare the command to request completions for the program. + # Split the command at the first space to separate the program and arguments. + $Program,$Arguments = $Command.Split(" ",2) + + $RequestComp="$Program ${compCmd} $Arguments" + __${programName}_debug "RequestComp: $RequestComp" + + # we cannot use $WordToComplete because it + # has the wrong values if the cursor was moved + # so use the last argument + if ($WordToComplete -ne "" ) { + $WordToComplete = $Arguments.Split(" ")[-1] + } + __${programName}_debug "New WordToComplete: $WordToComplete" + + + # Check for flag with equal sign + $IsEqualFlag = ($WordToComplete -Like "--*=*" ) + if ( $IsEqualFlag ) { + __${programName}_debug "Completing equal sign flag" + # Remove the flag part + $Flag,$WordToComplete = $WordToComplete.Split("=",2) + } + + if ( $WordToComplete -eq "" -And ( -Not $IsEqualFlag )) { + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go method. + __${programName}_debug "Adding extra empty parameter" + # PowerShell 7.2+ changed the way how the arguments are passed to executables, + # so for pre-7.2 or when Legacy argument passing is enabled we need to use + # \`"\`" to pass an empty argument, a "" or '' does not work!!! + if ($PSVersionTable.PsVersion -lt [version]'7.2.0' -or + ($PSVersionTable.PsVersion -lt [version]'7.3.0' -and -not [ExperimentalFeature]::IsEnabled("PSNativeCommandArgumentPassing")) -or + (($PSVersionTable.PsVersion -ge [version]'7.3.0' -or [ExperimentalFeature]::IsEnabled("PSNativeCommandArgumentPassing")) -and + $PSNativeCommandArgumentPassing -eq 'Legacy')) { + $RequestComp="$RequestComp" + ' \`"\`"' + } else { + $RequestComp="$RequestComp" + ' ""' + } + } + + __${programName}_debug "Calling $RequestComp" + # First disable ActiveHelp which is not supported for Powershell + \${env:${ACTIVE_HELP_ENV_VAR}}=0 + + #call the command store the output in $out and redirect stderr and stdout to null + # $Out is an array contains each line per element + Invoke-Expression -OutVariable out "$RequestComp" 2>&1 | Out-Null + + # get directive from last line + [int]$Directive = $Out[-1].TrimStart(':') + if ($Directive -eq "") { + # There is no directive specified + $Directive = 0 + } + __${programName}_debug "The completion directive is: $Directive" + + # remove directive (last element) from out + $Out = $Out | Where-Object { $_ -ne $Out[-1] } + __${programName}_debug "The completions are: $Out" + + if (($Directive -band $ShellCompDirectiveError) -ne 0 ) { + # Error code. No completion. + __${programName}_debug "Received error from custom completion go code" + return + } + + $Longest = 0 + [Array]$Values = $Out | ForEach-Object { + #Split the output in name and description + $Name, $Description = $_.Split("\`t",2) + __${programName}_debug "Name: $Name Description: $Description" + + # Look for the longest completion so that we can format things nicely + if ($Longest -lt $Name.Length) { + $Longest = $Name.Length + } + + # Set the description to a one space string if there is none set. + # This is needed because the CompletionResult does not accept an empty string as argument + if (-Not $Description) { + $Description = " " + } + New-Object -TypeName PSCustomObject -Property @{ + Name = "$Name" + Description = "$Description" + } + } + + + $Space = " " + if (($Directive -band $ShellCompDirectiveNoSpace) -ne 0 ) { + # remove the space here + __${programName}_debug "ShellCompDirectiveNoSpace is called" + $Space = "" + } + + if ((($Directive -band $ShellCompDirectiveFilterFileExt) -ne 0 ) -or + (($Directive -band $ShellCompDirectiveFilterDirs) -ne 0 )) { + __${programName}_debug "ShellCompDirectiveFilterFileExt ShellCompDirectiveFilterDirs are not supported" + + # return here to prevent the completion of the extensions + return + } + + $Values = $Values | Where-Object { + # filter the result + $_.Name -like "$WordToComplete*" + + # Join the flag back if we have an equal sign flag + if ( $IsEqualFlag ) { + __${programName}_debug "Join the equal sign flag back to the completion value" + $_.Name = $Flag + "=" + $_.Name + } + } + + # we sort the values in ascending order by name if keep order isn't passed + if (($Directive -band $ShellCompDirectiveKeepOrder) -eq 0 ) { + $Values = $Values | Sort-Object -Property Name + } + + if (($Directive -band $ShellCompDirectiveNoFileComp) -ne 0 ) { + __${programName}_debug "ShellCompDirectiveNoFileComp is called" + + if ($Values.Length -eq 0) { + # Just print an empty string here so the + # shell does not start to complete paths. + # We cannot use CompletionResult here because + # it does not accept an empty string as argument. + "" + return + } + } + + # Get the current mode + $Mode = (Get-PSReadLineKeyHandler | Where-Object {$_.Key -eq "Tab" }).Function + __${programName}_debug "Mode: $Mode" + + $Values | ForEach-Object { + + # store temporary because switch will overwrite $_ + $comp = $_ + + # PowerShell supports three different completion modes + # - TabCompleteNext (default windows style - on each key press the next option is displayed) + # - Complete (works like bash) + # - MenuComplete (works like zsh) + # You set the mode with Set-PSReadLineKeyHandler -Key Tab -Function + + # CompletionResult Arguments: + # 1) CompletionText text to be used as the auto completion result + # 2) ListItemText text to be displayed in the suggestion list + # 3) ResultType type of completion result + # 4) ToolTip text for the tooltip with details about the object + + switch ($Mode) { + + # bash like + "Complete" { + + if ($Values.Length -eq 1) { + __${programName}_debug "Only one completion left" + + # insert space after value + $CompletionText = $($comp.Name | __${programName}_escapeStringWithSpecialChars) + $Space + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + + } else { + # Add the proper number of spaces to align the descriptions + while($comp.Name.Length -lt $Longest) { + $comp.Name = $comp.Name + " " + } + + # Check for empty description and only add parentheses if needed + if ($($comp.Description) -eq " " ) { + $Description = "" + } else { + $Description = " ($($comp.Description))" + } + + $CompletionText = "$($comp.Name)$Description" + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)$Description", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + } + } + + # zsh like + "MenuComplete" { + # insert space after value + # MenuComplete will automatically show the ToolTip of + # the highlighted value at the bottom of the suggestions. + + $CompletionText = $($comp.Name | __${programName}_escapeStringWithSpecialChars) + $Space + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + } + + # TabCompleteNext and in case we get something unknown + Default { + # Like MenuComplete but we don't want to add a space here because + # the user need to press space anyway to get the completion. + # Description will not be shown because that's not possible with TabCompleteNext + + $CompletionText = $($comp.Name | __${programName}_escapeStringWithSpecialChars) + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + } + } + + } +} + +Register-ArgumentCompleter -CommandName '${programName}' -ScriptBlock \${__${programName}CompleterBlock} +`; +} + +export type LegacyCompletionShell = "bash" | "zsh" | "fish" | "powershell"; + +/** + * Generates the exact script cobra v1.10.2's `supabase completion ` + * would have produced, without shelling out to (or otherwise depending on) + * the Go binary. + */ +export function legacyGenerateCompletionScript( + shell: LegacyCompletionShell, + options: { readonly noDescriptions: boolean }, +): string { + const compCmd: CompletionRequestCmd = options.noDescriptions + ? SHELL_COMP_NO_DESC_REQUEST_CMD + : SHELL_COMP_REQUEST_CMD; + + switch (shell) { + case "bash": + return genBashCompletionScript(PROGRAM_NAME, compCmd); + case "zsh": + return genZshCompletionScript(PROGRAM_NAME, compCmd); + case "fish": + return genFishCompletionScript(PROGRAM_NAME, compCmd); + case "powershell": + return genPowerShellCompletionScript(PROGRAM_NAME, compCmd); + } +} diff --git a/apps/cli/src/legacy/commands/completion/legacy-completion-scripts.unit.test.ts b/apps/cli/src/legacy/commands/completion/legacy-completion-scripts.unit.test.ts new file mode 100644 index 0000000000..122b73bd1a --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/legacy-completion-scripts.unit.test.ts @@ -0,0 +1,145 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { + type LegacyCompletionShell, + legacyGenerateCompletionScript, +} from "./legacy-completion-scripts.ts"; + +const fixturesDir = fileURLToPath(new URL("./__fixtures__", import.meta.url)); + +function readFixture(shell: LegacyCompletionShell, variant: "desc" | "nodesc"): string { + return readFileSync(`${fixturesDir}/${shell}.${variant}.txt`, "utf8"); +} + +describe("legacyGenerateCompletionScript", () => { + describe("bash", () => { + it("contains the bash completion V2 header", () => { + const script = legacyGenerateCompletionScript("bash", { noDescriptions: false }); + expect(script).toContain("# bash completion V2 for supabase"); + }); + + it("calls back into __complete by default and never mentions __completeNoDesc", () => { + const script = legacyGenerateCompletionScript("bash", { noDescriptions: false }); + expect(script).toContain("__complete"); + expect(script).not.toContain("__completeNoDesc"); + }); + + it("calls back into __completeNoDesc when noDescriptions is true", () => { + const script = legacyGenerateCompletionScript("bash", { noDescriptions: true }); + expect(script).toContain("__completeNoDesc"); + }); + + it("differs from the with-descriptions variant only by the __complete/__completeNoDesc token", () => { + const withDescriptions = legacyGenerateCompletionScript("bash", { noDescriptions: false }); + const noDescriptions = legacyGenerateCompletionScript("bash", { noDescriptions: true }); + expect(noDescriptions.replaceAll("__completeNoDesc", "__complete")).toBe(withDescriptions); + }); + }); + + describe("zsh", () => { + it("contains the #compdef header and the trailing compdef invocation", () => { + const script = legacyGenerateCompletionScript("zsh", { noDescriptions: false }); + expect(script).toContain("#compdef supabase"); + expect(script).toContain("compdef _supabase supabase"); + }); + + it("calls back into __complete by default and never mentions __completeNoDesc", () => { + const script = legacyGenerateCompletionScript("zsh", { noDescriptions: false }); + expect(script).toContain("__complete"); + expect(script).not.toContain("__completeNoDesc"); + }); + + it("calls back into __completeNoDesc when noDescriptions is true", () => { + const script = legacyGenerateCompletionScript("zsh", { noDescriptions: true }); + expect(script).toContain("__completeNoDesc"); + }); + + it("differs from the with-descriptions variant only by the __complete/__completeNoDesc token", () => { + const withDescriptions = legacyGenerateCompletionScript("zsh", { noDescriptions: false }); + const noDescriptions = legacyGenerateCompletionScript("zsh", { noDescriptions: true }); + expect(noDescriptions.replaceAll("__completeNoDesc", "__complete")).toBe(withDescriptions); + }); + }); + + describe("fish", () => { + it("contains the fish completion header and disables activeHelp via SUPABASE_ACTIVE_HELP=0", () => { + const script = legacyGenerateCompletionScript("fish", { noDescriptions: false }); + expect(script).toContain("# fish completion for supabase"); + expect(script).toContain("SUPABASE_ACTIVE_HELP=0"); + }); + + it("calls back into __complete by default and never mentions __completeNoDesc", () => { + const script = legacyGenerateCompletionScript("fish", { noDescriptions: false }); + expect(script).toContain("__complete"); + expect(script).not.toContain("__completeNoDesc"); + }); + + it("calls back into __completeNoDesc when noDescriptions is true", () => { + const script = legacyGenerateCompletionScript("fish", { noDescriptions: true }); + expect(script).toContain("__completeNoDesc"); + }); + + it("differs from the with-descriptions variant only by the __complete/__completeNoDesc token", () => { + const withDescriptions = legacyGenerateCompletionScript("fish", { noDescriptions: false }); + const noDescriptions = legacyGenerateCompletionScript("fish", { noDescriptions: true }); + expect(noDescriptions.replaceAll("__completeNoDesc", "__complete")).toBe(withDescriptions); + }); + }); + + describe("powershell", () => { + it("registers the argument completer for the supabase command", () => { + const script = legacyGenerateCompletionScript("powershell", { noDescriptions: false }); + expect(script).toContain("Register-ArgumentCompleter -CommandName 'supabase'"); + }); + + it("calls back into __complete by default and never mentions __completeNoDesc", () => { + const script = legacyGenerateCompletionScript("powershell", { noDescriptions: false }); + expect(script).toContain("__complete"); + expect(script).not.toContain("__completeNoDesc"); + }); + + it("calls back into __completeNoDesc when noDescriptions is true", () => { + const script = legacyGenerateCompletionScript("powershell", { noDescriptions: true }); + expect(script).toContain("__completeNoDesc"); + }); + + it("differs from the with-descriptions variant only by the __complete/__completeNoDesc token", () => { + const withDescriptions = legacyGenerateCompletionScript("powershell", { + noDescriptions: false, + }); + const noDescriptions = legacyGenerateCompletionScript("powershell", { + noDescriptions: true, + }); + expect(noDescriptions.replaceAll("__completeNoDesc", "__complete")).toBe(withDescriptions); + }); + }); + + // The substring/self-consistency checks above prove structural facts, but + // "byte-for-byte transcription of cobra v1.10.2" is the module's entire + // contract, and every one of the hundreds of hand-escaped `${…}`/backtick/ + // `$'\t'` sequences in the four templates is otherwise unguarded — a + // well-intentioned "cleanup" of an escape could ship silently. These + // fixtures are the literal stdout of a real `apps/cli-go` binary (pinned to + // `spf13/cobra v1.10.2`, same version as `go.mod`) running + // `supabase completion [--no-descriptions]`, captured once and + // checked in — see `apps/cli/src/legacy/commands/completion/__fixtures__/`. + // Regenerate them (and re-verify byte equality by hand) only if cobra is + // ever upgraded. + describe("byte-exact parity with real cobra v1.10.2 output", () => { + const shells: ReadonlyArray = ["bash", "zsh", "fish", "powershell"]; + + for (const shell of shells) { + it(`matches the real cobra ${shell} completion script byte-for-byte (with descriptions)`, () => { + const generated = legacyGenerateCompletionScript(shell, { noDescriptions: false }); + expect(generated).toBe(readFixture(shell, "desc")); + }); + + it(`matches the real cobra ${shell} completion script byte-for-byte (--no-descriptions)`, () => { + const generated = legacyGenerateCompletionScript(shell, { noDescriptions: true }); + expect(generated).toBe(readFixture(shell, "nodesc")); + }); + } + }); +}); diff --git a/apps/cli/src/legacy/commands/completion/powershell/powershell.command.ts b/apps/cli/src/legacy/commands/completion/powershell/powershell.command.ts index 72dd479f3f..c4bae7b26d 100644 --- a/apps/cli/src/legacy/commands/completion/powershell/powershell.command.ts +++ b/apps/cli/src/legacy/commands/completion/powershell/powershell.command.ts @@ -9,7 +9,13 @@ const config = { export type LegacyCompletionPowershellFlags = CliCommand.Command.Config.Infer; export const legacyCompletionPowershellCommand = Command.make("powershell", config).pipe( - Command.withDescription("Generate the autocompletion script for powershell"), + Command.withDescription( + "Generate the autocompletion script for powershell.\n\n" + + "To load completions in your current shell session:\n\n" + + "\tsupabase completion powershell | Out-String | Invoke-Expression\n\n" + + "To load completions for every new session, add the output of the above command\n" + + "to your powershell profile.", + ), Command.withShortDescription("Generate the autocompletion script for powershell"), Command.withHandler((flags) => legacyCompletionPowershell(flags)), ); diff --git a/apps/cli/src/legacy/commands/completion/powershell/powershell.handler.ts b/apps/cli/src/legacy/commands/completion/powershell/powershell.handler.ts index 8b1d056431..e21645ebb3 100644 --- a/apps/cli/src/legacy/commands/completion/powershell/powershell.handler.ts +++ b/apps/cli/src/legacy/commands/completion/powershell/powershell.handler.ts @@ -1,12 +1,13 @@ import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyGenerateCompletionScript } from "../legacy-completion-scripts.ts"; import type { LegacyCompletionPowershellFlags } from "./powershell.command.ts"; export const legacyCompletionPowershell = Effect.fn("legacy.completion.powershell")(function* ( flags: LegacyCompletionPowershellFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["completion", "powershell"]; - if (flags.noDescriptions) args.push("--no-descriptions"); - yield* proxy.exec(args); + const output = yield* Output; + yield* output.raw( + legacyGenerateCompletionScript("powershell", { noDescriptions: flags.noDescriptions }), + ); }); diff --git a/apps/cli/src/legacy/commands/completion/powershell/powershell.integration.test.ts b/apps/cli/src/legacy/commands/completion/powershell/powershell.integration.test.ts index da678deeb2..5a55b8e657 100644 --- a/apps/cli/src/legacy/commands/completion/powershell/powershell.integration.test.ts +++ b/apps/cli/src/legacy/commands/completion/powershell/powershell.integration.test.ts @@ -1,20 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect } from "effect"; import { Command } from "effect/unstable/cli"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { legacyCompletionPowershellCommand } from "./powershell.command.ts"; import { legacyCompletionPowershell } from "./powershell.handler.ts"; function setupLegacyCompletionPowershell() { - const calls: Array> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args) => - Effect.sync(() => { - calls.push(args); - }), - execCapture: () => Effect.succeed(""), - }); - return { layer, calls }; + return mockOutput(); } function legacyTestRoot() { @@ -24,30 +16,38 @@ function legacyTestRoot() { } describe("legacy completion powershell", () => { - it.live("forwards `completion powershell` to the Go binary", () => { - const { layer, calls } = setupLegacyCompletionPowershell(); + it.live("prints the native powershell completion script", () => { + const out = setupLegacyCompletionPowershell(); return Effect.gen(function* () { yield* legacyCompletionPowershell({ noDescriptions: false }); - expect(calls).toEqual([["completion", "powershell"]]); - }).pipe(Effect.provide(layer)); + expect(out.stdoutText).toContain("# powershell completion for supabase"); + expect(out.stdoutText).not.toContain("__completeNoDesc"); + expect(out.stdoutText).toContain("__complete"); + }).pipe(Effect.provide(out.layer)); }); - it.live("forwards --no-descriptions when set", () => { - const { layer, calls } = setupLegacyCompletionPowershell(); - return Effect.gen(function* () { - yield* legacyCompletionPowershell({ noDescriptions: true }); - expect(calls).toEqual([["completion", "powershell", "--no-descriptions"]]); - }).pipe(Effect.provide(layer)); - }); + it.live( + "prints the native powershell completion script without descriptions when --no-descriptions is set", + () => { + const out = setupLegacyCompletionPowershell(); + return Effect.gen(function* () { + yield* legacyCompletionPowershell({ noDescriptions: true }); + expect(out.stdoutText).toContain("__completeNoDesc"); + }).pipe(Effect.provide(out.layer)); + }, + ); - it.live("accepts --no-descriptions from real argv via the command parser", () => { - const { layer, calls } = setupLegacyCompletionPowershell(); - return Effect.gen(function* () { - yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ - "powershell", - "--no-descriptions", - ]); - expect(calls).toEqual([["completion", "powershell", "--no-descriptions"]]); - }).pipe(Effect.provide(layer)) as Effect.Effect; - }); + it.live( + "accepts --no-descriptions from real argv via the command parser and still prints the no-desc script", + () => { + const out = setupLegacyCompletionPowershell(); + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ + "powershell", + "--no-descriptions", + ]); + expect(out.stdoutText).toContain("__completeNoDesc"); + }).pipe(Effect.provide(out.layer)) as Effect.Effect; + }, + ); }); diff --git a/apps/cli/src/legacy/commands/completion/zsh/zsh.command.ts b/apps/cli/src/legacy/commands/completion/zsh/zsh.command.ts index 8951d805f6..1d0ac35c33 100644 --- a/apps/cli/src/legacy/commands/completion/zsh/zsh.command.ts +++ b/apps/cli/src/legacy/commands/completion/zsh/zsh.command.ts @@ -9,7 +9,20 @@ const config = { export type LegacyCompletionZshFlags = CliCommand.Command.Config.Infer; export const legacyCompletionZshCommand = Command.make("zsh", config).pipe( - Command.withDescription("Generate the autocompletion script for zsh"), + Command.withDescription( + "Generate the autocompletion script for the zsh shell.\n\n" + + "If shell completion is not already enabled in your environment you will need\n" + + "to enable it. You can execute the following once:\n\n" + + '\techo "autoload -U compinit; compinit" >> ~/.zshrc\n\n' + + "To load completions in your current shell session:\n\n" + + "\tsource <(supabase completion zsh)\n\n" + + "To load completions for every new session, execute once:\n\n" + + "#### Linux:\n\n" + + '\tsupabase completion zsh > "${fpath[1]}/_supabase"\n\n' + + "#### macOS:\n\n" + + "\tsupabase completion zsh > $(brew --prefix)/share/zsh/site-functions/_supabase\n\n" + + "You will need to start a new shell for this setup to take effect.", + ), Command.withShortDescription("Generate the autocompletion script for zsh"), Command.withHandler((flags) => legacyCompletionZsh(flags)), ); diff --git a/apps/cli/src/legacy/commands/completion/zsh/zsh.handler.ts b/apps/cli/src/legacy/commands/completion/zsh/zsh.handler.ts index 472f1918bc..dbcb1c1858 100644 --- a/apps/cli/src/legacy/commands/completion/zsh/zsh.handler.ts +++ b/apps/cli/src/legacy/commands/completion/zsh/zsh.handler.ts @@ -1,12 +1,13 @@ import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyGenerateCompletionScript } from "../legacy-completion-scripts.ts"; import type { LegacyCompletionZshFlags } from "./zsh.command.ts"; export const legacyCompletionZsh = Effect.fn("legacy.completion.zsh")(function* ( flags: LegacyCompletionZshFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["completion", "zsh"]; - if (flags.noDescriptions) args.push("--no-descriptions"); - yield* proxy.exec(args); + const output = yield* Output; + yield* output.raw( + legacyGenerateCompletionScript("zsh", { noDescriptions: flags.noDescriptions }), + ); }); diff --git a/apps/cli/src/legacy/commands/completion/zsh/zsh.integration.test.ts b/apps/cli/src/legacy/commands/completion/zsh/zsh.integration.test.ts index 9745c1a1e4..9e170775fd 100644 --- a/apps/cli/src/legacy/commands/completion/zsh/zsh.integration.test.ts +++ b/apps/cli/src/legacy/commands/completion/zsh/zsh.integration.test.ts @@ -1,20 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect } from "effect"; import { Command } from "effect/unstable/cli"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { legacyCompletionZshCommand } from "./zsh.command.ts"; import { legacyCompletionZsh } from "./zsh.handler.ts"; function setupLegacyCompletionZsh() { - const calls: Array> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args) => - Effect.sync(() => { - calls.push(args); - }), - execCapture: () => Effect.succeed(""), - }); - return { layer, calls }; + return mockOutput(); } function legacyTestRoot() { @@ -22,30 +14,38 @@ function legacyTestRoot() { } describe("legacy completion zsh", () => { - it.live("forwards `completion zsh` to the Go binary", () => { - const { layer, calls } = setupLegacyCompletionZsh(); + it.live("prints the native zsh completion script", () => { + const out = setupLegacyCompletionZsh(); return Effect.gen(function* () { yield* legacyCompletionZsh({ noDescriptions: false }); - expect(calls).toEqual([["completion", "zsh"]]); - }).pipe(Effect.provide(layer)); + expect(out.stdoutText).toContain("#compdef supabase"); + expect(out.stdoutText).not.toContain("__completeNoDesc"); + expect(out.stdoutText).toContain("__complete"); + }).pipe(Effect.provide(out.layer)); }); - it.live("forwards --no-descriptions when set", () => { - const { layer, calls } = setupLegacyCompletionZsh(); - return Effect.gen(function* () { - yield* legacyCompletionZsh({ noDescriptions: true }); - expect(calls).toEqual([["completion", "zsh", "--no-descriptions"]]); - }).pipe(Effect.provide(layer)); - }); + it.live( + "prints the native zsh completion script without descriptions when --no-descriptions is set", + () => { + const out = setupLegacyCompletionZsh(); + return Effect.gen(function* () { + yield* legacyCompletionZsh({ noDescriptions: true }); + expect(out.stdoutText).toContain("__completeNoDesc"); + }).pipe(Effect.provide(out.layer)); + }, + ); - it.live("accepts --no-descriptions from real argv via the command parser", () => { - const { layer, calls } = setupLegacyCompletionZsh(); - return Effect.gen(function* () { - yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ - "zsh", - "--no-descriptions", - ]); - expect(calls).toEqual([["completion", "zsh", "--no-descriptions"]]); - }).pipe(Effect.provide(layer)) as Effect.Effect; - }); + it.live( + "accepts --no-descriptions from real argv via the command parser and still prints the no-desc script", + () => { + const out = setupLegacyCompletionZsh(); + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ + "zsh", + "--no-descriptions", + ]); + expect(out.stdoutText).toContain("__completeNoDesc"); + }).pipe(Effect.provide(out.layer)) as Effect.Effect; + }, + ); }); diff --git a/apps/cli/src/legacy/shared/legacy-param-introspection.ts b/apps/cli/src/legacy/shared/legacy-param-introspection.ts new file mode 100644 index 0000000000..713d3b1592 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-param-introspection.ts @@ -0,0 +1,98 @@ +import { Option } from "effect"; +import { Param } from "effect/unstable/cli"; + +/** + * `effect/unstable/cli`'s own `Param.extractSingleParams`/`Param.getParamMetadata` + * (`.repos/effect/packages/effect/src/unstable/cli/Param.ts`) already implement + * exactly this unwrap — the same functions `--help` rendering uses internally — + * but both carry an `@internal` JSDoc tag and are confirmed ABSENT from this + * package's published `.d.ts` (present only in the compiled `.js`; verified + * against the pinned `effect@4.0.0-beta.97` under `node_modules`), so calling + * them would only type-check via an `as` cast, which this repo forbids. + * + * This module reimplements the same unwrap using only type-visible public + * fields. A `Map`/`Transform`/`Optional`/`Variadic` param wraps an inner + * `.param` of the same shape (e.g. `.pipe(Flag.optional)`, + * `.pipe(Flag.withDefault(...))`, which composes as `Map(Optional(Single))`), + * and every non-`Single` variant publicly declares `.param` per its own + * interface. The variant union is closed as of this effect version, so an + * unrecognized future variant fails *closed* (the walk stops and returns + * `undefined`) rather than open. Delete this in favor of + * `Param.extractSingleParams`/`Param.getParamMetadata` if effect ever + * publishes them. + */ +interface LegacyWrappedParam { + readonly param: Param.Any; +} + +function legacyIsWrappedParam(param: Param.Any): param is Param.Any & LegacyWrappedParam { + return "param" in param; +} + +interface LegacyVariadicParam { + readonly min: Option.Option; +} + +function legacyIsVariadicParam( + param: Param.Any & LegacyWrappedParam, +): param is Param.Any & LegacyWrappedParam & LegacyVariadicParam { + return "min" in param; +} + +export interface LegacyUnwrappedParam { + readonly single: Param.Single; + readonly isOptional: boolean; + readonly isVariadic: boolean; + /** + * The `Param.variadic`/`Flag.atLeast`/`Flag.between` minimum occurrence + * count, or `0` when the param isn't variadic at all. A variadic param with + * `min === 0` (e.g. `Flag.atLeast(0)`, what `legacyStringSliceFlag` uses) + * can legitimately be omitted entirely — `Param.ts`'s `parseOptionVariadic` + * only fails with `MissingOption` when `count < min` and `min > 0` — so + * "variadic" alone does NOT imply "optional" the way wrapping in + * `Param.Optional` does. Callers computing required-ness must check this, + * not just `isVariadic`. + */ + readonly variadicMin: number; +} + +/** + * Unwraps a possibly-wrapped `Param` down to its underlying `Single` leaf, + * alongside whether the param passed through `Param.optional`/`Flag.optional` + * (or `Flag.withDefault`, which composes as `Map(Optional(Single))`) and/or + * `Param.variadic`/`Flag.between`/`Flag.atLeast`/`Flag.atMost`. Returns + * `undefined` only if the variant union gains an unrecognized future case. + */ +export function legacyUnwrapParam(param: Param.Any): LegacyUnwrappedParam | undefined { + let current: Param.Any = param; + let isOptional = false; + let isVariadic = false; + let variadicMin = 0; + + while (!Param.isSingle(current)) { + if (!legacyIsWrappedParam(current)) return undefined; + if (current._tag === "Optional") isOptional = true; + if (current._tag === "Variadic") { + isVariadic = true; + if (legacyIsVariadicParam(current)) { + variadicMin = Option.getOrElse(current.min, () => 0); + } + } + current = current.param; + } + + return { single: current, isOptional, isVariadic, variadicMin }; +} + +/** + * Unwraps down to the underlying `Single` param only, discarding the + * optional/variadic metadata `legacyUnwrapParam` also computes. Shared by + * `legacy/telemetry/legacy-command-instrumentation.ts` (telemetry flag + * redaction) and `legacy/cli/legacy-complete.ts` (shell completion), both of + * which only need the leaf `Single`'s `name`/`aliases`/`primitiveType` fields. + */ +export function legacyUnwrapToSingleParam( + param: Param.Any, +): Param.Single | undefined { + return legacyUnwrapParam(param)?.single; +} diff --git a/apps/cli/src/legacy/shared/legacy-param-introspection.unit.test.ts b/apps/cli/src/legacy/shared/legacy-param-introspection.unit.test.ts new file mode 100644 index 0000000000..7d0666f5c1 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-param-introspection.unit.test.ts @@ -0,0 +1,94 @@ +import { Flag } from "effect/unstable/cli"; +import { describe, expect, it } from "vitest"; + +import { legacyUnwrapParam, legacyUnwrapToSingleParam } from "./legacy-param-introspection.ts"; + +describe("legacyUnwrapParam", () => { + it("unwraps a plain, required flag with isOptional/isVariadic both false and variadicMin 0", () => { + const result = legacyUnwrapParam(Flag.string("custom-hostname")); + expect(result?.single.name).toBe("custom-hostname"); + expect(result?.isOptional).toBe(false); + expect(result?.isVariadic).toBe(false); + expect(result?.variadicMin).toBe(0); + }); + + it("marks a Flag.optional-wrapped flag as isOptional", () => { + const result = legacyUnwrapParam(Flag.string("desired-subdomain").pipe(Flag.optional)); + expect(result?.single.name).toBe("desired-subdomain"); + expect(result?.isOptional).toBe(true); + }); + + it("marks a Flag.withDefault-wrapped flag as isOptional (composes as Map(Optional(Single)))", () => { + const result = legacyUnwrapParam(Flag.string("profile").pipe(Flag.withDefault("supabase"))); + expect(result?.single.name).toBe("profile"); + expect(result?.isOptional).toBe(true); + }); + + it("does not mark a plain boolean flag as isOptional (booleans default to false unwrapped)", () => { + const result = legacyUnwrapParam(Flag.boolean("debug")); + expect(result?.single.name).toBe("debug"); + expect(result?.isOptional).toBe(false); + expect(result?.single.primitiveType._tag).toBe("Boolean"); + }); + + it("marks a zero-minimum variadic flag (Flag.atLeast(0)) as variadic but NOT optional, with variadicMin 0", () => { + // This is the exact shape `legacyStringSliceFlag` builds on + // (`legacy-string-slice-flag.ts`) — a real bug (CLI-1965 review) treated + // this as "required" for shell-completion purposes because it isn't + // `Optional`-wrapped, even though `Param.ts`'s `parseOptionVariadic` only + // fails with `MissingOption` when `count < min` and `min > 0`, so a + // zero-minimum variadic flag can legitimately be omitted entirely. + const result = legacyUnwrapParam(Flag.string("domains").pipe(Flag.atLeast(0))); + expect(result?.single.name).toBe("domains"); + expect(result?.isOptional).toBe(false); + expect(result?.isVariadic).toBe(true); + expect(result?.variadicMin).toBe(0); + }); + + it("captures a positive variadic minimum (Flag.atLeast(2))", () => { + const result = legacyUnwrapParam(Flag.string("source").pipe(Flag.atLeast(2))); + expect(result?.isVariadic).toBe(true); + expect(result?.variadicMin).toBe(2); + }); + + it("captures the minimum from Flag.between", () => { + const result = legacyUnwrapParam(Flag.string("host").pipe(Flag.between(1, 3))); + expect(result?.isVariadic).toBe(true); + expect(result?.variadicMin).toBe(1); + }); + + it("reports variadicMin 0 for an unbounded Flag.atMost (no minimum set)", () => { + const result = legacyUnwrapParam(Flag.string("warning").pipe(Flag.atMost(3))); + expect(result?.isVariadic).toBe(true); + expect(result?.variadicMin).toBe(0); + }); + + it("walks through a chained Map after Optional (Flag.withDefault on a choice flag)", () => { + const result = legacyUnwrapParam( + Flag.choice("dns-resolver", ["native", "https"] as const).pipe(Flag.withDefault("native")), + ); + expect(result?.single.name).toBe("dns-resolver"); + expect(result?.isOptional).toBe(true); + expect(result?.single.primitiveType._tag).toBe("Choice"); + }); + + it("preserves aliases and hidden metadata on the underlying Single", () => { + const result = legacyUnwrapParam( + Flag.string("type").pipe(Flag.withAlias("t"), Flag.withHidden), + ); + expect(result?.single.aliases).toEqual(["t"]); + expect(result?.single.hidden).toBe(true); + }); +}); + +describe("legacyUnwrapToSingleParam", () => { + it("returns just the underlying Single, discarding optional/variadic metadata", () => { + const single = legacyUnwrapToSingleParam(Flag.string("role").pipe(Flag.optional)); + expect(single?.name).toBe("role"); + }); + + it("agrees with legacyUnwrapParam's own .single for the same input", () => { + const param = Flag.string("status").pipe(Flag.atLeast(0)); + expect(legacyUnwrapToSingleParam(param)).toBe(legacyUnwrapParam(param)?.single); + }); +}); diff --git a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts index ceae9c91be..bd349ff18b 100644 --- a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts +++ b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts @@ -31,6 +31,7 @@ import { VALUE_CONSUMING_LONG_FLAGS, VALUE_CONSUMING_SHORT_FLAGS, } from "../shared/legacy-db-target-flags.ts"; +import { legacyUnwrapToSingleParam } from "../shared/legacy-param-introspection.ts"; interface LegacyCommandInstrumentationOptions = never> { readonly analytics?: boolean; @@ -216,40 +217,6 @@ function normalizeFlagValue(value: unknown): unknown | undefined { return normalizeFlagValue(value.value); } -// A `Map`/`Transform`/`Optional`/`Variadic` param wraps an inner `param` of the -// same shape (e.g. `.pipe(Flag.optional)`, `.pipe(Flag.withDefault(...))`, which -// composes as `Map(Optional(Single))`). `effect/unstable/cli` already ships the -// exact unwrap this needs — `Param.extractSingleParams`, the same function -// `--help` rendering uses — but it (and `Primitive.getChoiceKeys`) are -// `@internal`-tagged and confirmed absent from this package's published `.d.ts` -// (present in the compiled `.js`, so calling them would only type-check via an -// `as` cast, which this repo forbids). This predicate reimplements the -// `isSingle`-or-has-a-`.param`-field check using only type-visible public -// fields; every non-`Single` variant publicly declares `.param` per its own -// interface, and the variant union is closed as of this effect version, so an -// unrecognized future variant fails *closed* (silently not detected as a -// choice flag, i.e. stays redacted) rather than open. Delete this in favor of -// `Param.extractSingleParams` if effect ever publishes it. -interface WrappedParam { - readonly param: Param.Any; -} -function isWrappedParam(param: Param.Any): param is Param.Any & WrappedParam { - return "param" in param; -} - -// Unwraps down to the underlying `Single` param the same way `--help` -// rendering does. Shared by `getChoiceFlagNames` and `GLOBAL_SHORT_ALIASES` -// below — both need the leaf `Single` to read its type-visible `name`/ -// `aliases`/`primitiveType` fields. Returns `undefined` only if the variant -// union gains an unrecognized future case (fails closed, see the -// `isWrappedParam` doc above for why this hand-rolled unwrap exists instead of -// the `@internal` `Param.extractSingleParams`). -function unwrapToSingleParam(param: Param.Any): Param.Single | undefined { - if (Param.isSingle(param)) return param; - if (isWrappedParam(param)) return unwrapToSingleParam(param.param); - return undefined; -} - // Mirrors Go's `isEnumFlag` (`cmd/root_analytics.go:110-116`), which checks // `flag.Value.(*utils.EnumFlag)` unconditionally — every enum flag is // telemetry-safe, no per-flag annotation needed. Checks the unwrapped @@ -261,7 +228,7 @@ function getChoiceFlagNames(config: Record | undefined): Read if (config === undefined) return names; for (const param of Object.values(config)) { - const single = unwrapToSingleParam(param); + const single = legacyUnwrapToSingleParam(param); if ( single !== undefined && single.kind === Param.flagKind && @@ -289,7 +256,7 @@ function getChoiceFlagNames(config: Record | undefined): Read const GLOBAL_SHORT_ALIASES: Readonly> = (() => { const aliases: Record = {}; for (const globalFlag of LEGACY_GLOBAL_FLAGS) { - const single = unwrapToSingleParam(globalFlag.flag); + const single = legacyUnwrapToSingleParam(globalFlag.flag); if (single === undefined) continue; for (const alias of single.aliases) { aliases[alias] = single.name; From 311f87623236ef4ee2416274d736d63881f32232 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 15:14:45 +0100 Subject: [PATCH 02/14] fix(cli): tighten shell completion internals typing and flag/help parity (review) Addresses three Codex review findings on PR #6083 (legacy-complete.ts): - Replace the `as unknown as LegacyCommandInternal` assertion with a runtime type guard (legacyHasCommandInternals), mirroring the precedent already established in legacy-param-introspection.ts for the same "internal-only effect/unstable/cli field" problem. The repo's typing rules forbid `as` casts to paper over Effect/CLI typing gaps. - Synthesize cobra's auto-registered `help` subcommand in root-level `__complete` output (InitDefaultHelpCmd registers it only on whichever command Execute() runs against, never recursively on descendants) - verified empirically against a real apps/cli-go build. - Short-circuit to zero candidates with the Default directive when a flag-shaped token doesn't resolve to any in-scope flag, matching cobra's finalCmd.ParseFlags() failing outright on an unrecognized flag - a failure that wins even over the --help/--version short-circuit. Also verified empirically. A fourth finding (shared flags via Command.withSharedFlags not visible on the declaring command itself before a child is selected) was investigated and rejected: Command.withSharedFlags already merges shared flags into the declaring command's own `.config`, not just descendants' `.contextConfig`, so the described regression does not reproduce. --- apps/cli/src/legacy/cli/legacy-complete.ts | 112 +++++++++++++++++++-- 1 file changed, 105 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/legacy/cli/legacy-complete.ts b/apps/cli/src/legacy/cli/legacy-complete.ts index d0ac0ad3e2..eda40ce01a 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.ts @@ -98,7 +98,7 @@ export interface LegacyCompleteDeps { } /* ========================================================================== */ -/* Internal command field access (`next/docs/command-docs.ts` precedent) */ +/* Internal command field access (`legacy-param-introspection.ts` precedent) */ /* ========================================================================== */ /** @@ -108,8 +108,18 @@ export interface LegacyCompleteDeps { * `Command`/`Command.Any` TypeScript interface — only `name`, `description`, * `shortDescription`, `alias`, `examples`, `subcommands`, `annotations`, and * `hidden` are public — but they exist at runtime (`internal/command.ts`'s - * `makeCommand`, via `Object.assign`). Accessed the same way - * `next/docs/command-docs.ts` already accesses `buildHelpDoc`. + * `makeCommand`, via `Object.assign`; that internal module is not importable + * — its package.json export map entry is `null` — so there is no type-safe + * import to reach for instead). + * + * A bare `as unknown as` here would silently paper over that gap (forbidden + * by this repo's typing rules — see `CLAUDE.md`), so this narrows through a + * runtime type guard instead, the same `"" in value` shape + * `legacy-param-introspection.ts`'s `legacyIsWrappedParam` already + * establishes for the identical problem (an internal-only field the public + * `effect/unstable/cli` types don't declare). If a future `effect` version + * ever drops one of these fields, this throws instead of silently completing + * against `undefined`. */ interface LegacyCommandInternal { readonly config: { readonly flags: ReadonlyArray }; @@ -117,8 +127,19 @@ interface LegacyCommandInternal { readonly globalFlags: ReadonlyArray>; } +function legacyHasCommandInternals( + command: Command.Command.Any, +): command is Command.Command.Any & LegacyCommandInternal { + return "config" in command && "contextConfig" in command && "globalFlags" in command; +} + function legacyInternalCommand(command: Command.Command.Any): LegacyCommandInternal { - return command as unknown as LegacyCommandInternal; + if (!legacyHasCommandInternals(command)) { + throw new Error( + `legacy-complete.ts: command "${command.name}" is missing the internal config/contextConfig/globalFlags fields shell completion relies on — effect's Command implementation shape may have changed.`, + ); + } + return command; } function legacyFlattenSubcommands( @@ -397,6 +418,55 @@ function legacyChangedFlagNames( return changed; } +/** + * Finds the first token in `trimmedArgs` that looks like a flag (starts with + * `-`, excluding the bare `-` positional pflag itself treats as a non-flag + * argument) but does not resolve to anything in `inScopeFlags`, consuming a + * following token as a non-boolean flag's value the same way + * `legacyResolveCommandPath` does. A bare `--` ends the scan entirely without + * itself counting as unresolved — pflag's own end-of-flags sentinel, after + * which everything is positional, not a flag to validate (`pflag@v1.0.9`'s + * `parseArgs`: `if s[1] == '-' { if len(s) == 2 { ... terminates the flags`). + * Returns the offending token, or `undefined` if every flag-shaped token + * resolves. + * + * Mirrors cobra's real two-phase design: `Find()` tolerantly skips flags it + * doesn't recognize while walking for a subcommand name (see + * `legacyResolveCommandPath`, which only needs to know "does this consume a + * value", not "is this real"), but the later `finalCmd.ParseFlags()` strictly + * validates every remaining flag token against the fully-resolved command's + * complete flag set and fails outright on the first one it can't recognize + * (`completions.go:373-375`) — a failure so early it wins even over the + * `--help`/`--version` short-circuit below (verified empirically against a + * real `apps/cli-go` build: both `__complete --bogus --help ""` and + * `__complete --help --bogus ""` report the unknown flag, not help; a bare + * `__complete --bogus ""` returns zero candidates with the Default directive, + * not the root subcommand list; `__complete -- ""` is unaffected and still + * lists every root subcommand). + */ +function legacyFindUnresolvedFlagToken( + trimmedArgs: ReadonlyArray, + inScopeFlags: ReadonlyArray, +): string | undefined { + let index = 0; + while (index < trimmedArgs.length) { + const token = trimmedArgs[index]; + index++; + if (token === undefined || token === "-" || !token.startsWith("-")) continue; + if (token === "--") break; + + const equalsIndex = token.indexOf("="); + const bareToken = equalsIndex === -1 ? token : token.slice(0, equalsIndex); + const resolved = legacyResolveFlagFromToken(bareToken, inScopeFlags); + if (resolved === undefined) return token; + + if (equalsIndex === -1 && !resolved.isBoolean && index < trimmedArgs.length) { + index++; // skip the consumed value token + } + } + return undefined; +} + function legacyFlagValueCompletion( matchedPath: ReadonlyArray, flagName: string | undefined, @@ -418,6 +488,10 @@ function legacyFlagValueCompletion( * mirroring cobra's `checkIfFlagCompletion` and the branch in * `getCompletions` that follows it: * + * 0. A flag-shaped token that doesn't resolve to any in-scope flag + * short-circuits to no candidates with the Default directive — mirrors + * `finalCmd.ParseFlags()` failing outright on an unrecognized flag, which + * wins even over `--help`/`--version` below. * 1. `--help`/`-h` anywhere in `trimmedArgs` (or `--version`/`-v`, only when * resolved to the root command) short-circuits to no candidates — these * exit before any real completion runs. @@ -432,6 +506,10 @@ export function legacyClassifyCompletion( const { finalCommand, matchedPath, leftoverArgs, trimmedArgs, toComplete, inScopeFlags } = input; const isAtRoot = matchedPath.length === 0; + if (legacyFindUnresolvedFlagToken(trimmedArgs, inScopeFlags) !== undefined) { + return { candidates: [], directive: LegacyCompletionDirective.Default }; + } + if ( trimmedArgs.some((token) => LEGACY_HELP_TOKENS.has(token)) || (isAtRoot && trimmedArgs.some((token) => LEGACY_VERSION_TOKENS.has(token))) @@ -502,9 +580,29 @@ export function legacyClassifyCompletion( const visibleSubcommands = legacyFlattenSubcommands(finalCommand).filter((sub) => !sub.hidden); if (visibleSubcommands.length > 0) { directive = LegacyCompletionDirective.NoFileComp; - for (const sub of visibleSubcommands) { - if (sub.name.startsWith(toComplete)) { - candidates.push({ name: sub.name, description: sub.shortDescription ?? sub.description }); + const subcommandCandidates: Array = visibleSubcommands.map( + (sub) => ({ name: sub.name, description: sub.shortDescription ?? sub.description }), + ); + // Cobra's `InitDefaultHelpCmd` (`command.go:1100,1263-1266`) auto-registers a + // `help` subcommand on whichever command `ExecuteC()` is called against — + // here, always the root — but never recursively on descendants (verified + // empirically against a real `apps/cli-go` build: `__complete db ""` does + // NOT surface it, only `__complete ""` does). This TS tree has no explicit + // `help` command node to walk, so synthesize the one candidate cobra would + // otherwise contribute, matching its literal `Short` text. + if (isAtRoot) { + subcommandCandidates.push({ name: "help", description: "Help about any command" }); + } + // Cobra's own `Commands()` — what its subcommand-name completion walks — + // sorts alphabetically by name whenever `EnableCommandSorting` (the + // default) is on. This tree's subcommand declarations already happen to + // be listed alphabetically, so this sort is a no-op everywhere except at + // the root, where it places the synthetic "help" entry above in its + // correct alphabetical position. + subcommandCandidates.sort((a, b) => a.name.localeCompare(b.name)); + for (const candidate of subcommandCandidates) { + if (candidate.name.startsWith(toComplete)) { + candidates.push(candidate); } } } From 360cce7fe5a65ae4fdef1fbd1a177bd5a4ccc8b9 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 15:14:57 +0100 Subject: [PATCH 03/14] fix(cli): wrap native completion script commands with legacy telemetry (review) Go's completion {bash,zsh,fish,powershell} run through cobra's normal Execute() path (unlike __complete/__completeNoDesc, which bypass it), so cli_command_executed fires for them today - there is no completion-specific telemetry exemption in apps/cli-go/cmd/root.go. Since CLI-1965 replaced the Go-proxy handler with a native one, that event stopped firing. Pipe all four leaves through withLegacyCommandInstrumentation({ flags }) + withJsonErrorHandling, matching every other native legacy command, and provide commandRuntimeLayer(["completion", ""]) the same way telemetry/enable, telemetry/status, and init do for commands with no Management API runtime, so the recorded `command` telemetry property reflects the real resolved path. Adds an integration test per shell asserting cli_command_executed is actually captured, and updates the "--no-descriptions from real argv" test's layer to satisfy the wrapper's Analytics/ProcessControl/Stdio requirements. --- .../commands/completion/bash/bash.command.ts | 11 ++++- .../completion/bash/bash.integration.test.ts | 41 +++++++++++++++++-- .../commands/completion/fish/fish.command.ts | 11 ++++- .../completion/fish/fish.integration.test.ts | 41 +++++++++++++++++-- .../powershell/powershell.command.ts | 11 ++++- .../powershell/powershell.integration.test.ts | 41 +++++++++++++++++-- .../commands/completion/zsh/zsh.command.ts | 11 ++++- .../completion/zsh/zsh.integration.test.ts | 41 +++++++++++++++++-- 8 files changed, 192 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/legacy/commands/completion/bash/bash.command.ts b/apps/cli/src/legacy/commands/completion/bash/bash.command.ts index 99e181c88c..b14d7a14be 100644 --- a/apps/cli/src/legacy/commands/completion/bash/bash.command.ts +++ b/apps/cli/src/legacy/commands/completion/bash/bash.command.ts @@ -1,5 +1,8 @@ import { Command } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { LegacyCompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { legacyCompletionBash } from "./bash.handler.ts"; @@ -23,5 +26,11 @@ export const legacyCompletionBashCommand = Command.make("bash", config).pipe( "You will need to start a new shell for this setup to take effect.", ), Command.withShortDescription("Generate the autocompletion script for bash"), - Command.withHandler((flags) => legacyCompletionBash(flags)), + Command.withHandler((flags) => + legacyCompletionBash(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(commandRuntimeLayer(["completion", "bash"])), ); diff --git a/apps/cli/src/legacy/commands/completion/bash/bash.integration.test.ts b/apps/cli/src/legacy/commands/completion/bash/bash.integration.test.ts index 1c8c7dd9e1..243f153be2 100644 --- a/apps/cli/src/legacy/commands/completion/bash/bash.integration.test.ts +++ b/apps/cli/src/legacy/commands/completion/bash/bash.integration.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Layer } from "effect"; import { Command } from "effect/unstable/cli"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { processControlLayer } from "../../../../shared/runtime/process-control.layer.ts"; +import { EventCommandExecuted } from "../../../../shared/telemetry/event-catalog.ts"; import { legacyCompletionBashCommand } from "./bash.command.ts"; import { legacyCompletionBash } from "./bash.handler.ts"; @@ -39,13 +42,45 @@ describe("legacy completion bash", () => { "accepts --no-descriptions from real argv via the command parser and still prints the no-desc script", () => { const out = setupLegacyCompletionBash(); + // Running through the real command (rather than calling the handler + // directly, as the two tests above do) also runs + // `withLegacyCommandInstrumentation` (CLI-1965 review finding: telemetry + // parity with the Go CLI's `cli_command_executed` event), which needs + // `Analytics`/`ProcessControl`/`Stdio` alongside `Output` — the same + // minimal layer set `telemetry.integration.test.ts` uses for its own + // local-only (no Management API) native command. + const layer = Layer.mergeAll( + out.layer, + mockAnalytics().layer, + BunServices.layer, + processControlLayer, + ); return Effect.gen(function* () { yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ "bash", "--no-descriptions", ]); expect(out.stdoutText).toContain("__completeNoDesc"); - }).pipe(Effect.provide(out.layer)) as Effect.Effect; + }).pipe(Effect.provide(layer)) as Effect.Effect; + }, + ); + + it.live( + "fires the cli_command_executed telemetry event, matching Go's PersistentPostRun (CLI-1965 review finding)", + () => { + const out = setupLegacyCompletionBash(); + const analytics = mockAnalytics(); + const layer = Layer.mergeAll( + out.layer, + analytics.layer, + BunServices.layer, + processControlLayer, + ); + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["bash"]); + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event).toBeDefined(); + }).pipe(Effect.provide(layer)) as Effect.Effect; }, ); }); diff --git a/apps/cli/src/legacy/commands/completion/fish/fish.command.ts b/apps/cli/src/legacy/commands/completion/fish/fish.command.ts index a38838172f..e4332845ff 100644 --- a/apps/cli/src/legacy/commands/completion/fish/fish.command.ts +++ b/apps/cli/src/legacy/commands/completion/fish/fish.command.ts @@ -1,5 +1,8 @@ import { Command } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { LegacyCompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { legacyCompletionFish } from "./fish.handler.ts"; @@ -18,5 +21,11 @@ export const legacyCompletionFishCommand = Command.make("fish", config).pipe( "You will need to start a new shell for this setup to take effect.", ), Command.withShortDescription("Generate the autocompletion script for fish"), - Command.withHandler((flags) => legacyCompletionFish(flags)), + Command.withHandler((flags) => + legacyCompletionFish(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(commandRuntimeLayer(["completion", "fish"])), ); diff --git a/apps/cli/src/legacy/commands/completion/fish/fish.integration.test.ts b/apps/cli/src/legacy/commands/completion/fish/fish.integration.test.ts index 786278d06b..a6c5b970a7 100644 --- a/apps/cli/src/legacy/commands/completion/fish/fish.integration.test.ts +++ b/apps/cli/src/legacy/commands/completion/fish/fish.integration.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Layer } from "effect"; import { Command } from "effect/unstable/cli"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { processControlLayer } from "../../../../shared/runtime/process-control.layer.ts"; +import { EventCommandExecuted } from "../../../../shared/telemetry/event-catalog.ts"; import { legacyCompletionFishCommand } from "./fish.command.ts"; import { legacyCompletionFish } from "./fish.handler.ts"; @@ -39,13 +42,45 @@ describe("legacy completion fish", () => { "accepts --no-descriptions from real argv via the command parser and still prints the no-desc script", () => { const out = setupLegacyCompletionFish(); + // Running through the real command (rather than calling the handler + // directly, as the two tests above do) also runs + // `withLegacyCommandInstrumentation` (CLI-1965 review finding: telemetry + // parity with the Go CLI's `cli_command_executed` event), which needs + // `Analytics`/`ProcessControl`/`Stdio` alongside `Output` — the same + // minimal layer set `telemetry.integration.test.ts` uses for its own + // local-only (no Management API) native command. + const layer = Layer.mergeAll( + out.layer, + mockAnalytics().layer, + BunServices.layer, + processControlLayer, + ); return Effect.gen(function* () { yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ "fish", "--no-descriptions", ]); expect(out.stdoutText).toContain("__completeNoDesc"); - }).pipe(Effect.provide(out.layer)) as Effect.Effect; + }).pipe(Effect.provide(layer)) as Effect.Effect; + }, + ); + + it.live( + "fires the cli_command_executed telemetry event, matching Go's PersistentPostRun (CLI-1965 review finding)", + () => { + const out = setupLegacyCompletionFish(); + const analytics = mockAnalytics(); + const layer = Layer.mergeAll( + out.layer, + analytics.layer, + BunServices.layer, + processControlLayer, + ); + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["fish"]); + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event).toBeDefined(); + }).pipe(Effect.provide(layer)) as Effect.Effect; }, ); }); diff --git a/apps/cli/src/legacy/commands/completion/powershell/powershell.command.ts b/apps/cli/src/legacy/commands/completion/powershell/powershell.command.ts index c4bae7b26d..704f831cf7 100644 --- a/apps/cli/src/legacy/commands/completion/powershell/powershell.command.ts +++ b/apps/cli/src/legacy/commands/completion/powershell/powershell.command.ts @@ -1,5 +1,8 @@ import { Command } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { LegacyCompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { legacyCompletionPowershell } from "./powershell.handler.ts"; @@ -17,5 +20,11 @@ export const legacyCompletionPowershellCommand = Command.make("powershell", conf "to your powershell profile.", ), Command.withShortDescription("Generate the autocompletion script for powershell"), - Command.withHandler((flags) => legacyCompletionPowershell(flags)), + Command.withHandler((flags) => + legacyCompletionPowershell(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(commandRuntimeLayer(["completion", "powershell"])), ); diff --git a/apps/cli/src/legacy/commands/completion/powershell/powershell.integration.test.ts b/apps/cli/src/legacy/commands/completion/powershell/powershell.integration.test.ts index 5a55b8e657..a59916e072 100644 --- a/apps/cli/src/legacy/commands/completion/powershell/powershell.integration.test.ts +++ b/apps/cli/src/legacy/commands/completion/powershell/powershell.integration.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Layer } from "effect"; import { Command } from "effect/unstable/cli"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { processControlLayer } from "../../../../shared/runtime/process-control.layer.ts"; +import { EventCommandExecuted } from "../../../../shared/telemetry/event-catalog.ts"; import { legacyCompletionPowershellCommand } from "./powershell.command.ts"; import { legacyCompletionPowershell } from "./powershell.handler.ts"; @@ -41,13 +44,45 @@ describe("legacy completion powershell", () => { "accepts --no-descriptions from real argv via the command parser and still prints the no-desc script", () => { const out = setupLegacyCompletionPowershell(); + // Running through the real command (rather than calling the handler + // directly, as the two tests above do) also runs + // `withLegacyCommandInstrumentation` (CLI-1965 review finding: telemetry + // parity with the Go CLI's `cli_command_executed` event), which needs + // `Analytics`/`ProcessControl`/`Stdio` alongside `Output` — the same + // minimal layer set `telemetry.integration.test.ts` uses for its own + // local-only (no Management API) native command. + const layer = Layer.mergeAll( + out.layer, + mockAnalytics().layer, + BunServices.layer, + processControlLayer, + ); return Effect.gen(function* () { yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ "powershell", "--no-descriptions", ]); expect(out.stdoutText).toContain("__completeNoDesc"); - }).pipe(Effect.provide(out.layer)) as Effect.Effect; + }).pipe(Effect.provide(layer)) as Effect.Effect; + }, + ); + + it.live( + "fires the cli_command_executed telemetry event, matching Go's PersistentPostRun (CLI-1965 review finding)", + () => { + const out = setupLegacyCompletionPowershell(); + const analytics = mockAnalytics(); + const layer = Layer.mergeAll( + out.layer, + analytics.layer, + BunServices.layer, + processControlLayer, + ); + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["powershell"]); + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event).toBeDefined(); + }).pipe(Effect.provide(layer)) as Effect.Effect; }, ); }); diff --git a/apps/cli/src/legacy/commands/completion/zsh/zsh.command.ts b/apps/cli/src/legacy/commands/completion/zsh/zsh.command.ts index 1d0ac35c33..4f380dd4f5 100644 --- a/apps/cli/src/legacy/commands/completion/zsh/zsh.command.ts +++ b/apps/cli/src/legacy/commands/completion/zsh/zsh.command.ts @@ -1,5 +1,8 @@ import { Command } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { LegacyCompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { legacyCompletionZsh } from "./zsh.handler.ts"; @@ -24,5 +27,11 @@ export const legacyCompletionZshCommand = Command.make("zsh", config).pipe( "You will need to start a new shell for this setup to take effect.", ), Command.withShortDescription("Generate the autocompletion script for zsh"), - Command.withHandler((flags) => legacyCompletionZsh(flags)), + Command.withHandler((flags) => + legacyCompletionZsh(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(commandRuntimeLayer(["completion", "zsh"])), ); diff --git a/apps/cli/src/legacy/commands/completion/zsh/zsh.integration.test.ts b/apps/cli/src/legacy/commands/completion/zsh/zsh.integration.test.ts index 9e170775fd..02c6596c1c 100644 --- a/apps/cli/src/legacy/commands/completion/zsh/zsh.integration.test.ts +++ b/apps/cli/src/legacy/commands/completion/zsh/zsh.integration.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Layer } from "effect"; import { Command } from "effect/unstable/cli"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { processControlLayer } from "../../../../shared/runtime/process-control.layer.ts"; +import { EventCommandExecuted } from "../../../../shared/telemetry/event-catalog.ts"; import { legacyCompletionZshCommand } from "./zsh.command.ts"; import { legacyCompletionZsh } from "./zsh.handler.ts"; @@ -39,13 +42,45 @@ describe("legacy completion zsh", () => { "accepts --no-descriptions from real argv via the command parser and still prints the no-desc script", () => { const out = setupLegacyCompletionZsh(); + // Running through the real command (rather than calling the handler + // directly, as the two tests above do) also runs + // `withLegacyCommandInstrumentation` (CLI-1965 review finding: telemetry + // parity with the Go CLI's `cli_command_executed` event), which needs + // `Analytics`/`ProcessControl`/`Stdio` alongside `Output` — the same + // minimal layer set `telemetry.integration.test.ts` uses for its own + // local-only (no Management API) native command. + const layer = Layer.mergeAll( + out.layer, + mockAnalytics().layer, + BunServices.layer, + processControlLayer, + ); return Effect.gen(function* () { yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ "zsh", "--no-descriptions", ]); expect(out.stdoutText).toContain("__completeNoDesc"); - }).pipe(Effect.provide(out.layer)) as Effect.Effect; + }).pipe(Effect.provide(layer)) as Effect.Effect; + }, + ); + + it.live( + "fires the cli_command_executed telemetry event, matching Go's PersistentPostRun (CLI-1965 review finding)", + () => { + const out = setupLegacyCompletionZsh(); + const analytics = mockAnalytics(); + const layer = Layer.mergeAll( + out.layer, + analytics.layer, + BunServices.layer, + processControlLayer, + ); + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["zsh"]); + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event).toBeDefined(); + }).pipe(Effect.provide(layer)) as Effect.Effect; }, ); }); From d0024c9e6682251cb77a61e6ee544a20cbaea3b0 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 17:03:15 +0100 Subject: [PATCH 04/14] fix(cli): match Go's shell-completion flag-value/cluster/sentinel/pre-command parsing (review) Four Go-parity gaps in the native __complete responder, each verified against a real apps/cli-go build: - Validate flag values (not just names) before completing, matching pflag's typed Value.Set failing ParseFlags for e.g. `-o not-a-format` or `--debug=maybe` (zero candidates, Default directive). - Stop command-path descent at a bare `--` end-of-flags sentinel instead of still matching subcommands after it. - Walk every shorthand in a boolean flag cluster (e.g. `-rj`) as changed, not just the last character, so `--recursive` isn't re-offered after `-rj 2`. - Consume a value-taking flag's value during path descent even when the flag isn't yet in scope (e.g. `--db-url` typed before `db dump`), mirroring cobra's stripFlags optimistically assuming an unknown flag takes a value. review: PRRT_kwDOErm0O86Wsq4H, PRRT_kwDOErm0O86Wsq4K, PRRT_kwDOErm0O86Wsq4P, PRRT_kwDOErm0O86Wsq4X --- apps/cli/src/legacy/cli/legacy-complete.ts | 196 ++++++++++++++---- .../legacy/cli/legacy-complete.unit.test.ts | 2 + 2 files changed, 163 insertions(+), 35 deletions(-) diff --git a/apps/cli/src/legacy/cli/legacy-complete.ts b/apps/cli/src/legacy/cli/legacy-complete.ts index eda40ce01a..b1cbc344f9 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.ts @@ -1,6 +1,6 @@ import { Option } from "effect"; import { GlobalFlag } from "effect/unstable/cli"; -import type { Command, Param } from "effect/unstable/cli"; +import type { Command, Param, Primitive } from "effect/unstable/cli"; import process from "node:process"; import { legacyUnwrapParam } from "../shared/legacy-param-introspection.ts"; @@ -72,6 +72,10 @@ export interface LegacyFlagDescriptor { readonly description: string | undefined; readonly isVariadic: boolean; readonly isBoolean: boolean; + /** `Param.Single`'s underlying `Primitive._tag` (`"Boolean"`, `"Choice"`, `"Integer"`, ...). */ + readonly primitiveTag: string; + /** The valid value set for a `primitiveTag === "Choice"` flag; `undefined` for every other tag. */ + readonly choiceKeys: ReadonlyArray | undefined; } export interface LegacyCommandPathResolution { @@ -152,6 +156,32 @@ function legacyFlattenSubcommands( /* Flag descriptors */ /* ========================================================================== */ +/** + * `Flag.choice`/`Flag.choiceWithValue`'s `choiceKeys` (the valid value set) is + * attached to the `Choice`-tagged `Primitive` via `Object.assign` at + * runtime (`Primitive.choice`, + * `.repos/effect/packages/effect/src/unstable/cli/Primitive.ts`) but carries + * an `@internal` JSDoc tag and is absent from the public `Primitive` + * interface — the identical gap `LegacyCommandInternal` above already works + * around for `Command`, so this reuses the same runtime type-guard idiom + * instead of an `as` cast. + */ +interface LegacyChoicePrimitive { + readonly choiceKeys: ReadonlyArray; +} + +function legacyHasChoiceKeys( + primitive: Primitive.Primitive, +): primitive is Primitive.Primitive & LegacyChoicePrimitive { + return "choiceKeys" in primitive; +} + +function legacyChoiceKeysOf( + primitive: Primitive.Primitive, +): ReadonlyArray | undefined { + return legacyHasChoiceKeys(primitive) ? primitive.choiceKeys : undefined; +} + function legacyFlagDescriptorFromParam(param: Param.AnyFlag): LegacyFlagDescriptor | undefined { const unwrapped = legacyUnwrapParam(param); if (unwrapped === undefined) return undefined; @@ -163,6 +193,8 @@ function legacyFlagDescriptorFromParam(param: Param.AnyFlag): LegacyFlagDescript description: Option.getOrUndefined(single.description), isVariadic, isBoolean: single.primitiveType._tag === "Boolean", + primitiveTag: single.primitiveType._tag, + choiceKeys: legacyChoiceKeysOf(single.primitiveType), }; } @@ -248,16 +280,38 @@ function legacyResolveFlagFromToken( /** * Descends from `root` through `trimmedArgs`, matching each non-flag token * against the current command's subcommand names/aliases (exact, - * case-sensitive — no prefix or fuzzy matching). A flag-shaped token — and, - * when it's a non-boolean flag with no embedded `=`, the single token - * immediately following it as its value — is skipped without stopping the - * descent, mirroring cobra's `Find()`, which strips flags before matching - * positional command names (`completions.go:340`). Descent stops at the first - * non-flag token that doesn't match a subcommand; that token and everything - * after it becomes `leftoverArgs` — the *positional* leftover cobra's - * `finalArgs` represents (`completions.go:397-399`), used to gate - * subcommand-name completion (`len(finalArgs) == 0`). Flag tokens and their - * consumed values are never part of `leftoverArgs`. + * case-sensitive — no prefix or fuzzy matching). Mirrors cobra's `Find()`, + * which strips flags before matching positional command names + * (`completions.go:340`) via its own heuristic `stripFlags` + * (`pflag@v1.0.9/flag.go`) — a cruder, command-tree-only pre-pass distinct + * from the real flag parser `legacyChangedFlagNames` mirrors: + * + * - A long flag (`--foo`) or a single-character short flag (`-f`) with no + * embedded `=` consumes the following token as its value UNLESS it's + * already known at this point in the descent to be boolean — this + * includes flags not yet in scope, e.g. a subcommand's own local flag + * typed before that subcommand is reached (`--db-url`, local to `db + * dump`, typed before `db`): `stripFlags`'s `hasNoOptDefVal` returns + * `false` for a name it can't find yet, so `!hasNoOptDefVal(...)` is + * `true` and it optimistically consumes a value anyway (verified + * empirically against a real `apps/cli-go` build: `__complete --db-url + * postgres:// db dump --s` still offers `db dump`'s `--schema`, which + * requires descending past `--db-url postgres://` to reach `db dump` at + * all). + * - Anything else flag-shaped — a multi-character shorthand cluster + * (`-rj`), a flag containing `=`, or a bare `--` — is skipped without + * consuming a value. A bare `--` additionally stops the descent + * entirely: it's pflag's end-of-flags sentinel, so no token at or after + * it can ever match a subcommand (verified empirically: `__complete -- + * db ""` returns zero candidates with the Default directive, not `db`'s + * subcommands). + * + * Descent stops at the first non-flag token that doesn't match a subcommand, + * or at a `--` sentinel; that token and everything after it becomes + * `leftoverArgs` — the *positional* leftover cobra's `finalArgs` represents + * (`completions.go:397-399`), used to gate subcommand-name completion + * (`len(finalArgs) == 0`). Flag tokens and their consumed values are never + * part of `leftoverArgs`. */ export function legacyResolveCommandPath( root: Command.Command.Any, @@ -276,14 +330,22 @@ export function legacyResolveCommandPath( continue; } + if (token === "--") break; // pflag's end-of-flags sentinel: nothing at or after this can match a subcommand. + if (token.startsWith("-")) { consumedIndices.add(index); - if (!token.includes("=")) { + const isLong = token.startsWith("--"); + const isSingleCharShort = !isLong && token.length === 2; + if (!token.includes("=") && (isLong || isSingleCharShort)) { // The flags visible at this point of the descent are enough to tell // whether this token consumes the next one as its value. const inScopeSoFar = legacyCollectInScopeFlags(root, commandChain); const resolved = legacyResolveFlagFromToken(token, inScopeSoFar); - if (resolved !== undefined && !resolved.isBoolean && index + 1 < trimmedArgs.length) { + // An unrecognized flag is optimistically assumed to take a value too + // (see the doc comment above) — only a flag already known here to be + // boolean is exempt. + const takesValue = resolved === undefined || !resolved.isBoolean; + if (takesValue && index + 1 < trimmedArgs.length) { consumedIndices.add(index + 1); index += 2; continue; @@ -391,8 +453,20 @@ function legacyFlagNameCandidates( /** * A lightweight, string-only approximation of "which in-scope flags have - * already been provided" (not a real flag parser) — correct for the - * overwhelming majority of real completion inputs. + * already been provided" (not a real flag parser, but close enough to mirror + * pflag's actual `Set`-time behavior for the shapes real completion input + * takes) — correct for the overwhelming majority of real completion inputs. + * + * A short-flag token walks its shorthand cluster exactly like + * `pflag@v1.0.9`'s `parseSingleShortArg`: each character that resolves to a + * boolean (`NoOptDefVal != ""`) flag is marked changed and the walk continues + * to the next character in the SAME token; the first non-boolean character + * (or a `=value` suffix) is also marked changed but ends the walk there, + * since the rest of the token (or the next arg) is that flag's value, not + * another shorthand (verified empirically against a real `apps/cli-go` + * build: after `storage cp -rj 2`, both `-r`/`--recursive` and `-j`/`--jobs` + * are "changed" — `--r` offers nothing further — whereas this function + * used to record only the cluster's last character). */ function legacyChangedFlagNames( trimmedArgs: ReadonlyArray, @@ -408,41 +482,86 @@ function legacyChangedFlagNames( continue; } if (token.startsWith("-") && token !== "-") { - const equalsIndex = token.indexOf("="); - const shorthand = - equalsIndex === -1 ? token.charAt(token.length - 1) : token.charAt(equalsIndex - 1); - const owner = inScopeFlags.find((flag) => flag.aliases.includes(shorthand)); - if (owner !== undefined) changed.add(owner.name); + legacyMarkChangedShorthandCluster(token, inScopeFlags, changed); } } return changed; } /** - * Finds the first token in `trimmedArgs` that looks like a flag (starts with - * `-`, excluding the bare `-` positional pflag itself treats as a non-flag - * argument) but does not resolve to anything in `inScopeFlags`, consuming a - * following token as a non-boolean flag's value the same way + * Walks a short-flag token's shorthand cluster (e.g. `-rj`, `-o=json`), + * marking every shorthand consumed before — and including — the + * value-consuming one as changed. See `legacyChangedFlagNames`'s doc comment + * for the pflag behavior this mirrors. + */ +function legacyMarkChangedShorthandCluster( + token: string, + inScopeFlags: ReadonlyArray, + changed: Set, +): void { + let shorthands = token.slice(1); + while (shorthands.length > 0) { + const owner = inScopeFlags.find((flag) => flag.aliases.includes(shorthands.charAt(0))); + if (owner === undefined) return; // unresolved shorthand — defensive stop, already filtered upstream. + changed.add(owner.name); + if (shorthands.length > 1 && shorthands.charAt(1) === "=") return; // "-f=value": cluster ends at the explicit value. + if (!owner.isBoolean) return; // non-boolean: the rest of the token (or the next arg) is its value. + shorthands = shorthands.slice(1); // boolean shorthand consumed no value — keep walking the cluster. + } +} + +/** + * Validates a flag's value the way pflag's typed `Value.Set` does inside + * `finalCmd.ParseFlags()` — e.g. `-o not-a-format` (a `Choice`-typed + * `--output`) or `--debug=maybe` (a `Boolean`-typed `--debug`) fail to parse + * in real pflag, and cobra reports the parse error instead of generating any + * completions (verified empirically against a real `apps/cli-go` build: both + * return zero candidates with the Default directive, exactly like an + * unresolved flag name). Only the primitive shapes pflag can actually reject + * are checked; `String`/`Path`/`Date`/etc. flags accept any string in Go too, + * so every other tag is unconditionally valid here. + */ +function legacyIsValidFlagValue(flag: LegacyFlagDescriptor, value: string): boolean { + switch (flag.primitiveTag) { + case "Boolean": + return legacyParseGoBool(value) !== undefined; + case "Choice": + return flag.choiceKeys !== undefined && flag.choiceKeys.includes(value); + case "Integer": + return /^[+-]?\d+$/.test(value); + case "Float": + return value.trim().length > 0 && !Number.isNaN(Number(value)); + default: + return true; + } +} + +/** + * Finds the first token in `trimmedArgs` that either (a) looks like a flag + * (starts with `-`, excluding the bare `-` positional pflag itself treats as + * a non-flag argument) but does not resolve to anything in `inScopeFlags`, or + * (b) resolves to a real flag whose value `legacyIsValidFlagValue` rejects. + * Consumes a following token as a non-boolean flag's value the same way * `legacyResolveCommandPath` does. A bare `--` ends the scan entirely without * itself counting as unresolved — pflag's own end-of-flags sentinel, after * which everything is positional, not a flag to validate (`pflag@v1.0.9`'s * `parseArgs`: `if s[1] == '-' { if len(s) == 2 { ... terminates the flags`). * Returns the offending token, or `undefined` if every flag-shaped token - * resolves. + * resolves to a real flag with a valid value. * * Mirrors cobra's real two-phase design: `Find()` tolerantly skips flags it * doesn't recognize while walking for a subcommand name (see * `legacyResolveCommandPath`, which only needs to know "does this consume a * value", not "is this real"), but the later `finalCmd.ParseFlags()` strictly - * validates every remaining flag token against the fully-resolved command's - * complete flag set and fails outright on the first one it can't recognize - * (`completions.go:373-375`) — a failure so early it wins even over the - * `--help`/`--version` short-circuit below (verified empirically against a - * real `apps/cli-go` build: both `__complete --bogus --help ""` and - * `__complete --help --bogus ""` report the unknown flag, not help; a bare - * `__complete --bogus ""` returns zero candidates with the Default directive, - * not the root subcommand list; `__complete -- ""` is unaffected and still - * lists every root subcommand). + * validates every remaining flag token — both that it resolves AND that its + * value parses — against the fully-resolved command's complete flag set, and + * fails outright on the first one that doesn't (`completions.go:373-375`) — a + * failure so early it wins even over the `--help`/`--version` short-circuit + * below (verified empirically against a real `apps/cli-go` build: both + * `__complete --bogus --help ""` and `__complete --help --bogus ""` report + * the unknown flag, not help; a bare `__complete --bogus ""` returns zero + * candidates with the Default directive, not the root subcommand list; + * `__complete -- ""` is unaffected and still lists every root subcommand). */ function legacyFindUnresolvedFlagToken( trimmedArgs: ReadonlyArray, @@ -460,8 +579,15 @@ function legacyFindUnresolvedFlagToken( const resolved = legacyResolveFlagFromToken(bareToken, inScopeFlags); if (resolved === undefined) return token; - if (equalsIndex === -1 && !resolved.isBoolean && index < trimmedArgs.length) { + if (equalsIndex !== -1) { + if (!legacyIsValidFlagValue(resolved, token.slice(equalsIndex + 1))) return token; + continue; + } + + if (!resolved.isBoolean && index < trimmedArgs.length) { + const value = trimmedArgs[index]; index++; // skip the consumed value token + if (value !== undefined && !legacyIsValidFlagValue(resolved, value)) return value; } } return undefined; diff --git a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts index 59690a7c9d..13b5cb5858 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts @@ -419,6 +419,8 @@ describe("legacyCollectInScopeFlags", () => { description: "Output debug logs to stderr.", isVariadic: false, isBoolean: true, + primitiveTag: "Boolean", + choiceKeys: undefined, }); }); From 0cf5e840f1bea15ab17694a7b125e825fffdd20d Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 18:50:15 +0100 Subject: [PATCH 05/14] fix(cli): match Go's completion terminator, attached-shorthand, and boolean=value parsing (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six new Codex findings from the latest __complete review round, each confirmed against a real apps/cli-go build (rebuilt with the release version ldflag for faithful --version behaviour) and fixed: - A bare `--` sentinel now disables flag-name/flag-value completion entirely (Cases 1/2), matching cobra's flagCompletion gate, while legacyChangedFlagNames also stops scanning at `--` and correctly skips a long flag's consumed value token so a required flag after the sentinel is still offered. - legacyFindUnresolvedFlagToken now resolves short-flag clusters via a new legacyResolveShortFlagCluster helper that walks pflag's real first-character-owns-the-value semantics (`-j4`, `-ojson`), instead of reusing legacyResolveFlagFromToken's last-character cobra heuristic, which remains correct for its other two use sites. - A trailing value-taking flag with no value at all is only a hard parse error when toComplete is itself flag-shaped (mirroring cobra's checkIfFlagCompletion rescue condition); otherwise it still falls through to flag-VALUE completion. The Case 2 "preceding token" branch now also hard-stops (instead of silently falling through) when that token is unresolved under cobra's own last-character heuristic, which is what real cobra does for inputs like `-ojson ""`. - A boolean flag with an explicit `=` (`--debug=maybe`) is flag-VALUE completion, not noun completion — cobra only resets to noun completion in the no-`=` two-token case. - The five completion leaves (`completion`, `completion {bash,zsh,fish,powershell}`) now force the NoFileComp directive, mirroring cobra's ValidArgsFunction: NoFileCompletions registration, which getCompletions always applies last and unconditionally. The one earlier "shared flags not visible" finding on this same file is a separate, already-adjudicated rejection and is left as-is. --- apps/cli/src/legacy/cli/legacy-complete.ts | 288 +++++++++++++++--- .../legacy/cli/legacy-complete.unit.test.ts | 134 ++++++++ 2 files changed, 382 insertions(+), 40 deletions(-) diff --git a/apps/cli/src/legacy/cli/legacy-complete.ts b/apps/cli/src/legacy/cli/legacy-complete.ts index b1cbc344f9..bf33434958 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.ts @@ -256,7 +256,15 @@ export function legacyCollectInScopeFlags( /** * Resolves a bare flag token (`--project-ref`, `-p`, or a shorthand cluster * like `-po`, where cobra's rule is "the character immediately before the - * value/`=`", i.e. the last character) to its owning in-scope flag. + * value/`=`", i.e. the last character) to its owning in-scope flag. Mirrors + * cobra's `checkIfFlagCompletion` heuristic (`completions.go:676-681,702-707`, + * the documented `-asd` => `d` quirk from cobra issue #1257) for guessing + * which flag the CURRENT or immediately PRECEDING token is mid-way through + * value-completing — deliberately NOT the same algorithm as + * `legacyResolveShortFlagCluster`, which mirrors the real, strict + * `ParseFlags()` parser instead (first character owns the value, not last). + * See that function's doc comment for why the two differ and where each is + * used. */ function legacyResolveFlagFromToken( token: string, @@ -435,6 +443,29 @@ function legacyIsRequiredCompletionFlag( return LEGACY_COMPLETION_REQUIRED_FLAGS.has(`${matchedPath.join(" ")}:${flagName}`); } +/** + * Mirrors cobra's `InitDefaultCompletionCmd` (`completions.go:769-928`), + * which registers `ValidArgsFunction: NoFileCompletions` on the `completion` + * group command itself and each of its `bash`/`zsh`/`fish`/`powershell` + * leaves — the only `ValidArgsFunction`/`ValidArgs` usage anywhere relevant + * to this tree (`apps/cli-go/cmd/`, `apps/cli-go/internal/` register none of + * their own). `getCompletions` always calls a resolved command's own + * `ValidArgsFunction` when one is registered, and that call OVERWRITES the + * directive outright (`completions.go:564-579`) — for a leaf like + * `completion bash`, which has no subcommands of its own to otherwise set + * NoFileComp, this is the ONLY thing that sets it (verified empirically + * against a real `apps/cli-go` build: `completion bash ""` returns the + * NoFileComp directive with zero candidates, not Default — CLI-1965 review + * finding). Key = space-joined `matchedPath` (excluding "supabase"). + */ +const LEGACY_COMPLETION_NO_FILE_COMP_PATHS: ReadonlySet = new Set([ + "completion", + "completion bash", + "completion zsh", + "completion fish", + "completion powershell", +]); + function legacyFlagNameCandidates( flag: LegacyFlagDescriptor, toComplete: string, @@ -457,8 +488,24 @@ function legacyFlagNameCandidates( * pflag's actual `Set`-time behavior for the shapes real completion input * takes) — correct for the overwhelming majority of real completion inputs. * + * Stops at a bare `--` the same way `legacyFindUnresolvedFlagToken` and + * `legacyResolveCommandPath` do — pflag's end-of-flags sentinel means + * nothing at or after it is ever parsed as a flag, so nothing past it can be + * "changed" (verified empirically against a real `apps/cli-go` build: `sso + * add -- --type --typ` still offers `--type`, since that token is + * positional, past the terminator, and never reaches pflag's flag parser at + * all — CLI-1965 review finding). + * + * A LONG flag with no `=` that resolves to a non-boolean in-scope flag + * consumes the immediately following token as its value — that token is + * skipped here entirely, exactly like pflag's `parseLongArg` + * (`pflag@v1.0.10/flag.go:1013-1023`), so a value that happens to look like a + * flag (e.g. `--domains --type foo`, where `--type` is `--domains`'s value) + * is never itself marked changed (CLI-1965 review finding, verified + * empirically against a real `apps/cli-go` build). + * * A short-flag token walks its shorthand cluster exactly like - * `pflag@v1.0.9`'s `parseSingleShortArg`: each character that resolves to a + * `pflag@v1.0.10`'s `parseSingleShortArg`: each character that resolves to a * boolean (`NoOptDefVal != ""`) flag is marked changed and the walk continues * to the next character in the SAME token; the first non-boolean character * (or a `=value` suffix) is also marked changed but ends the walk there, @@ -473,16 +520,27 @@ function legacyChangedFlagNames( inScopeFlags: ReadonlyArray, ): ReadonlySet { const changed = new Set(); - for (const token of trimmedArgs) { + let index = 0; + while (index < trimmedArgs.length) { + const token = trimmedArgs[index]; + index++; + if (token === undefined) continue; + if (token === "--") break; // pflag's end-of-flags sentinel: nothing at or after this is parsed as a flag. + if (token.startsWith("--")) { const rest = token.slice(2); const equalsIndex = rest.indexOf("="); const name = equalsIndex === -1 ? rest : rest.slice(0, equalsIndex); if (name.length > 0) changed.add(name); + if (equalsIndex === -1 && index < trimmedArgs.length) { + const owner = inScopeFlags.find((flag) => flag.name === name); + if (owner !== undefined && !owner.isBoolean) index++; // consumes the next token as its value. + } continue; } if (token.startsWith("-") && token !== "-") { - legacyMarkChangedShorthandCluster(token, inScopeFlags, changed); + const consumesNextToken = legacyMarkChangedShorthandCluster(token, inScopeFlags, changed); + if (consumesNextToken && index < trimmedArgs.length) index++; } } return changed; @@ -491,23 +549,28 @@ function legacyChangedFlagNames( /** * Walks a short-flag token's shorthand cluster (e.g. `-rj`, `-o=json`), * marking every shorthand consumed before — and including — the - * value-consuming one as changed. See `legacyChangedFlagNames`'s doc comment - * for the pflag behavior this mirrors. + * value-consuming one as changed. Returns `true` when the cluster ends on a + * non-boolean shorthand with no attached value (`-f`, or `-rf` ending on + * `f`) — the caller must then skip the immediately following token, since + * pflag consumes it as that shorthand's value rather than parsing it as its + * own flag. See `legacyChangedFlagNames`'s doc comment for the pflag + * behavior this mirrors. */ function legacyMarkChangedShorthandCluster( token: string, inScopeFlags: ReadonlyArray, changed: Set, -): void { +): boolean { let shorthands = token.slice(1); while (shorthands.length > 0) { const owner = inScopeFlags.find((flag) => flag.aliases.includes(shorthands.charAt(0))); - if (owner === undefined) return; // unresolved shorthand — defensive stop, already filtered upstream. + if (owner === undefined) return false; // unresolved shorthand — defensive stop, already filtered upstream. changed.add(owner.name); - if (shorthands.length > 1 && shorthands.charAt(1) === "=") return; // "-f=value": cluster ends at the explicit value. - if (!owner.isBoolean) return; // non-boolean: the rest of the token (or the next arg) is its value. + if (shorthands.length > 1 && shorthands.charAt(1) === "=") return false; // "-f=value": cluster ends at the explicit value. + if (!owner.isBoolean) return shorthands.length === 1; // non-boolean: the rest of the token (if any) is its value; otherwise the next arg is. shorthands = shorthands.slice(1); // boolean shorthand consumed no value — keep walking the cluster. } + return false; } /** @@ -536,18 +599,90 @@ function legacyIsValidFlagValue(flag: LegacyFlagDescriptor, value: string): bool } } +/** + * Walks a short-flag cluster (`-o`, `-ojson`, `-rj`, `-o=json`) the same way + * pflag's `parseSingleShortArg` does (`pflag@v1.0.10/flag.go:1040-1114`) — + * first character owns the value, not last. This is deliberately a + * DIFFERENT algorithm from `legacyResolveFlagFromToken`'s last-character + * resolution: that function mirrors cobra's OWN separate, narrower + * `checkIfFlagCompletion` heuristic, used only to guess "is the CURRENT or + * PRECEDING token mid-way through being value-completed" — not to strictly + * parse a token that's already fully typed. This function mirrors the real + * strict parser (`finalCmd.ParseFlags()`) instead, used by + * `legacyFindUnresolvedFlagToken` (verified empirically against a real + * `apps/cli-go` build: `functions deploy -j4 --p` still offers + * `--profile`/`--project-ref`/`--prune` — `-j4` is a fully valid, already- + * resolved `--jobs=4`, not an unknown flag — CLI-1965 review finding). + * + * Returns `undefined` if any character in the cluster doesn't resolve to an + * in-scope flag shorthand. Otherwise returns the flag that ultimately owns + * the cluster's (possibly absent) attached value — the first non-boolean + * shorthand encountered, or the cluster's last shorthand if every character + * in it is boolean — plus that attached value, which is `undefined` only + * when there is nothing left in the token to attach (`-o` alone, or an + * all-boolean cluster like `-rf`), meaning a following token supplies it + * instead. + */ +function legacyResolveShortFlagCluster( + token: string, + inScopeFlags: ReadonlyArray, +): { readonly flag: LegacyFlagDescriptor; readonly attachedValue: string | undefined } | undefined { + let shorthands = token.slice(1); + let lastResolved: LegacyFlagDescriptor | undefined; + while (shorthands.length > 0) { + const owner = inScopeFlags.find((flag) => flag.aliases.includes(shorthands.charAt(0))); + if (owner === undefined) return undefined; + lastResolved = owner; + if (shorthands.length > 1 && shorthands.charAt(1) === "=") { + return { flag: owner, attachedValue: shorthands.slice(2) }; + } + if (!owner.isBoolean) { + return { + flag: owner, + attachedValue: shorthands.length > 1 ? shorthands.slice(1) : undefined, + }; + } + shorthands = shorthands.slice(1); // boolean shorthand consumed no value — keep walking the cluster. + } + return lastResolved === undefined ? undefined : { flag: lastResolved, attachedValue: undefined }; +} + /** * Finds the first token in `trimmedArgs` that either (a) looks like a flag * (starts with `-`, excluding the bare `-` positional pflag itself treats as - * a non-flag argument) but does not resolve to anything in `inScopeFlags`, or - * (b) resolves to a real flag whose value `legacyIsValidFlagValue` rejects. + * a non-flag argument) but does not resolve to anything in `inScopeFlags`, + * (b) resolves to a real flag whose value `legacyIsValidFlagValue` rejects, + * or (c) resolves to a real, non-boolean flag with NO value available at all + * — no attached suffix and no following token — AND `toComplete` itself is a + * bare flag-shaped token (starts with `-`, no `=`). + * + * That last condition mirrors a real two-part cobra/pflag interaction: + * cobra's `checkIfFlagCompletion` only rescues a trailing incomplete flag + * from `ParseFlags()` (treating it as "the flag currently being + * value-completed" instead of a parse error) when `toComplete` is EMPTY or + * otherwise not itself flag-shaped (`completions.go:666-687`); when + * `toComplete` IS flag-shaped with no `=`, that rescue never happens and the + * real `finalCmd.ParseFlags()` call (`completions.go:373-375`) fails + * outright on the dangling flag (verified empirically against a real + * `apps/cli-go` build: `__complete -o --d` returns zero candidates with the + * Default directive — Go's `ParseFlags` error is "flag needs an argument: + * 'o' in -o" — while `__complete -o ''` and `__complete -o pre` both instead + * fall through to flag-VALUE completion for `--output`, per + * `legacyClassifyCompletion`'s Case 2 — CLI-1965 review finding). + * + * Long flags (`--foo`, `--foo=bar`) resolve via `legacyResolveFlagFromToken` + * (no first/last-character ambiguity for a `--name` token). Short flags + * resolve via `legacyResolveShortFlagCluster` instead — see that function's + * doc comment for why this deliberately does NOT reuse + * `legacyResolveFlagFromToken`'s last-character heuristic here. + * * Consumes a following token as a non-boolean flag's value the same way * `legacyResolveCommandPath` does. A bare `--` ends the scan entirely without * itself counting as unresolved — pflag's own end-of-flags sentinel, after * which everything is positional, not a flag to validate (`pflag@v1.0.9`'s * `parseArgs`: `if s[1] == '-' { if len(s) == 2 { ... terminates the flags`). * Returns the offending token, or `undefined` if every flag-shaped token - * resolves to a real flag with a valid value. + * resolves to a real flag with a valid, available value. * * Mirrors cobra's real two-phase design: `Find()` tolerantly skips flags it * doesn't recognize while walking for a subcommand name (see @@ -565,8 +700,16 @@ function legacyIsValidFlagValue(flag: LegacyFlagDescriptor, value: string): bool */ function legacyFindUnresolvedFlagToken( trimmedArgs: ReadonlyArray, + toComplete: string, inScopeFlags: ReadonlyArray, ): string | undefined { + // See this function's doc comment: only a `toComplete` that's itself a + // bare flag-shaped token (no `=`) blocks cobra's "rescue" of a trailing, + // value-less flag — every other shape of `toComplete` leaves it for + // flag-VALUE completion instead, so a missing value at the end of + // `trimmedArgs` is not, by itself, unresolved in that case. + const trailingMissingValueIsFatal = toComplete.startsWith("-") && !toComplete.includes("="); + let index = 0; while (index < trimmedArgs.length) { const token = trimmedArgs[index]; @@ -574,21 +717,41 @@ function legacyFindUnresolvedFlagToken( if (token === undefined || token === "-" || !token.startsWith("-")) continue; if (token === "--") break; - const equalsIndex = token.indexOf("="); - const bareToken = equalsIndex === -1 ? token : token.slice(0, equalsIndex); - const resolved = legacyResolveFlagFromToken(bareToken, inScopeFlags); - if (resolved === undefined) return token; - - if (equalsIndex !== -1) { - if (!legacyIsValidFlagValue(resolved, token.slice(equalsIndex + 1))) return token; - continue; - } - - if (!resolved.isBoolean && index < trimmedArgs.length) { + if (token.startsWith("--")) { + const equalsIndex = token.indexOf("="); + const bareToken = equalsIndex === -1 ? token : token.slice(0, equalsIndex); + const resolved = legacyResolveFlagFromToken(bareToken, inScopeFlags); + if (resolved === undefined) return token; + + if (equalsIndex !== -1) { + if (!legacyIsValidFlagValue(resolved, token.slice(equalsIndex + 1))) return token; + continue; + } + if (resolved.isBoolean) continue; + if (index >= trimmedArgs.length) { + if (trailingMissingValueIsFatal) return token; + continue; + } const value = trimmedArgs[index]; index++; // skip the consumed value token if (value !== undefined && !legacyIsValidFlagValue(resolved, value)) return value; + continue; + } + + const cluster = legacyResolveShortFlagCluster(token, inScopeFlags); + if (cluster === undefined) return token; + if (cluster.flag.isBoolean) continue; + if (cluster.attachedValue !== undefined) { + if (!legacyIsValidFlagValue(cluster.flag, cluster.attachedValue)) return token; + continue; + } + if (index >= trimmedArgs.length) { + if (trailingMissingValueIsFatal) return token; + continue; } + const value = trimmedArgs[index]; + index++; // skip the consumed value token + if (value !== undefined && !legacyIsValidFlagValue(cluster.flag, value)) return value; } return undefined; } @@ -621,10 +784,18 @@ function legacyFlagValueCompletion( * 1. `--help`/`-h` anywhere in `trimmedArgs` (or `--version`/`-v`, only when * resolved to the root command) short-circuits to no candidates — these * exit before any real completion runs. - * 2. `toComplete` is a bare flag with no `=` → flag-NAME completion. - * 3. `toComplete` (or the immediately preceding token) identifies a + * 2. A bare `--` anywhere in `trimmedArgs` disables ALL flag-name and + * flag-value completion (Cases 3/4 below) for the rest of this request — + * mirrors cobra's `flagCompletion` gate, which goes false the moment a + * previous `--` is already present (`completions.go:364-381`; see + * `hasFlagTerminator` below). + * 3. `toComplete` is a bare flag with no `=` → flag-NAME completion. + * 4. `toComplete` (or the immediately preceding token) identifies a * non-boolean flag's value slot → flag-VALUE completion. - * 4. Otherwise → subcommand-name + required-flag (noun) completion. + * 5. Otherwise → subcommand-name + required-flag (noun) completion; five + * specific leaf paths (`completion[ bash|zsh|fish|powershell]`) force the + * directive to NoFileComp regardless of what the subcommand walk above + * computed — see `LEGACY_COMPLETION_NO_FILE_COMP_PATHS`. */ export function legacyClassifyCompletion( input: LegacyClassifyCompletionInput, @@ -632,7 +803,7 @@ export function legacyClassifyCompletion( const { finalCommand, matchedPath, leftoverArgs, trimmedArgs, toComplete, inScopeFlags } = input; const isAtRoot = matchedPath.length === 0; - if (legacyFindUnresolvedFlagToken(trimmedArgs, inScopeFlags) !== undefined) { + if (legacyFindUnresolvedFlagToken(trimmedArgs, toComplete, inScopeFlags) !== undefined) { return { candidates: [], directive: LegacyCompletionDirective.Default }; } @@ -651,9 +822,15 @@ export function legacyClassifyCompletion( const toCompleteIsFlag = toComplete.startsWith("-"); const toCompleteEqualsIndex = toComplete.indexOf("="); + // Once a bare `--` sentinel has already appeared, cobra never does + // flag-name or flag-value completion again for the rest of the request + // (verified empirically against a real `apps/cli-go` build: `db dump -- + // --s` returns zero candidates with the Default directive, not + // `--schema` — CLI-1965 review finding). + const hasFlagTerminator = trimmedArgs.includes("--"); // Case 1: flag-NAME completion. - if (toCompleteIsFlag && toCompleteEqualsIndex === -1) { + if (!hasFlagTerminator && toCompleteIsFlag && toCompleteEqualsIndex === -1) { const requiredCandidates = requiredFlags.flatMap((flag) => legacyFlagNameCandidates(flag, toComplete), ); @@ -669,18 +846,23 @@ export function legacyClassifyCompletion( } // Case 2: flag-VALUE completion. - if (toCompleteIsFlag) { - // toCompleteEqualsIndex !== -1 here — the no-`=` branch above returns. - const resolved = legacyResolveFlagFromToken( - toComplete.slice(0, toCompleteEqualsIndex), - inScopeFlags, - ); - if (resolved === undefined || !resolved.isBoolean) { + if (!hasFlagTerminator) { + if (toCompleteIsFlag) { + // toCompleteEqualsIndex !== -1 here — the no-`=` branch above returns. + // Cobra's checkIfFlagCompletion treats ANY `--flag=value` token + // (including a boolean's) as flag-value completion — the "reset to + // noun completion for a boolean" only applies in the separate no-`=` + // two-token case handled by the `else` branch below + // (`completions.go`'s `!flagWithEqual` guard around that reset; + // verified empirically against a real `apps/cli-go` build: + // `--debug=maybe` returns zero candidates with the Default directive, + // not the root command list — CLI-1965 review finding). + const resolved = legacyResolveFlagFromToken( + toComplete.slice(0, toCompleteEqualsIndex), + inScopeFlags, + ); return legacyFlagValueCompletion(matchedPath, resolved?.name); } - // A boolean flag doesn't consume a following value — fall through to - // Case 3 with the ORIGINAL toComplete/trimmedArgs, unchanged. - } else { const precedingToken = trimmedArgs[trimmedArgs.length - 1]; if ( precedingToken !== undefined && @@ -688,9 +870,26 @@ export function legacyClassifyCompletion( !precedingToken.includes("=") ) { const resolved = legacyResolveFlagFromToken(precedingToken, inScopeFlags); - if (resolved !== undefined && !resolved.isBoolean) { + if (resolved === undefined) { + // Cobra's checkIfFlagCompletion errors out here (a `flagCompError` + // short-circuits `getCompletions` outright) rather than falling + // through to noun completion — an unresolved trailing flag (per + // that function's OWN last-character heuristic, not + // `legacyFindUnresolvedFlagToken`'s strict first-character parse) + // before an empty/non-flag toComplete is a hard stop (verified + // empirically against a real `apps/cli-go` build: `-ojson ""` + // returns zero candidates with the Default directive, even though + // `-ojson` is a perfectly valid `-o=json` under real pflag parsing + // — cobra's own heuristic looks at `-ojson`'s LAST character, `n`, + // which resolves to nothing). + return { candidates: [], directive: LegacyCompletionDirective.Default }; + } + if (!resolved.isBoolean) { return legacyFlagValueCompletion(matchedPath, resolved.name); } + // A resolved BOOLEAN precedingToken falls through to Case 3 — it + // never consumed a value, so this wasn't really flag-value + // completion. } } @@ -739,6 +938,15 @@ export function legacyClassifyCompletion( candidates.push(...legacyFlagNameCandidates(flag, toComplete)); } + // Cobra always invokes a resolved command's own `ValidArgsFunction` (when + // registered) at the very end of `getCompletions`, and that call + // OVERWRITES whatever directive the subcommand walk above already set + // (`completions.go:564-579`) — see `LEGACY_COMPLETION_NO_FILE_COMP_PATHS`'s + // doc comment for which paths this applies to and why. + if (LEGACY_COMPLETION_NO_FILE_COMP_PATHS.has(matchedPath.join(" "))) { + directive = LegacyCompletionDirective.NoFileComp; + } + return { candidates, directive }; } diff --git a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts index 13b5cb5858..955efbdd5c 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts @@ -343,6 +343,140 @@ describe("legacyRespondToComplete", () => { }); }); + describe("flag terminator (`--`) disables flag completion (CLI-1965 review)", () => { + it("does not offer a flag-name candidate for a positional operand after `--`", () => { + // Cobra's flagCompletion gate goes false once a `--` is already present + // in the args (completions.go:364-381), so a positional operand that + // happens to start with `-` after the terminator must not be treated as + // a flag-name completion (verified empirically against a real + // apps/cli-go build: `db dump -- --s` returns zero candidates with the + // Default directive, not `--schema`). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "db", "dump", "--", "--s"]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("still offers a required flag that appears (as a positional) after `--`", () => { + // completeRequireFlags is called unconditionally in cobra's noun- + // completion branch, even past the terminator — and a token past `--` + // is never parsed as a flag at all, so it must not be marked "changed" + // either (verified empirically against a real apps/cli-go build: `sso + // add -- --type --typ` still offers `--type` with the Default + // directive). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + "add", + "--", + "--type", + "--typ", + ]); + expect(result).toEqual({ + candidates: [{ name: "--type", description: expect.any(String) }], + directive: LegacyCompletionDirective.Default, + }); + }); + }); + + describe("attached shorthand values resolve via pflag's real strict parser (CLI-1965 review)", () => { + it("parses a non-boolean shorthand's attached value instead of treating the token as unknown", () => { + // pflag's parseSingleShortArg resolves a shorthand cluster's value by + // its FIRST character, not its last — `-j4` is a fully valid, + // already-resolved `--jobs=4`, so it must not suppress completion of a + // later flag name (verified empirically against a real apps/cli-go + // build: `functions deploy -j4 --p` still offers + // --profile/--project-ref/--prune). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "functions", + "deploy", + "-j4", + "--p", + ]); + expect(result?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["--profile", "--project-ref", "--prune"]), + ); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + }); + }); + + describe("a trailing incomplete flag is a hard parse error only when toComplete is itself flag-shaped (CLI-1965 review)", () => { + it("rejects a dangling value-taking flag when toComplete is a bare flag-shaped token", () => { + // Cobra's checkIfFlagCompletion only rescues a trailing incomplete flag + // from ParseFlags() when toComplete is empty or not flag-shaped + // (completions.go:666-687); `-o --d` leaves `-o` dangling with no + // rescue, so the real ParseFlags() call fails outright (verified + // empirically against a real apps/cli-go build: zero candidates with + // the Default directive — Go's error is "flag needs an argument: 'o' + // in -o"). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "-o", "--d"]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it.each([ + { toComplete: "", label: "empty toComplete" }, + { toComplete: "pre", label: "non-flag-shaped toComplete" }, + ])( + "still falls through to flag-VALUE completion for the same dangling flag given $label", + ({ toComplete }) => { + const result = legacyRespondToComplete(legacyRoot, ["__complete", "-o", toComplete]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }, + ); + }); + + describe("changed-flag tracking honors a long flag's real value consumption (CLI-1965 review)", () => { + it("does not mark a value token as its own changed flag, so a still-required flag stays offered", () => { + // pflag's parseLongArg consumes the immediately following token as a + // non-boolean flag's value regardless of its shape — `--domains` (a + // string flag) consumes `--type` here, so `--type` itself was never + // parsed as a flag and must still be offered as required (verified + // empirically against a real apps/cli-go build: `sso add --domains + // --type foo --typ` still offers `--type` with the NoFileComp + // directive). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + "add", + "--domains", + "--type", + "foo", + "--typ", + ]); + expect(result).toEqual({ + candidates: [{ name: "--type", description: expect.any(String) }], + directive: LegacyCompletionDirective.NoFileComp, + }); + }); + }); + + describe("a boolean flag with an explicit `=` is still flag-VALUE completion (CLI-1965 review)", () => { + it("does not fall through to noun completion for `--boolFlag=value`", () => { + // Cobra's checkIfFlagCompletion only resets a boolean flag back to noun + // completion in the no-`=` two-token case (`!flagWithEqual` guard); + // `--debug=maybe` keeps `flag` set and goes to flag-VALUE completion, + // which resolves to zero candidates (verified empirically against a + // real apps/cli-go build: the Default directive, not the root command + // list). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "--debug=maybe"]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + }); + + describe("completion script leaves force NoFileComp (CLI-1965 review)", () => { + it.each(["bash", "zsh", "fish", "powershell"])( + "returns zero candidates with the NoFileComp directive for `completion %s`", + (shell) => { + // Cobra's InitDefaultCompletionCmd registers ValidArgsFunction: + // NoFileCompletions on the completion group and each of its shell + // leaves; getCompletions always calls a resolved command's own + // ValidArgsFunction, overwriting the directive outright (verified + // empirically against a real apps/cli-go build). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "completion", shell, ""]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.NoFileComp }); + }, + ); + }); + it("returns undefined for zero completion args (mirrors cobra's MinimumNArgs(1) failure)", () => { expect(legacyRespondToComplete(legacyRoot, ["__complete"])).toBeUndefined(); }); From 38c536a4e3dafa31d0440337e8ce5ab15f454093 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 19:38:13 +0100 Subject: [PATCH 06/14] fix(cli): match Go's help-argument completion, uint flag rejection, and per-command --output validation (review) - legacy-complete.ts: `help ` now re-resolves the path after `help` from root (mirroring cobra's auto-registered help command's own ValidArgsFunction), instead of treating `help` as an unresolved leftover and returning nothing. - legacy-complete.ts: `functions deploy --jobs`, `migration down --last`, and `db reset --last` are Go UintVar/UintVarP flags that reject a leading sign; the generic signed-Integer regex used for completion validation wrongly accepted negative values. Hoisted `legacyParseUintBase0` (Go's strconv.ParseUint(s, 0, 64), previously storage/cp-only) into legacy/shared/ and reused it for these three flags. - legacy-complete.ts: the global LegacyOutputFlag's choiceKeys is the union of root's 5-value enum and db query's own 3-value enum, so completion validation accepted db query's table/csv values everywhere and the resource commands' env/pretty/toml/yaml values inside db query. Restored per-command validation. All three verified empirically against a real apps/cli-go build (release version ldflag set). --- apps/cli/src/legacy/cli/legacy-complete.ts | 149 +++++++++++++++- .../legacy/cli/legacy-complete.unit.test.ts | 167 ++++++++++++++++++ .../legacy/commands/storage/cp/cp.command.ts | 2 +- .../legacy-parse-uint.ts} | 11 +- .../legacy-parse-uint.unit.test.ts} | 4 +- 5 files changed, 320 insertions(+), 13 deletions(-) rename apps/cli/src/legacy/{commands/storage/cp/cp.parse-uint.ts => shared/legacy-parse-uint.ts} (88%) rename apps/cli/src/legacy/{commands/storage/cp/cp.parse-uint.unit.test.ts => shared/legacy-parse-uint.unit.test.ts} (95%) diff --git a/apps/cli/src/legacy/cli/legacy-complete.ts b/apps/cli/src/legacy/cli/legacy-complete.ts index bf33434958..91e492fd8d 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.ts @@ -2,7 +2,12 @@ import { Option } from "effect"; import { GlobalFlag } from "effect/unstable/cli"; import type { Command, Param, Primitive } from "effect/unstable/cli"; import process from "node:process"; +import { + LEGACY_QUERY_OUTPUT_FORMATS, + LEGACY_RESOURCE_OUTPUT_FORMATS, +} from "../shared/legacy-go-output-flag.ts"; import { legacyUnwrapParam } from "../shared/legacy-param-introspection.ts"; +import { legacyParseUintBase0 } from "../shared/legacy-parse-uint.ts"; /** * Native TypeScript reimplementation of cobra's dynamic-completion protocol @@ -573,6 +578,50 @@ function legacyMarkChangedShorthandCluster( return false; } +/** + * Go registers `--jobs`/`--last` as `UintVarP`/`UintVar` pflag values + * (`apps/cli-go/cmd/functions.go:161` — `functions deploy`; + * `cmd/migration.go:152` — `migration down`; `cmd/db.go:717` — `db reset`, + * the same bug class), which reject a leading `-`/`+` outright + * (`strconv.ParseUint(s, 0, 64)`) — unlike this TS tree's plain signed + * `Flag.integer("jobs"/"last")`. `legacyIsValidFlagValue`'s generic `Integer` + * regex accepts a leading sign, so it must consult this table to know when + * to defer to the stricter `legacyParseUintBase0` instead (verified + * empirically against a real `apps/cli-go` build: `functions deploy --jobs + * -1 --p`, `migration down --last -1 --d`, and `db reset --last -1 --d` all + * return zero candidates with the Default directive — CLI-1965 review + * finding). `storage cp --jobs` hits the same Go flag type but is already + * `Flag.string("jobs")` in TS (its own handler calls `legacyParseUintBase0` + * directly, `cp.command.ts`), so it never reaches this `Integer` branch and + * needs no entry here. Key = `:`, matching + * `LEGACY_COMPLETION_REQUIRED_FLAGS`'s convention. + */ +const LEGACY_COMPLETION_UINT_FLAGS: ReadonlySet = new Set([ + "functions deploy:jobs", + "migration down:last", + "db reset:last", +]); + +/** + * Go registers `--output`/`-o` as a command-scoped enum: the root persistent + * flag accepts `env|pretty|json|toml|yaml` (`internal/utils/output.go:30-38`) + * while `db query`'s own local flag accepts `json|table|csv` (`cmd/db.go:285- + * 288`) — two value sets that only overlap on `json`, on what this TS tree + * models as a single global `LegacyOutputFlag` whose `choiceKeys` is the + * union of both (`legacy-go-output-flag.ts`), so `flag.choiceKeys` alone + * can't tell which enum applies at the resolved command. This restores Go's + * per-command validation (verified empirically against a real `apps/cli-go` + * build: `--output table ""` outside `db query`, and `db query --output env + * ""`, are BOTH rejected with zero candidates and the Default directive, + * even though each value is accepted on the OTHER side — CLI-1965 review + * finding). + */ +function legacyOutputFlagChoiceKeys(matchedPath: ReadonlyArray): ReadonlyArray { + return matchedPath.length === 2 && matchedPath[0] === "db" && matchedPath[1] === "query" + ? LEGACY_QUERY_OUTPUT_FORMATS + : LEGACY_RESOURCE_OUTPUT_FORMATS; +} + /** * Validates a flag's value the way pflag's typed `Value.Set` does inside * `finalCmd.ParseFlags()` — e.g. `-o not-a-format` (a `Choice`-typed @@ -582,15 +631,28 @@ function legacyMarkChangedShorthandCluster( * return zero candidates with the Default directive, exactly like an * unresolved flag name). Only the primitive shapes pflag can actually reject * are checked; `String`/`Path`/`Date`/etc. flags accept any string in Go too, - * so every other tag is unconditionally valid here. + * so every other tag is unconditionally valid here — except the two + * command-dependent overrides above (`LEGACY_COMPLETION_UINT_FLAGS`, + * `legacyOutputFlagChoiceKeys`), which a bare `LegacyFlagDescriptor` can't + * express on its own, hence the `matchedPath` parameter. */ -function legacyIsValidFlagValue(flag: LegacyFlagDescriptor, value: string): boolean { +function legacyIsValidFlagValue( + matchedPath: ReadonlyArray, + flag: LegacyFlagDescriptor, + value: string, +): boolean { switch (flag.primitiveTag) { case "Boolean": return legacyParseGoBool(value) !== undefined; case "Choice": + if (flag.name === "output") { + return legacyOutputFlagChoiceKeys(matchedPath).includes(value); + } return flag.choiceKeys !== undefined && flag.choiceKeys.includes(value); case "Integer": + if (LEGACY_COMPLETION_UINT_FLAGS.has(`${matchedPath.join(" ")}:${flag.name}`)) { + return "value" in legacyParseUintBase0(value); + } return /^[+-]?\d+$/.test(value); case "Float": return value.trim().length > 0 && !Number.isNaN(Number(value)); @@ -702,6 +764,7 @@ function legacyFindUnresolvedFlagToken( trimmedArgs: ReadonlyArray, toComplete: string, inScopeFlags: ReadonlyArray, + matchedPath: ReadonlyArray, ): string | undefined { // See this function's doc comment: only a `toComplete` that's itself a // bare flag-shaped token (no `=`) blocks cobra's "rescue" of a trailing, @@ -724,7 +787,8 @@ function legacyFindUnresolvedFlagToken( if (resolved === undefined) return token; if (equalsIndex !== -1) { - if (!legacyIsValidFlagValue(resolved, token.slice(equalsIndex + 1))) return token; + if (!legacyIsValidFlagValue(matchedPath, resolved, token.slice(equalsIndex + 1))) + return token; continue; } if (resolved.isBoolean) continue; @@ -734,7 +798,8 @@ function legacyFindUnresolvedFlagToken( } const value = trimmedArgs[index]; index++; // skip the consumed value token - if (value !== undefined && !legacyIsValidFlagValue(resolved, value)) return value; + if (value !== undefined && !legacyIsValidFlagValue(matchedPath, resolved, value)) + return value; continue; } @@ -742,7 +807,7 @@ function legacyFindUnresolvedFlagToken( if (cluster === undefined) return token; if (cluster.flag.isBoolean) continue; if (cluster.attachedValue !== undefined) { - if (!legacyIsValidFlagValue(cluster.flag, cluster.attachedValue)) return token; + if (!legacyIsValidFlagValue(matchedPath, cluster.flag, cluster.attachedValue)) return token; continue; } if (index >= trimmedArgs.length) { @@ -751,7 +816,8 @@ function legacyFindUnresolvedFlagToken( } const value = trimmedArgs[index]; index++; // skip the consumed value token - if (value !== undefined && !legacyIsValidFlagValue(cluster.flag, value)) return value; + if (value !== undefined && !legacyIsValidFlagValue(matchedPath, cluster.flag, value)) + return value; } return undefined; } @@ -772,6 +838,62 @@ function legacyFlagValueCompletion( return { candidates: [], directive: LegacyCompletionDirective.Default }; } +/** + * Mirrors cobra's auto-registered `help` command's own `ValidArgsFunction` + * (`command.go:1263-1310`, `InitDefaultHelpCmd`): `help` is a REAL subcommand + * of root, and its `ValidArgsFunction` re-resolves everything typed after it + * from root — via `c.Root().Find(args)` — then lists THAT resolved command's + * own visible subcommands, filtered by `toComplete`'s prefix. `help db d` + * therefore completes as if `d` were being completed inside `db` (`diff`, + * `dump`), not as an argument to `help` itself. + * + * Mirrors cobra's `legacyArgs` validator (`args.go:28-37`) for the "unknown + * command" error `Find` surfaces through `e`: it fires ONLY when the + * resolved command is root itself (no real descent happened at all) AND a + * token is left over — a token left over under any OTHER resolved command is + * never an error there (subcommands "will always accept arbitrary + * arguments"). On that error path cobra returns zero candidates, but still + * with the NoFileComp directive, same as the success path (verified + * empirically against a real `apps/cli-go` build: `help bogus d` -> no + * candidates; `help db bogus d` -> still `db`'s subcommands, since `db` is + * not root — CLI-1965 review finding). + * + * `root` here is always `finalCommand` from the outer resolution: `help` has + * no node anywhere in this TS tree (it is a synthesized candidate, not a + * real `Command.Command.Any` — see the comment where Case 3 pushes it + * below), so the outer `legacyResolveCommandPath` call always stops at root + * immediately when `trimmedArgs[0] === "help"`, making `finalCommand` and + * real cobra's `c.Root()` the same command. + */ +function legacyHelpArgumentCandidates( + root: Command.Command.Any, + argsAfterHelp: ReadonlyArray, + toComplete: string, +): LegacyCompletionResult { + const { matchedPath, leftoverArgs, commandChain } = legacyResolveCommandPath(root, argsAfterHelp); + if (matchedPath.length === 0 && leftoverArgs.length > 0) { + return { candidates: [], directive: LegacyCompletionDirective.NoFileComp }; + } + + const resolved = commandChain[commandChain.length - 1] ?? root; + const visibleSubcommands = legacyFlattenSubcommands(resolved).filter((sub) => !sub.hidden); + const candidates: Array = visibleSubcommands.map((sub) => ({ + name: sub.name, + description: sub.shortDescription ?? sub.description, + })); + // Cobra's help command is itself one of root's `Commands()`, so re-resolving + // to root also re-lists `help` (verified empirically: `help h` -> `help`). + if (matchedPath.length === 0) { + candidates.push({ name: "help", description: "Help about any command" }); + } + candidates.sort((a, b) => a.name.localeCompare(b.name)); + + return { + candidates: candidates.filter((candidate) => candidate.name.startsWith(toComplete)), + directive: LegacyCompletionDirective.NoFileComp, + }; +} + /** * Classifies a single completion request into candidates + directive, * mirroring cobra's `checkIfFlagCompletion` and the branch in @@ -803,7 +925,9 @@ export function legacyClassifyCompletion( const { finalCommand, matchedPath, leftoverArgs, trimmedArgs, toComplete, inScopeFlags } = input; const isAtRoot = matchedPath.length === 0; - if (legacyFindUnresolvedFlagToken(trimmedArgs, toComplete, inScopeFlags) !== undefined) { + if ( + legacyFindUnresolvedFlagToken(trimmedArgs, toComplete, inScopeFlags, matchedPath) !== undefined + ) { return { candidates: [], directive: LegacyCompletionDirective.Default }; } @@ -897,6 +1021,17 @@ export function legacyClassifyCompletion( const candidates: Array = []; let directive: number = LegacyCompletionDirective.Default; + // `help` is a real cobra subcommand with its own `ValidArgsFunction` that + // completes a SECOND, independent command-path lookup from root — see + // `legacyHelpArgumentCandidates`'s doc comment. `isAtRoot && + // trimmedArgs[0] === "help"` exactly identifies "this request is `help + // ...`": `help` isn't a node anywhere in this tree, so the outer + // `legacyResolveCommandPath` call always stops at root immediately when + // it's the first token (CLI-1965 review finding). + if (isAtRoot && trimmedArgs[0] === "help") { + return legacyHelpArgumentCandidates(finalCommand, trimmedArgs.slice(1), toComplete); + } + // Once any flag or extra positional token has already appeared before this // position, subcommand-name completion is suppressed entirely (cobra's // `len(finalArgs) == 0` gate) — including the directive it would otherwise diff --git a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts index 955efbdd5c..06adf6ba07 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts @@ -477,6 +477,173 @@ describe("legacyRespondToComplete", () => { ); }); + describe("help's own ValidArgsFunction resolves a second command path from root (CLI-1965 review)", () => { + it("completes root subcommand names after `help`", () => { + // Cobra's auto-registered help command has its own ValidArgsFunction + // (command.go:1274-1290) that re-resolves everything after `help` from + // root — `help d` completes as if `d` were being completed at the + // root itself, not as an argument to `help` (verified empirically + // against a real apps/cli-go build: `db`, `domains`). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "help", "d"]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["db", "domains"]), + ); + }); + + it("completes a resolved subcommand's own children after `help `", () => { + // `help db d` completes as `db d` would — db's own subcommands, not + // help's (verified empirically against a real apps/cli-go build: + // `diff`, `dump`). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "help", "db", "d"]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["diff", "dump"]), + ); + }); + + it("returns no candidates for a leaf command with no subcommands of its own", () => { + // `db dump` is a leaf; cobra's ValidArgsFunction loops over its empty + // Commands() and finds nothing, but still sets NoFileComp (verified + // empirically against a real apps/cli-go build). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "help", "db", "dump", "s"]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.NoFileComp }); + }); + + it("returns no candidates for an unresolved token directly under root", () => { + // Cobra's legacyArgs validator (args.go:28-37) only errors the + // "unknown command" case when the resolved command IS root and a + // token is left over (verified empirically against a real + // apps/cli-go build: `help bogus d` -> zero candidates). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "help", "bogus", "d"]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.NoFileComp }); + }); + + it("still lists a resolved non-root command's subcommands past an unresolved token", () => { + // The same legacyArgs validator never errors for a non-root resolved + // command, even with leftover args — subcommands "will always accept + // arbitrary arguments" (verified empirically against a real + // apps/cli-go build: `help db bogus d` still offers db's own + // subcommands). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "help", + "db", + "bogus", + "d", + ]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["diff", "dump"]), + ); + }); + + it("includes the synthetic `help` candidate itself when resolved back to root", () => { + // Cobra's help command is one of root's own Commands(), so completing + // help's arguments back at root re-lists help too (verified + // empirically against a real apps/cli-go build: `help h` -> `help`). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "help", "h"]); + expect(result).toEqual({ + candidates: [{ name: "help", description: "Help about any command" }], + directive: LegacyCompletionDirective.NoFileComp, + }); + }); + }); + + describe("uint-backed flags reject a leading sign like real pflag's ParseUint (CLI-1965 review)", () => { + it.each([ + { path: ["functions", "deploy"], flag: "jobs" }, + { path: ["migration", "down"], flag: "last" }, + { path: ["db", "reset"], flag: "last" }, + ])("rejects a negative value for $path --$flag", ({ path, flag }) => { + // Go registers these as UintVarP/UintVar (strconv.ParseUint(s, 0, 64)), + // which rejects any sign prefix outright — unlike this tree's plain + // signed Flag.integer, whose generic regex accepts one (verified + // empirically against a real apps/cli-go build: zero candidates with + // the Default directive for all three). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + ...path, + `--${flag}`, + "-1", + "", + ]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("still accepts a valid uint value, including the zero boundary", () => { + // Regression guard: the stricter check must not reject what real Go + // accepts (verified empirically against a real apps/cli-go build: + // `db reset --last 0 --d` still offers --debug/--dns-resolver/--db-url). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "db", + "reset", + "--last", + "0", + "--d", + ]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["--debug", "--dns-resolver", "--db-url"]), + ); + }); + }); + + describe("--output's choice values are validated per-command, not the widened global union (CLI-1965 review)", () => { + it("rejects db query's own local values (table/csv) everywhere else", () => { + // The global LegacyOutputFlag's choiceKeys is the UNION of root's + // 5-value enum and db query's own 3-value enum (legacy-go-output- + // flag.ts), but real Go's root persistent --output only accepts + // env|pretty|json|toml|yaml (verified empirically against a real + // apps/cli-go build: `--output table ""` -> zero candidates with the + // Default directive). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "--output", "table", ""]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("rejects the resource-command values (env/pretty/toml/yaml) under db query", () => { + // The reverse direction of the same defect: db query's own Go enum is + // json|table|csv only (verified empirically against a real + // apps/cli-go build: `db query --output env ""` -> zero candidates + // with the Default directive, even though `env` is valid everywhere + // else). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "db", + "query", + "--output", + "env", + "", + ]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("accepts db query's own values (table/csv) under db query", () => { + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "db", + "query", + "--output", + "table", + "--li", + ]); + expect(result?.candidates.map((c) => c.name)).toContain("--linked"); + }); + + it("accepts the resource-command values (env/pretty/toml/yaml) outside db query", () => { + const result = legacyRespondToComplete(legacyRoot, ["__complete", "--output", "env", ""]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("branches"); + }); + + it("still accepts json everywhere — the one value both Go enums share", () => { + const result = legacyRespondToComplete(legacyRoot, ["__complete", "--output", "json", ""]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("branches"); + }); + }); + it("returns undefined for zero completion args (mirrors cobra's MinimumNArgs(1) failure)", () => { expect(legacyRespondToComplete(legacyRoot, ["__complete"])).toBeUndefined(); }); diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.command.ts b/apps/cli/src/legacy/commands/storage/cp/cp.command.ts index e1befdbf56..82bf9a3af2 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.command.ts +++ b/apps/cli/src/legacy/commands/storage/cp/cp.command.ts @@ -8,7 +8,7 @@ import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-comm import { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; import { legacyStorageGatewayRuntimeLayer } from "../../../shared/legacy-storage-runtime.layer.ts"; import { legacyStorageInvalidJobsMessage } from "../storage.errors.ts"; -import { legacyParseUintBase0 } from "./cp.parse-uint.ts"; +import { legacyParseUintBase0 } from "../../../shared/legacy-parse-uint.ts"; import { LegacyStorageLinkedFlagDef, LegacyStorageLocalFlagDef, diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.ts b/apps/cli/src/legacy/shared/legacy-parse-uint.ts similarity index 88% rename from apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.ts rename to apps/cli/src/legacy/shared/legacy-parse-uint.ts index b56e96622f..b2a37c43c2 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.ts +++ b/apps/cli/src/legacy/shared/legacy-parse-uint.ts @@ -1,8 +1,13 @@ /** * Faithful port of Go's `strconv.ParseUint(s, 0, 64)` — the exact parser pflag - * runs for a `UintVarP` flag like `storage cp --jobs` (`uintValue.Set`, - * `pflag/uint.go`). Operating on the RAW flag token (instead of a - * pre-normalized number) is load-bearing for parity: + * runs for a `UintVarP`/`UintVar` flag (`uintValue.Set`, `pflag/uint.go`). + * Hoisted here (from its original home under `commands/storage/cp/`, CLI-1965 + * review) once a second family needed it: `legacy-complete.ts` validates + * `functions deploy --jobs`/`migration down --last`/`db reset --last` — all + * three declared `Flag.integer` in TS but `UintVar`/`UintVarP` in Go — the + * same way `storage cp --jobs` already does at parse time. Operating on the + * RAW flag token (instead of a pre-normalized number) is load-bearing for + * parity: * * - every sign prefix is rejected, including `-0` and `+1` (a numeric * normalization turns `-0` into negative zero, for which `value < 0` is diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.unit.test.ts b/apps/cli/src/legacy/shared/legacy-parse-uint.unit.test.ts similarity index 95% rename from apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.unit.test.ts rename to apps/cli/src/legacy/shared/legacy-parse-uint.unit.test.ts index 1133857f3a..b446817b35 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-parse-uint.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { legacyParseUintBase0 } from "./cp.parse-uint.ts"; +import { legacyParseUintBase0 } from "./legacy-parse-uint.ts"; // Every expectation in this file is ground truth captured from go1.26: // `strconv.ParseUint(s, 0, 64)` — the exact call pflag makes for a `UintVarP` @@ -56,7 +56,7 @@ describe("legacyParseUintBase0 (Go strconv.ParseUint(s, 0, 64) parity)", () => { expect(legacyParseUintBase0("18446744073709551616")).toEqual({ cause: "value out of range" }); expect(legacyParseUintBase0("0x10000000000000000")).toEqual({ cause: "value out of range" }); // Max uint64 parses (the Number conversion is lossy up there — documented - // residual in cp.parse-uint.ts — but the accept/reject verdict matches Go). + // residual in legacy-parse-uint.ts — but the accept/reject verdict matches Go). expect(legacyParseUintBase0("18446744073709551615")).toEqual({ value: Number(18446744073709551615n), }); From cb64005d42727cd587e3133b2502aaf352733cf2 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 21:27:36 +0100 Subject: [PATCH 07/14] fix(cli): match Go's pflag value parsing, boolean shorthand, help/version Changed check, and bare-dash handling in shell completion (review) - legacyIsValidFlagValue now accepts base-0 integers (0x/0o/0b/leading-zero octal), and checks the uint/CSV overrides before dispatching on primitiveTag so they also catch string-typed flags like storage cp --jobs and every StringSliceVar-backed variadic flag (--domains, --schema, etc.), with db reset --sql-paths excluded as Go's one plain StringArrayVar. - legacyFindUnresolvedFlagToken validates a short flag cluster's attached value even when the owning flag is boolean (-f=value), matching pflag. - legacyMatchesFlagToken makes the --help/--version short-circuit match Changed semantics (--help=false counts, not just a bare --help). - legacyHasUnconsumedFlagTerminator replaces a raw `includes("--")` check so a `--` already consumed as a preceding flag's own value doesn't disable flag completion for the rest of the request. - legacyResolveCommandPath and the flag-value "preceding token" check both now treat a bare `-` as the positional pflag considers it, not a flag. All five fixes verified against a real apps/cli-go build (release version ldflag set), diffing actual __complete output before/after. --- apps/cli/src/legacy/cli/legacy-complete.ts | 257 +++++++++++++++--- .../legacy/cli/legacy-complete.unit.test.ts | 219 +++++++++++++++ 2 files changed, 438 insertions(+), 38 deletions(-) diff --git a/apps/cli/src/legacy/cli/legacy-complete.ts b/apps/cli/src/legacy/cli/legacy-complete.ts index 91e492fd8d..77fd34f359 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.ts @@ -8,6 +8,7 @@ import { } from "../shared/legacy-go-output-flag.ts"; import { legacyUnwrapParam } from "../shared/legacy-param-introspection.ts"; import { legacyParseUintBase0 } from "../shared/legacy-parse-uint.ts"; +import { legacyParseStringSliceFlag } from "../shared/legacy-string-slice-flag.ts"; /** * Native TypeScript reimplementation of cobra's dynamic-completion protocol @@ -318,13 +319,27 @@ function legacyResolveFlagFromToken( * it can ever match a subcommand (verified empirically: `__complete -- * db ""` returns zero candidates with the Default directive, not `db`'s * subcommands). + * - A bare `-` is NOT flag-shaped at all — pflag's own `isFlagArg` + * (`command.go:750-753`) requires at least 2 characters, so cobra's + * `stripFlags` (`command.go:674-706`) silently drops it from its + * subcommand-name scan (it matches none of that function's `switch` + * cases) without either consuming a value OR stopping the descent, and — + * critically — WITHOUT removing it from the leftover args the way a + * matched command name is (`argsMinusFirstX` only ever strips the exact + * matched name). It therefore must stay in `leftoverArgs` here too, while + * still letting the descent continue past it (verified empirically + * against a real `apps/cli-go` build: `db - dump --da` still descends + * past the bare `-` into `dump` and offers `--data-only`, while `sso - + * --debug a` returns zero candidates with the Default directive — the + * surviving `-` keeps the `len(finalArgs) == 0` subcommand-listing gate + * below closed — CLI-1965 review finding). * * Descent stops at the first non-flag token that doesn't match a subcommand, * or at a `--` sentinel; that token and everything after it becomes * `leftoverArgs` — the *positional* leftover cobra's `finalArgs` represents * (`completions.go:397-399`), used to gate subcommand-name completion * (`len(finalArgs) == 0`). Flag tokens and their consumed values are never - * part of `leftoverArgs`. + * part of `leftoverArgs`; a bare `-` is the one exception, per above. */ export function legacyResolveCommandPath( root: Command.Command.Any, @@ -345,6 +360,16 @@ export function legacyResolveCommandPath( if (token === "--") break; // pflag's end-of-flags sentinel: nothing at or after this can match a subcommand. + if (token === "-") { + // Not flag-shaped (pflag's `isFlagArg` requires length >= 2) and never + // a real subcommand name — skip it without consuming a value, without + // breaking the descent, and WITHOUT marking it consumed, so it survives + // into `leftoverArgs` exactly like real cobra's `finalArgs` does. See + // this function's doc comment for the empirical verification. + index++; + continue; + } + if (token.startsWith("-")) { consumedIndices.add(index); const isLong = token.startsWith("--"); @@ -398,6 +423,26 @@ const LEGACY_HELP_TOKENS: ReadonlySet = new Set(["--help", "-h"]); */ const LEGACY_VERSION_TOKENS: ReadonlySet = new Set(["--version", "-v"]); +/** + * A token matches `names` either in its bare boolean-flag form (`--help`, + * `-h`) or with an EXPLICIT value (`--help=true`, `--help=false`, `-h=false`, + * ...) — pflag's `boolValue.Set` marks the flag `Changed` on either + * spelling, and cobra's `helpOrVersionFlagPresent` (`completions.go:530-537`) + * checks `.Changed`, not the parsed value, so `--help=false` short-circuits + * exactly like a bare `--help` (verified empirically against a real + * `apps/cli-go` build: `--help=false --d` and `--version=false br` both + * return zero candidates with the NoFileComp directive — CLI-1965 review + * finding). Any explicit-value token reaching this check has already had + * its value validated as a real boolean by `legacyFindUnresolvedFlagToken` + * earlier in `legacyClassifyCompletion` — an invalid value (`--help=maybe`) + * short-circuits there first instead, with the Default directive. + */ +function legacyMatchesFlagToken(token: string, names: ReadonlySet): boolean { + if (names.has(token)) return true; + const equalsIndex = token.indexOf("="); + return equalsIndex !== -1 && names.has(token.slice(0, equalsIndex)); +} + /** * Mirrors cobra's `MarkFlagFilename` calls in `apps/cli-go/cmd/sso.go:166,167,181,182` * — 4 individually hardcoded lines in Go, not derived from anything generic, @@ -578,30 +623,135 @@ function legacyMarkChangedShorthandCluster( return false; } +/** + * Whether `trimmedArgs` contains a genuine, unconsumed pflag end-of-flags + * sentinel — a bare `--` token that is NOT itself the value a preceding + * value-taking flag already consumed. `--file --` consumes the `--` as + * `--file`'s string value (`pflag@v1.0.10/flag.go:1013-1023`'s + * `parseLongArg`, which grabs the very next token unconditionally); pflag's + * sentinel check only ever inspects the CURRENT token being parsed, never + * one already claimed as a preceding flag's value, so a consumed `--` + * never disables later flag completion. A naive `trimmedArgs.includes("--")` + * treats that consumed token as a terminator too, wrongly shutting off + * flag-name/flag-value completion for the rest of the request (verified + * empirically against a real `apps/cli-go` build: `db dump --file -- --s` + * still offers `--schema`, not zero candidates, while `db dump -- --s` — no + * preceding value flag to consume the `--` — correctly returns zero + * candidates — CLI-1965 review finding). Walks the same long/short + * consumption rules `legacyChangedFlagNames` does, reusing + * `legacyResolveShortFlagCluster` for the short-flag case. + */ +function legacyHasUnconsumedFlagTerminator( + trimmedArgs: ReadonlyArray, + inScopeFlags: ReadonlyArray, +): boolean { + let index = 0; + while (index < trimmedArgs.length) { + const token = trimmedArgs[index]; + index++; + if (token === undefined) continue; + if (token === "--") return true; // genuine, unconsumed sentinel. + + if (token.startsWith("--")) { + const rest = token.slice(2); + const equalsIndex = rest.indexOf("="); + const name = equalsIndex === -1 ? rest : rest.slice(0, equalsIndex); + if (equalsIndex === -1 && index < trimmedArgs.length) { + const owner = inScopeFlags.find((flag) => flag.name === name); + if (owner !== undefined && !owner.isBoolean) index++; // consumes the next token (possibly `--`) as its value. + } + continue; + } + if (token.startsWith("-") && token !== "-") { + const cluster = legacyResolveShortFlagCluster(token, inScopeFlags); + const consumesNextToken = + cluster !== undefined && !cluster.flag.isBoolean && cluster.attachedValue === undefined; + if (consumesNextToken && index < trimmedArgs.length) index++; + } + } + return false; +} + /** * Go registers `--jobs`/`--last` as `UintVarP`/`UintVar` pflag values * (`apps/cli-go/cmd/functions.go:161` — `functions deploy`; - * `cmd/migration.go:152` — `migration down`; `cmd/db.go:717` — `db reset`, - * the same bug class), which reject a leading `-`/`+` outright - * (`strconv.ParseUint(s, 0, 64)`) — unlike this TS tree's plain signed - * `Flag.integer("jobs"/"last")`. `legacyIsValidFlagValue`'s generic `Integer` - * regex accepts a leading sign, so it must consult this table to know when - * to defer to the stricter `legacyParseUintBase0` instead (verified - * empirically against a real `apps/cli-go` build: `functions deploy --jobs - * -1 --p`, `migration down --last -1 --d`, and `db reset --last -1 --d` all - * return zero candidates with the Default directive — CLI-1965 review - * finding). `storage cp --jobs` hits the same Go flag type but is already - * `Flag.string("jobs")` in TS (its own handler calls `legacyParseUintBase0` - * directly, `cp.command.ts`), so it never reaches this `Integer` branch and - * needs no entry here. Key = `:`, matching + * `cmd/migration.go:152` — `migration down`; `cmd/db.go:717` — `db reset`; + * `cmd/storage.go:107` — `storage cp`, the same bug class), which reject a + * leading `-`/`+` outright (`strconv.ParseUint(s, 0, 64)`) — unlike this TS + * tree's plain signed `Flag.integer("jobs"/"last")`. `legacyIsValidFlagValue` + * checks this table BEFORE dispatching on `primitiveTag`, since it must + * catch `storage cp --jobs` too, which is `Flag.string("jobs")` in TS (its + * own handler already calls `legacyParseUintBase0` directly at parse time, + * `cp.command.ts`) rather than `Flag.integer` — a bare `primitiveTag` + * switch would never see it (verified empirically against a real + * `apps/cli-go` build: `functions deploy --jobs -1 --p`, `migration down + * --last -1 --d`, `db reset --last -1 --d`, and `storage cp --jobs -1 --r` + * all return zero candidates with the Default directive — CLI-1965 review + * finding). Key = `:`, matching * `LEGACY_COMPLETION_REQUIRED_FLAGS`'s convention. */ const LEGACY_COMPLETION_UINT_FLAGS: ReadonlySet = new Set([ "functions deploy:jobs", "migration down:last", "db reset:last", + "storage cp:jobs", +]); + +/** + * Go's plain (non-uint) integer flags — e.g. `backups restore --timestamp` + * (`cmd/backups.go`, an `Int64Var`) — parse via `strconv.ParseInt(s, 0, 64)` + * (pflag's `int64Value.Set`): base 0, so a leading `0x`/`0o`/`0b` prefix or a + * leading `0` (octal) all parse successfully, unlike a plain + * `/^[+-]?\d+$/` decimal-only regex (verified empirically against a real + * `apps/cli-go` build: `backups restore --timestamp 0x10 --p` still offers + * `--profile`/`--project-ref` — CLI-1965 review finding). Mirrors + * `strconv.ParseInt`'s own two-step design: strip an optional leading + * `+`/`-`, then run the exact same base-0 digit grammar + * `legacyParseUintBase0` already implements for `UintVar` flags on the + * remainder — the int64-vs-uint64 range distinction doesn't matter here, + * since completion only needs a syntax verdict, not the parsed value. + */ +function legacyIsValidBase0Integer(value: string): boolean { + const unsigned = value[0] === "+" || value[0] === "-" ? value.slice(1) : value; + return "value" in legacyParseUintBase0(unsigned); +} + +/** + * Go registers `--sql-paths` (`db reset`, `cmd/db.go:714`) as a plain + * `StringArrayVar` — pflag stores each repeated occurrence verbatim, with NO + * CSV parsing — unlike every OTHER variadic (`isVariadic`) string flag + * reachable from this tree, which Go declares `StringSliceVar`/ + * `StringSliceVarP` (CSV-split per occurrence): `--domains` (sso + * add/update), `--schema`/`--exclude` (db dump/diff/pull/lint, gen types, db + * schema declarative generate/sync), `--config` (postgres-config + * delete/update), `--db-unban-ip` (network-bans remove), `--db-allow-cidr` + * (network-restrictions update), and `--exclude`/`--override-name` + * (start/status). This is the one, small exception — kept as an exclusion + * set rather than an inclusion table, since the inclusion side is the much + * longer list. Key = `:`. + */ +const LEGACY_COMPLETION_NON_CSV_VARIADIC_FLAGS: ReadonlySet = new Set([ + "db reset:sql-paths", ]); +/** + * Validates a CSV-per-occurrence (`isVariadic`, pflag `StringSliceVar`) + * flag's value the same way `legacyParseStringSliceFlag` does at real parse + * time — reused here directly rather than re-implemented, so the two never + * drift (verified empirically against a real `apps/cli-go` build: `sso add + * --domains 'a,"b' --type` — an unterminated quote — returns zero + * candidates with the Default directive, not `--type` — CLI-1965 review + * finding). + */ +function legacyIsValidCsvFlagValue(value: string): boolean { + try { + legacyParseStringSliceFlag([value]); + return true; + } catch { + return false; + } +} + /** * Go registers `--output`/`-o` as a command-scoped enum: the root persistent * flag accepts `env|pretty|json|toml|yaml` (`internal/utils/output.go:30-38`) @@ -629,18 +779,32 @@ function legacyOutputFlagChoiceKeys(matchedPath: ReadonlyArray): Readonl * in real pflag, and cobra reports the parse error instead of generating any * completions (verified empirically against a real `apps/cli-go` build: both * return zero candidates with the Default directive, exactly like an - * unresolved flag name). Only the primitive shapes pflag can actually reject - * are checked; `String`/`Path`/`Date`/etc. flags accept any string in Go too, - * so every other tag is unconditionally valid here — except the two - * command-dependent overrides above (`LEGACY_COMPLETION_UINT_FLAGS`, - * `legacyOutputFlagChoiceKeys`), which a bare `LegacyFlagDescriptor` can't - * express on its own, hence the `matchedPath` parameter. + * unresolved flag name). The two command-dependent overrides + * (`LEGACY_COMPLETION_UINT_FLAGS`, `LEGACY_COMPLETION_NON_CSV_VARIADIC_FLAGS`) + * are checked BEFORE the `primitiveTag` dispatch — a bare `LegacyFlagDescriptor` + * can't express either on its own (both need `matchedPath`, and the uint one + * specifically needs to catch a flag whose TS `primitiveTag` isn't + * `"Integer"` at all, e.g. `storage cp --jobs`). Every other primitive shape + * pflag can actually reject is checked in the switch; `String`/`Path`/ + * `Date`/etc. flags accept any string in Go too, so the default case is + * unconditionally valid. */ function legacyIsValidFlagValue( matchedPath: ReadonlyArray, flag: LegacyFlagDescriptor, value: string, ): boolean { + const key = `${matchedPath.join(" ")}:${flag.name}`; + if (LEGACY_COMPLETION_UINT_FLAGS.has(key)) { + return "value" in legacyParseUintBase0(value); + } + if ( + flag.isVariadic && + flag.primitiveTag === "String" && + !LEGACY_COMPLETION_NON_CSV_VARIADIC_FLAGS.has(key) + ) { + return legacyIsValidCsvFlagValue(value); + } switch (flag.primitiveTag) { case "Boolean": return legacyParseGoBool(value) !== undefined; @@ -650,10 +814,7 @@ function legacyIsValidFlagValue( } return flag.choiceKeys !== undefined && flag.choiceKeys.includes(value); case "Integer": - if (LEGACY_COMPLETION_UINT_FLAGS.has(`${matchedPath.join(" ")}:${flag.name}`)) { - return "value" in legacyParseUintBase0(value); - } - return /^[+-]?\d+$/.test(value); + return legacyIsValidBase0Integer(value); case "Float": return value.trim().length > 0 && !Number.isNaN(Number(value)); default: @@ -805,11 +966,19 @@ function legacyFindUnresolvedFlagToken( const cluster = legacyResolveShortFlagCluster(token, inScopeFlags); if (cluster === undefined) return token; - if (cluster.flag.isBoolean) continue; + // An attached value (`-o=json`, or a non-boolean's `-ojson`) must be + // validated BEFORE the boolean short-circuit below — pflag treats + // `-f=value` as an explicit value for a boolean shorthand too + // (`pflag@v1.0.10/flag.go:1005-1033`), so a boolean owner does not, on + // its own, mean "nothing to validate" (verified empirically against a + // real `apps/cli-go` build: `storage cp -r=maybe --j` returns zero + // candidates with the Default directive, not `--jobs` — CLI-1965 review + // finding). if (cluster.attachedValue !== undefined) { if (!legacyIsValidFlagValue(matchedPath, cluster.flag, cluster.attachedValue)) return token; continue; } + if (cluster.flag.isBoolean) continue; if (index >= trimmedArgs.length) { if (trailingMissingValueIsFatal) return token; continue; @@ -906,11 +1075,12 @@ function legacyHelpArgumentCandidates( * 1. `--help`/`-h` anywhere in `trimmedArgs` (or `--version`/`-v`, only when * resolved to the root command) short-circuits to no candidates — these * exit before any real completion runs. - * 2. A bare `--` anywhere in `trimmedArgs` disables ALL flag-name and - * flag-value completion (Cases 3/4 below) for the rest of this request — - * mirrors cobra's `flagCompletion` gate, which goes false the moment a - * previous `--` is already present (`completions.go:364-381`; see - * `hasFlagTerminator` below). + * 2. A genuine, unconsumed bare `--` anywhere in `trimmedArgs` disables ALL + * flag-name and flag-value completion (Cases 3/4 below) for the rest of + * this request — mirrors cobra's `flagCompletion` gate, which goes false + * the moment a previous `--` is already present (`completions.go:364- + * 381`; see `legacyHasUnconsumedFlagTerminator` below for why "unconsumed" + * matters). * 3. `toComplete` is a bare flag with no `=` → flag-NAME completion. * 4. `toComplete` (or the immediately preceding token) identifies a * non-boolean flag's value slot → flag-VALUE completion. @@ -932,8 +1102,8 @@ export function legacyClassifyCompletion( } if ( - trimmedArgs.some((token) => LEGACY_HELP_TOKENS.has(token)) || - (isAtRoot && trimmedArgs.some((token) => LEGACY_VERSION_TOKENS.has(token))) + trimmedArgs.some((token) => legacyMatchesFlagToken(token, LEGACY_HELP_TOKENS)) || + (isAtRoot && trimmedArgs.some((token) => legacyMatchesFlagToken(token, LEGACY_VERSION_TOKENS))) ) { return { candidates: [], directive: LegacyCompletionDirective.NoFileComp }; } @@ -946,12 +1116,15 @@ export function legacyClassifyCompletion( const toCompleteIsFlag = toComplete.startsWith("-"); const toCompleteEqualsIndex = toComplete.indexOf("="); - // Once a bare `--` sentinel has already appeared, cobra never does - // flag-name or flag-value completion again for the rest of the request - // (verified empirically against a real `apps/cli-go` build: `db dump -- - // --s` returns zero candidates with the Default directive, not - // `--schema` — CLI-1965 review finding). - const hasFlagTerminator = trimmedArgs.includes("--"); + // Once a genuine, unconsumed bare `--` sentinel has already appeared, + // cobra never does flag-name or flag-value completion again for the rest + // of the request (verified empirically against a real `apps/cli-go` + // build: `db dump -- --s` returns zero candidates with the Default + // directive, not `--schema` — CLI-1965 review finding). See + // `legacyHasUnconsumedFlagTerminator`'s doc comment for why a raw + // `trimmedArgs.includes("--")` over-triggers when a preceding value-taking + // flag consumed that `--` as its own value instead. + const hasFlagTerminator = legacyHasUnconsumedFlagTerminator(trimmedArgs, inScopeFlags); // Case 1: flag-NAME completion. if (!hasFlagTerminator && toCompleteIsFlag && toCompleteEqualsIndex === -1) { @@ -991,6 +1164,14 @@ export function legacyClassifyCompletion( if ( precedingToken !== undefined && precedingToken.startsWith("-") && + // A bare `-` is excluded — pflag's `isFlagArg` (`command.go:750-753`) + // requires at least 2 characters, so real cobra's own equivalent + // "preceding token is flag-shaped" check never fires for it either, + // and this must fall through to Case 3 instead of hard-stopping + // (verified empirically against a real `apps/cli-go` build: `help db + // - d` still lists `db`'s subcommands `diff`/`dump`, not zero + // candidates — CLI-1965 review finding). + precedingToken !== "-" && !precedingToken.includes("=") ) { const resolved = legacyResolveFlagFromToken(precedingToken, inScopeFlags); diff --git a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts index 06adf6ba07..3545370e2e 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts @@ -644,6 +644,225 @@ describe("legacyRespondToComplete", () => { }); }); + describe("flag values are validated the way real pflag parses them (CLI-1965 review)", () => { + it("accepts a base-0 hex value for a plain (non-uint) integer flag", () => { + // Go's plain int64 flags parse via strconv.ParseInt(s, 0, 64) — base 0, + // so a `0x`-prefixed value is valid, unlike a decimal-only regex + // (verified empirically against a real apps/cli-go build: `backups + // restore --timestamp 0x10 --p` still offers --profile/--project-ref). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "backups", + "restore", + "--timestamp", + "0x10", + "--p", + ]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["--profile", "--project-ref"]), + ); + }); + + it("rejects a negative value for storage cp --jobs even though it's a string-typed flag in TS", () => { + // Go registers --jobs as a UintVarP (cmd/storage.go:107), the same as + // functions deploy/migration down/db reset above — but storage cp + // models it as Flag.string in TS, so the uint override must be + // consulted regardless of primitiveTag (verified empirically against + // a real apps/cli-go build: `storage cp --jobs -1 --r` returns zero + // candidates with the Default directive, not --recursive). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "storage", + "cp", + "--jobs", + "-1", + "--r", + ]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("rejects malformed CSV for a StringSliceVar-backed flag", () => { + // Go's --domains is a StringSliceVarP (cmd/sso.go:158), CSV-split via + // encoding/csv at parse time; an unterminated quote fails that parse + // (verified empirically against a real apps/cli-go build: `sso add + // --domains 'a,"b' --type` returns zero candidates with the Default + // directive, not --type). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + "add", + "--domains", + 'a,"b', + "--type", + ]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("still accepts well-formed CSV (a quoted comma) for the same flag", () => { + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + "add", + "--domains", + '"example.com,example.org"', + "--type", + ]); + expect(result?.candidates.map((c) => c.name)).toContain("--type"); + }); + + it("does not apply CSV validation to db reset --sql-paths (a plain StringArrayVar, not StringSliceVar)", () => { + // Go registers --sql-paths as a plain StringArrayVar (cmd/db.go:714) — + // no CSV parsing — unlike every other variadic string flag in this + // tree, so a value containing an unbalanced quote must still be + // accepted. + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "db", + "reset", + "--sql-paths", + 'a"b', + "--d", + ]); + expect(result?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["--debug", "--dns-resolver"]), + ); + }); + }); + + describe("an attached-value shorthand cluster is validated even when the owning flag is boolean (CLI-1965 review)", () => { + it("rejects an invalid boolean value attached via `=` to a boolean shorthand", () => { + // pflag treats `-f=value` as an explicit value for a boolean shorthand + // too (pflag@v1.0.10/flag.go:1005-1033) — an owning flag being boolean + // does not, on its own, mean there is nothing to validate (verified + // empirically against a real apps/cli-go build: `storage cp -r=maybe + // --j` returns zero candidates with the Default directive, not + // --jobs). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "storage", + "cp", + "-r=maybe", + "--j", + ]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("still accepts a valid boolean value attached the same way", () => { + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "storage", + "cp", + "-r=true", + "--j", + ]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("--jobs"); + }); + }); + + describe("the built-in help/version flags short-circuit on Changed, not on exact token spelling (CLI-1965 review)", () => { + it.each(["--help=false", "--help=true", "-h=false"])( + "treats %s the same as a bare --help", + (token) => { + // pflag's boolValue.Set marks the flag Changed on either spelling, + // and cobra's helpOrVersionFlagPresent checks .Changed, not the + // parsed value (completions.go:530-537) — verified empirically + // against a real apps/cli-go build: `--help=false --d` and + // `--help=true --d` both return zero candidates with the + // NoFileComp directive. + const result = legacyRespondToComplete(legacyRoot, ["__complete", token, "--d"]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.NoFileComp }); + }, + ); + + it("treats --version=false the same as a bare --version, at the root", () => { + // verified empirically against a real apps/cli-go build: `--version=false + // br` returns zero candidates with the NoFileComp directive, not + // `branches`. + const result = legacyRespondToComplete(legacyRoot, ["__complete", "--version=false", "br"]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.NoFileComp }); + }); + + it("does not treat --help=maybe as Changed — an invalid boolean value is an unresolved-flag parse error instead", () => { + // An invalid value fails pflag's own Set() before Changed is ever + // examined, so this is Case 0 (Default), not the help short-circuit + // (NoFileComp). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "--help=maybe", "--d"]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + }); + + describe("a `--` consumed as a preceding flag's value is not a genuine terminator (CLI-1965 review)", () => { + it("still offers a flag name after `--` was consumed as a value-taking flag's own value", () => { + // pflag's parseLongArg consumes the very next token unconditionally as + // a non-boolean flag's value — including a literal `--` — so it never + // reaches pflag's own end-of-flags sentinel check (pflag@v1.0.10/ + // flag.go:949-952) (verified empirically against a real apps/cli-go + // build: `db dump --file -- --s` still offers --schema, not zero + // candidates). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "db", + "dump", + "--file", + "--", + "--s", + ]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("--schema"); + }); + + it("still disables flag completion for a genuine, unconsumed `--` sentinel", () => { + // Regression guard: only a `--` that isn't claimed as a preceding + // flag's value is a real terminator (verified empirically against a + // real apps/cli-go build: `db dump -- --s` returns zero candidates + // with the Default directive). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "db", "dump", "--", "--s"]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + }); + + describe("a bare `-` is a positional argument, not a flag (CLI-1965 review)", () => { + it("keeps the subcommand-listing gate closed when a bare `-` survives as leftover", () => { + // pflag's isFlagArg requires at least 2 characters, so a bare `-` is + // never flag-shaped — but it also never removes itself from cobra's + // leftover finalArgs the way a matched command name does, so it keeps + // the `len(finalArgs) == 0` subcommand-listing gate closed (verified + // empirically against a real apps/cli-go build: `sso - --debug a` + // returns zero candidates with the Default directive, not `add`). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + "-", + "--debug", + "a", + ]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("still lets the descent continue past a bare `-` to a real subcommand match", () => { + // Regression guard: a bare `-` must not stop the descent the way an + // unmatched real token does (verified empirically against a real + // apps/cli-go build: `db - dump --da` still offers --data-only). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "db", "-", "dump", "--da"]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("--data-only"); + }); + + it("still resolves help's own second command-path lookup past a bare `-`", () => { + // The Case-2 "preceding token is flag-shaped" check must also exclude + // a bare `-`, or it hard-stops before ever reaching help's dispatch + // (verified empirically against a real apps/cli-go build: `help db - + // d` still lists db's own subcommands diff/dump). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "help", "db", "-", "d"]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["diff", "dump"]), + ); + }); + }); + it("returns undefined for zero completion args (mirrors cobra's MinimumNArgs(1) failure)", () => { expect(legacyRespondToComplete(legacyRoot, ["__complete"])).toBeUndefined(); }); From 09a2a62dca245192254f188a99e25bd4d9a4d252 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 23:26:59 +0100 Subject: [PATCH 08/14] fix(cli): match Go's int64/duration/RFC3339 flag validation, help/version Changed tracking, unknown-root gating, flag sort order, and global-flag description text in shell completion (review) Fixes six fresh Codex findings from the latest review round: - legacyIsValidFlagValue's Integer case validated backups restore --timestamp (an Int64VarP in Go) against the wider uint64 bound instead of int64's narrower, asymmetric two's-complement range, wrongly accepting 9223372036854775808 (one past int64 max). Hoists a shared base-0 digit-parsing core in legacy-parse-uint.ts and adds legacyIsValidBase0Int64 alongside the existing legacyParseUintBase0. - gen types --query-timeout, gen bearer-jwt --valid-for (DurationVar), and --exp (TimeVar, RFC3339) are plain Flag.string in TS with no validation, so a bogus value fell through to the default "always valid" case. Adds legacyIsValidGoDuration/legacyIsValidGoRfc3339 plus per-flag override tables, checked the same way the existing uint/CSV overrides are. - The --help/--version short-circuit scanned raw tokens for the literal strings, over-triggering past a genuine `--` terminator or when the token was actually consumed as a preceding flag's value. Now reads legacyChangedFlagNames (which already respects both) instead. - Flag-name completion after an unmatched ROOT-level positional (e.g. `nosuch --d`) still offered root's flags; cobra's Command.Find -> legacyArgs errors in exactly that case (root itself, unresolved, leftover positional), ahead of the help/version short-circuit too. Adds that gate, filtering out the bare `-`/empty-string leftovers pflag's own stripFlags excludes, and exempting genuine `help ...` requests (which re-resolve through their own, already-correct path). - legacyCollectInScopeFlags emitted flags in tree-declaration order; cobra's completion path walks InheritedFlags().VisitAll then NonInheritedFlags().VisitAll, each alphabetically sorted by pflag. Splits the collector into an inherited (ancestor) block and an own/local block, each sorted, with the own block excluding any name it shadows from the inherited one (mirroring pflag's own shadow-skip) instead of relying on Map re-insertion order. - Root persistent flag descriptions in global-flags.ts were capitalized with trailing periods; Go's own cmd/root.go registrations are lowercase, no period. This only became user-visible once native completion started echoing these descriptions verbatim in `__complete` output. All six verified empirically against a real apps/cli-go build (ldflag -X .../utils.Version=1.999.0) diffing __complete output before writing the fix, per this PR's established review methodology. --- .../legacy/cli/legacy-complete.e2e.test.ts | 2 +- apps/cli/src/legacy/cli/legacy-complete.ts | 335 +++++++++++++----- .../legacy/cli/legacy-complete.unit.test.ts | 262 +++++++++++++- .../src/legacy/shared/legacy-parse-uint.ts | 93 ++++- .../shared/legacy-parse-uint.unit.test.ts | 40 ++- apps/cli/src/shared/legacy/global-flags.ts | 29 +- 6 files changed, 645 insertions(+), 116 deletions(-) diff --git a/apps/cli/src/legacy/cli/legacy-complete.e2e.test.ts b/apps/cli/src/legacy/cli/legacy-complete.e2e.test.ts index f3273f388a..bbf56fe885 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.e2e.test.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.e2e.test.ts @@ -37,6 +37,6 @@ describe("supabase __complete (legacy)", () => { entrypoint: "legacy", }); expect(exitCode).toBe(0); - expect(stdout).toContain("--debug\tOutput debug logs to stderr."); + expect(stdout).toContain("--debug\toutput debug logs to stderr"); }); }); diff --git a/apps/cli/src/legacy/cli/legacy-complete.ts b/apps/cli/src/legacy/cli/legacy-complete.ts index 77fd34f359..d0362232c5 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.ts @@ -7,7 +7,7 @@ import { LEGACY_RESOURCE_OUTPUT_FORMATS, } from "../shared/legacy-go-output-flag.ts"; import { legacyUnwrapParam } from "../shared/legacy-param-introspection.ts"; -import { legacyParseUintBase0 } from "../shared/legacy-parse-uint.ts"; +import { legacyIsValidBase0Int64, legacyParseUintBase0 } from "../shared/legacy-parse-uint.ts"; import { legacyParseStringSliceFlag } from "../shared/legacy-string-slice-flag.ts"; /** @@ -206,17 +206,28 @@ function legacyFlagDescriptorFromParam(param: Param.AnyFlag): LegacyFlagDescript /** * The full in-scope flag list for `commandChain`'s last element (the resolved - * command): every command in the chain's own declared global flags - * (`Command.withGlobalFlags` — not just `root`'s, since a non-root command can - * declare its own, e.g. `legacySeedCommand`'s `--linked`/`--local`), plus the - * always-available `--help` and (root only) `--version`, every ancestor's - * shared flags (`Command.withSharedFlags`), and the resolved command's own - * local flags. + * command), ordered and grouped the way cobra's own completion path emits + * flag-name candidates: `InheritedFlags().VisitAll` (every ancestor's global + * and shared flags, as ONE pflag-alphabetically-sorted block), followed by + * `NonInheritedFlags().VisitAll` (the resolved command's own global flags, + * its own `--help`, root's own `--version`, and its own local flags, as a + * SECOND, separately-sorted block) — pflag's `FlagSet.VisitAll` walks + * `sortedFormalFlags`, which sorts strictly by each flag's canonical long + * name (verified empirically against a real `apps/cli-go` build: `db dump -` + * lists `--agent`, `--create-ticket`, `--debug`, ... alphabetically, THEN a + * second alphabetical run starting `--data-only`, `--db-url`, `--dry-run`, + * ... — not one merged alphabetical list and not this tree's own declaration + * order — CLI-1965 review finding). * - * Later entries win on a canonical-name collision — e.g. a command's own local - * `--output` (`db diff`'s file-path flag) must shadow the global `--output` - * choice flag declared at root, mirroring pflag's `InheritedFlags()`, which - * skips any persistent flag shadowed by a same-named local one. + * The resolved command's own local flags win on a canonical-name collision — + * e.g. a command's own local `--output` (`db diff`'s file-path flag) must + * shadow the global `--output` choice flag declared at root — by being + * excluded from the inherited block entirely, mirroring pflag's + * `InheritedFlags()`, which skips any persistent flag shadowed by a + * same-named local one (rather than being present in both and "last write + * wins": a `Map`'s insertion-order position does not move on a same-key + * `.set()`, so a naive later-overwrite would leave the shadowed entry sitting + * in the wrong (inherited) sort position instead of removing it). */ export function legacyCollectInScopeFlags( root: Command.Command.Any, @@ -225,34 +236,51 @@ export function legacyCollectInScopeFlags( const finalCommand = commandChain[commandChain.length - 1] ?? root; const ancestors = commandChain.slice(0, -1); - const chainGlobalFlagParams = commandChain - .flatMap((command) => legacyInternalCommand(command).globalFlags) - // `GlobalFlag.Completions`/`GlobalFlag.LogLevel` are TS-only framework - // additions with no Go/cobra equivalent. They are normally only injected - // via `GlobalFlag.BuiltIns` at parse time (never stored on a command's own - // `.globalFlags`), so this filter is a defensive guard rather than - // something that changes today's output — kept explicit so it stays true - // if that ever changes. - .filter((entry) => entry !== GlobalFlag.Completions && entry !== GlobalFlag.LogLevel) - .map((entry) => entry.flag); - - const params: Array = [ - ...chainGlobalFlagParams, + // `GlobalFlag.Completions`/`GlobalFlag.LogLevel` are TS-only framework + // additions with no Go/cobra equivalent. They are normally only injected + // via `GlobalFlag.BuiltIns` at parse time (never stored on a command's own + // `.globalFlags`), so this filter is a defensive guard rather than + // something that changes today's output — kept explicit so it stays true + // if that ever changes. + const globalFlagParamsOf = (command: Command.Command.Any): ReadonlyArray => + legacyInternalCommand(command) + .globalFlags.filter( + (entry) => entry !== GlobalFlag.Completions && entry !== GlobalFlag.LogLevel, + ) + .map((entry) => entry.flag); + + const inheritedParams: Array = [ + ...ancestors.flatMap(globalFlagParamsOf), + ...ancestors.flatMap((ancestor) => legacyInternalCommand(ancestor).contextConfig.flags), + ]; + const ownParams: Array = [ + ...globalFlagParamsOf(finalCommand), GlobalFlag.Help.flag, // Cobra's `InitDefaultVersionFlag` only registers `--version`, and only on // the root command (gated on `c.Version != ""`, and non-persistent) — it // is never inherited by subcommands the way `--help` is. ...(commandChain.length === 1 ? [GlobalFlag.Version.flag] : []), - ...ancestors.flatMap((ancestor) => legacyInternalCommand(ancestor).contextConfig.flags), ...legacyInternalCommand(finalCommand).config.flags, ]; - const byName = new Map(); - for (const param of params) { - const descriptor = legacyFlagDescriptorFromParam(param); - if (descriptor !== undefined) byName.set(descriptor.name, descriptor); - } - return Array.from(byName.values()); + const descriptorsOf = ( + params: ReadonlyArray, + ): ReadonlyArray => { + const byName = new Map(); + for (const param of params) { + const descriptor = legacyFlagDescriptorFromParam(param); + if (descriptor !== undefined) byName.set(descriptor.name, descriptor); + } + return Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name)); + }; + + const own = descriptorsOf(ownParams); + const ownNames = new Set(own.map((descriptor) => descriptor.name)); + const inherited = descriptorsOf(inheritedParams).filter( + (descriptor) => !ownNames.has(descriptor.name), + ); + + return [...inherited, ...own]; } /* ========================================================================== */ @@ -412,37 +440,6 @@ export function legacyResolveCommandPath( /* Classification */ /* ========================================================================== */ -const LEGACY_HELP_TOKENS: ReadonlySet = new Set(["--help", "-h"]); -/** - * Only checked when the resolved command IS the root (`matchedPath.length === - * 0`) — cobra's `--version` flag lives on the root command only (see - * `legacyCollectInScopeFlags`'s comment), so a `--version`/`-v` token typed - * while completing a subcommand's own arguments (e.g. `migration squash - * --version `, a genuine local flag unrelated to cobra's built-in one) - * must not be mistaken for it. - */ -const LEGACY_VERSION_TOKENS: ReadonlySet = new Set(["--version", "-v"]); - -/** - * A token matches `names` either in its bare boolean-flag form (`--help`, - * `-h`) or with an EXPLICIT value (`--help=true`, `--help=false`, `-h=false`, - * ...) — pflag's `boolValue.Set` marks the flag `Changed` on either - * spelling, and cobra's `helpOrVersionFlagPresent` (`completions.go:530-537`) - * checks `.Changed`, not the parsed value, so `--help=false` short-circuits - * exactly like a bare `--help` (verified empirically against a real - * `apps/cli-go` build: `--help=false --d` and `--version=false br` both - * return zero candidates with the NoFileComp directive — CLI-1965 review - * finding). Any explicit-value token reaching this check has already had - * its value validated as a real boolean by `legacyFindUnresolvedFlagToken` - * earlier in `legacyClassifyCompletion` — an invalid value (`--help=maybe`) - * short-circuits there first instead, with the Default directive. - */ -function legacyMatchesFlagToken(token: string, names: ReadonlySet): boolean { - if (names.has(token)) return true; - const equalsIndex = token.indexOf("="); - return equalsIndex !== -1 && names.has(token.slice(0, equalsIndex)); -} - /** * Mirrors cobra's `MarkFlagFilename` calls in `apps/cli-go/cmd/sso.go:166,167,181,182` * — 4 individually hardcoded lines in Go, not derived from anything generic, @@ -564,6 +561,20 @@ function legacyFlagNameCandidates( * build: after `storage cp -rj 2`, both `-r`/`--recursive` and `-j`/`--jobs` * are "changed" — `--r` offers nothing further — whereas this function * used to record only the cluster's last character). + * + * `legacyClassifyCompletion`'s `--help`/`--version` short-circuit reads THIS + * set (`changedFlagNames.has("help"/"version")`) rather than scanning raw + * tokens for a reason beyond DRY: pflag's `boolValue.Set` marks the flag + * `Changed` on an explicit-value spelling too (`--help=false`), and cobra's + * `helpOrVersionFlagPresent` (`completions.go:530-537`) checks `.Changed`, + * not the parsed value — so `--help=false`/`--version=false` short-circuit + * exactly like a bare `--help`/`--version` (verified empirically against a + * real `apps/cli-go` build: `--help=false --d` and `--version=false br` both + * return zero candidates with the NoFileComp directive) — and this + * function's name-collection above already marks a flag changed on ANY + * spelling, explicit-value included. A raw token scan misses the terminator- + * and value-consumption cases this function already handles instead (see + * `legacyClassifyCompletion`'s call site for the specific repros). */ function legacyChangedFlagNames( trimmedArgs: ReadonlyArray, @@ -698,22 +709,96 @@ const LEGACY_COMPLETION_UINT_FLAGS: ReadonlySet = new Set([ ]); /** - * Go's plain (non-uint) integer flags — e.g. `backups restore --timestamp` - * (`cmd/backups.go`, an `Int64Var`) — parse via `strconv.ParseInt(s, 0, 64)` - * (pflag's `int64Value.Set`): base 0, so a leading `0x`/`0o`/`0b` prefix or a - * leading `0` (octal) all parse successfully, unlike a plain - * `/^[+-]?\d+$/` decimal-only regex (verified empirically against a real - * `apps/cli-go` build: `backups restore --timestamp 0x10 --p` still offers - * `--profile`/`--project-ref` — CLI-1965 review finding). Mirrors - * `strconv.ParseInt`'s own two-step design: strip an optional leading - * `+`/`-`, then run the exact same base-0 digit grammar - * `legacyParseUintBase0` already implements for `UintVar` flags on the - * remainder — the int64-vs-uint64 range distinction doesn't matter here, - * since completion only needs a syntax verdict, not the parsed value. + * Go registers `--query-timeout` (`gen types`, `cmd/gen.go:161`) and + * `--valid-for` (`gen bearer-jwt`, `cmd/gen.go:179`) as `DurationVar` pflag + * values (`time.ParseDuration`), unlike this TS tree's plain + * `Flag.string("query-timeout"/"valid-for")` — same shape as + * `LEGACY_COMPLETION_UINT_FLAGS` above, keyed the same way (verified + * empirically against a real `apps/cli-go` build: `gen types + * --query-timeout bogus --l` and `gen bearer-jwt --role anon --valid-for + * bogus --p` both return zero candidates with the Default directive — + * CLI-1965 review finding). + */ +const LEGACY_COMPLETION_DURATION_FLAGS: ReadonlySet = new Set([ + "gen types:query-timeout", + "gen bearer-jwt:valid-for", +]); + +/** + * Go registers `--exp` (`gen bearer-jwt`, `cmd/gen.go:178`) as a `TimeVar` + * pflag value constrained to `time.RFC3339` (`time.Parse(time.RFC3339, s)`), + * unlike this TS tree's plain `Flag.string("exp")` (verified empirically + * against a real `apps/cli-go` build: `gen bearer-jwt --role anon --exp + * bogus --p` returns zero candidates with the Default directive, while + * `--exp 2024-01-02T15:04:05Z --p` still offers `--profile`/`--payload` — + * CLI-1965 review finding). */ -function legacyIsValidBase0Integer(value: string): boolean { - const unsigned = value[0] === "+" || value[0] === "-" ? value.slice(1) : value; - return "value" in legacyParseUintBase0(unsigned); +const LEGACY_COMPLETION_RFC3339_FLAGS: ReadonlySet = new Set(["gen bearer-jwt:exp"]); + +/** + * Mirrors Go's `time.ParseDuration` grammar (`time/format.go`): an optional + * sign, then either the literal `0` alone, or one or more + * `` terms concatenated (`1h30m`, `1.5h`, `.5s`) — every unit + * pflag's duration parser accepts: `ns`, `us`/`µs`/`μs`, `ms`, `s`, `m`, `h`. + * A bare number with no unit (`"5"`), a unit with no leading digits (`"h"`), + * or anything that fails to fully consume (trailing/leading garbage) is + * rejected, matching Go's "missing unit"/"invalid duration" errors (verified + * against go1.26 `time.ParseDuration`: `"300ms"`/`"1.5h"`/`"2h45m"`/ + * `"-1.5h"`/`"0"`/`".5s"` → valid; `"bogus"`/`"5"`/`"0.0"`/`"1_0s"` → + * invalid). + * + * Known residual: Go's accumulator additionally overflows (→ "invalid + * duration") for a magnitude that, once unit-scaled, exceeds `int64` + * nanoseconds (e.g. a ~20-digit hour count) — this syntax-only check doesn't + * reproduce that overflow bound, the same class of residual + * `legacyParseUintBase0`'s own doc comment already accepts for values above + * 2^53. Unreachable through any realistic completion input. + */ +const GO_DURATION_PATTERN = + /^[+-]?(?:0|(?:(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:ns|us|µs|μs|ms|s|m|h))+)$/; + +function legacyIsValidGoDuration(value: string): boolean { + return GO_DURATION_PATTERN.test(value); +} + +/** + * Mirrors Go's `time.Parse(time.RFC3339, s)` — `2006-01-02T15:04:05Z07:00` + * — exact 4/2/2/2/2/2-digit date-time fields, a literal (case-sensitive) `T` + * separator, an optional `.`-prefixed fractional-seconds run of any length, + * and a `Z` or `±HH:MM` offset with NO numeric bound of its own (verified + * against go1.26 `time.Parse`: `"2024-01-02T15:04:05+24:00"` parses + * successfully — Go never range-checks the offset). Hour/minute/second are + * bounded to `0-23`/`0-59`/`0-59` (Go rejects `":60"` — no leap-second + * allowance — and `"25:"` — verified empirically). Month/day validity + * (including leap years, and short months like April's 30 days) is checked + * by round-tripping the parsed year/month/day through `Date#setUTCFullYear` + * and comparing what comes back — that method (unlike the `Date` constructor + * or `Date.UTC`) does NOT special-case a 0-99 year into 1900+year, so it + * stays correct for Go's own accepted `"0000-01-02T15:04:05Z"`, and its + * normal calendar-overflow behavior (Feb 29 rolling to Mar 1 in a + * non-leap year, day 32 rolling into the next month, month 13 rolling into + * the next year) exactly reproduces Go's own leap-year and day/month-bounds + * rejections without hand-rolling the calendar math. + */ +const GO_RFC3339_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; + +function legacyIsValidGoRfc3339(value: string): boolean { + const match = GO_RFC3339_PATTERN.exec(value); + if (match === null) return false; + const [, year, month, day, hour, minute, second] = match; + const y = Number(year); + const mo = Number(month); + const d = Number(day); + if (Number(hour) > 23 || Number(minute) > 59 || Number(second) > 59) return false; + + const roundTrip = new Date(0); + roundTrip.setUTCFullYear(y, mo - 1, d); + return ( + roundTrip.getUTCFullYear() === y && + roundTrip.getUTCMonth() === mo - 1 && + roundTrip.getUTCDate() === d + ); } /** @@ -779,15 +864,17 @@ function legacyOutputFlagChoiceKeys(matchedPath: ReadonlyArray): Readonl * in real pflag, and cobra reports the parse error instead of generating any * completions (verified empirically against a real `apps/cli-go` build: both * return zero candidates with the Default directive, exactly like an - * unresolved flag name). The two command-dependent overrides - * (`LEGACY_COMPLETION_UINT_FLAGS`, `LEGACY_COMPLETION_NON_CSV_VARIADIC_FLAGS`) + * unresolved flag name). The command-dependent overrides + * (`LEGACY_COMPLETION_UINT_FLAGS`, `LEGACY_COMPLETION_DURATION_FLAGS`, + * `LEGACY_COMPLETION_RFC3339_FLAGS`, `LEGACY_COMPLETION_NON_CSV_VARIADIC_FLAGS`) * are checked BEFORE the `primitiveTag` dispatch — a bare `LegacyFlagDescriptor` - * can't express either on its own (both need `matchedPath`, and the uint one - * specifically needs to catch a flag whose TS `primitiveTag` isn't - * `"Integer"` at all, e.g. `storage cp --jobs`). Every other primitive shape - * pflag can actually reject is checked in the switch; `String`/`Path`/ - * `Date`/etc. flags accept any string in Go too, so the default case is - * unconditionally valid. + * can't express any of them on its own (all need `matchedPath`, and the uint + * one specifically needs to catch a flag whose TS `primitiveTag` isn't + * `"Integer"` at all, e.g. `storage cp --jobs`; the duration/RFC3339 ones + * catch flags that are plain `Flag.string` in TS but a Go `Duration`/`Time` + * pflag value). Every other primitive shape pflag can actually reject is + * checked in the switch; `String`/`Path`/`Date`/etc. flags accept any string + * in Go too, so the default case is unconditionally valid. */ function legacyIsValidFlagValue( matchedPath: ReadonlyArray, @@ -798,6 +885,12 @@ function legacyIsValidFlagValue( if (LEGACY_COMPLETION_UINT_FLAGS.has(key)) { return "value" in legacyParseUintBase0(value); } + if (LEGACY_COMPLETION_DURATION_FLAGS.has(key)) { + return legacyIsValidGoDuration(value); + } + if (LEGACY_COMPLETION_RFC3339_FLAGS.has(key)) { + return legacyIsValidGoRfc3339(value); + } if ( flag.isVariadic && flag.primitiveTag === "String" && @@ -814,7 +907,7 @@ function legacyIsValidFlagValue( } return flag.choiceKeys !== undefined && flag.choiceKeys.includes(value); case "Integer": - return legacyIsValidBase0Integer(value); + return legacyIsValidBase0Int64(value); case "Float": return value.trim().length > 0 && !Number.isNaN(Number(value)); default: @@ -1072,6 +1165,15 @@ function legacyHelpArgumentCandidates( * short-circuits to no candidates with the Default directive — mirrors * `finalCmd.ParseFlags()` failing outright on an unrecognized flag, which * wins even over `--help`/`--version` below. + * 0.5. An unmatched ROOT-level positional (`matchedPath.length === 0` — no + * real descent happened at all — with a genuine leftover positional token) + * ALSO short-circuits to no candidates with the Default directive, and + * wins over `--help`/`--version` too — mirrors `Command.Find`'s own + * `legacyArgs` validator (`cobra@v1.10.2/args.go:28-37`), which returns + * `unknown command %q` precisely when the resolved command is root, has + * subcommands (root always does), and has a leftover non-flag positional + * — an error `getCompletions` surfaces as zero candidates before doing + * anything else with `finalCmd`. * 1. `--help`/`-h` anywhere in `trimmedArgs` (or `--version`/`-v`, only when * resolved to the root command) short-circuits to no candidates — these * exit before any real completion runs. @@ -1101,14 +1203,65 @@ export function legacyClassifyCompletion( return { candidates: [], directive: LegacyCompletionDirective.Default }; } - if ( - trimmedArgs.some((token) => legacyMatchesFlagToken(token, LEGACY_HELP_TOKENS)) || - (isAtRoot && trimmedArgs.some((token) => legacyMatchesFlagToken(token, LEGACY_VERSION_TOKENS))) - ) { - return { candidates: [], directive: LegacyCompletionDirective.NoFileComp }; + // Mirrors cobra's `stripFlags` (`command.go:674-710`), which the + // `legacyArgs` validator below runs its leftover-count check against — a + // bare `-` (and an empty string) is dropped from consideration, NOT + // counted as a genuine leftover positional, even though + // `legacyResolveCommandPath` deliberately leaves a bare `-` IN + // `leftoverArgs` for other purposes (verified empirically against a real + // `apps/cli-go` build: `__complete - --d` still offers root's own + // `--debug`/`--dns-resolver`, since `stripFlags` drops the lone `-` and + // leaves zero real leftover commands to error on — CLI-1965 review + // finding, root cause shared with the `nosuch --d` finding below). + // + // Exempts `trimmedArgs[0] === "help"`: real cobra's `help` is a REAL child + // node of root (`InitDefaultHelpCmd`), so `Find(["help", ...])` resolves + // INTO the help command itself rather than stopping at root — this TS + // tree has no such node (see `legacyHelpArgumentCandidates`'s doc + // comment), so the outer resolution below always sees "help" as an + // immediate non-match and would otherwise misfire this same root-level + // check for every legitimate `help ...` request. Case 3's own + // `isAtRoot && trimmedArgs[0] === "help"` branch re-resolves `help`'s own + // arguments from root separately and already reproduces cobra's real + // unknown-command handling for THAT inner resolution + // (`legacyHelpArgumentCandidates`'s `matchedPath.length === 0 && + // leftoverArgs.length > 0` check). + const rootLeftoverPositionals = leftoverArgs.filter((arg) => arg !== "" && !arg.startsWith("-")); + if (isAtRoot && trimmedArgs[0] !== "help" && rootLeftoverPositionals.length > 0) { + // Mirrors cobra's `Command.Find` -> `legacyArgs` (`args.go:28-37`): + // resolving to root itself (no descent at all) with a leftover + // positional is an "unknown command" error there, unlike a leftover + // positional under any OTHER resolved command, which is never an error + // (verified empirically against a real `apps/cli-go` build: `nosuch + // --d` returns zero candidates with the Default directive — even ahead + // of the `--help`/`--version` short-circuit below, i.e. `nosuch --help` + // is ALSO zero candidates, not the help short-circuit's NoFileComp — + // while `db bogus --d`, where `db` itself resolves, still offers `db`'s + // own `--debug`/`--dns-resolver` normally — CLI-1965 review finding). + return { candidates: [], directive: LegacyCompletionDirective.Default }; } + // `legacyChangedFlagNames` (not a raw token scan) is load-bearing here: it + // already stops at a genuine, unconsumed `--` terminator and already skips + // a token consumed as a PRECEDING non-boolean flag's value — exactly the + // two cases pflag's own `Changed` tracking respects and a bare + // `trimmedArgs.some(...)` token scan does not (verified empirically against + // a real `apps/cli-go` build: `db dump -- --help ""` still offers `db + // dump`'s own completions, not the help short-circuit — `--help` is + // positional, past the terminator; `--workdir --version br` still + // completes `branches`, not the version short-circuit — `--version` is + // consumed as `--workdir`'s string value, never parsed as a flag at all — + // CLI-1965 review finding). const changedFlagNames = legacyChangedFlagNames(trimmedArgs, inScopeFlags); + + // `version` is gated on `isAtRoot`: cobra's `--version` flag lives on the + // root command only (see `legacyCollectInScopeFlags`'s comment) — `help` is + // NOT gated the same way since every command registers its own local + // `--help` (present in `inScopeFlags`/`changedFlagNames` at every depth). + if (changedFlagNames.has("help") || (isAtRoot && changedFlagNames.has("version"))) { + return { candidates: [], directive: LegacyCompletionDirective.NoFileComp }; + } + const requiredFlags = inScopeFlags.filter( (flag) => legacyIsRequiredCompletionFlag(matchedPath, flag.name) && !changedFlagNames.has(flag.name), diff --git a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts index 3545370e2e..61e5e33a31 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts @@ -183,6 +183,103 @@ describe("legacyRespondToComplete", () => { ]); expect(result?.candidates.map((c) => c.name)).toContain("--linked"); }); + + it("does not short-circuit on --help positioned after a genuine `--` terminator (it is positional, not a flag)", () => { + // A raw token scan for the literal string "--help" over-triggers once + // `--help` appears anywhere, even past an unconsumed `--` sentinel, + // where pflag never parses it as a flag at all (verified empirically + // against a real apps/cli-go build: `db dump -- --help ""` returns + // zero candidates with the DEFAULT directive — `db dump`'s own file + // completion — not the help short-circuit's NoFileComp — CLI-1965 + // review finding). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "db", + "dump", + "--", + "--help", + "", + ]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("does not short-circuit on --version consumed as a PRECEDING flag's own string value", () => { + // `--workdir` is a value-taking global flag; pflag consumes the very + // next token as its value regardless of what that token looks like, + // so `--version` here is `--workdir`'s value, never parsed as a flag + // occurrence (verified empirically against a real apps/cli-go build: + // `--workdir --version br` still completes `branches`, not the + // version short-circuit — CLI-1965 review finding). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "--workdir", + "--version", + "br", + ]); + expect(result?.candidates.map((c) => c.name)).toContain("branches"); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + }); + }); + + describe("unmatched root-level command (CLI-1965 review)", () => { + it("returns Default with zero candidates for a flag typed after an unmatched ROOT-level positional", () => { + // Mirrors cobra's Command.Find -> legacyArgs (args.go:28-37): resolving + // to root itself (no descent at all) with a leftover positional is an + // "unknown command" error there, wins even over the --help/--version + // short-circuit, and is stricter than the same situation under any + // OTHER resolved command (verified empirically against a real + // apps/cli-go build: `nosuch --d` and `nosuch --help` both return zero + // candidates with the Default directive, while `db bogus --d` — `db` + // itself resolves — still offers `db`'s own --debug/--dns-resolver + // normally — CLI-1965 review finding). + const unknownRoot = legacyRespondToComplete(legacyRoot, ["__complete", "nosuch", "--d"]); + expect(unknownRoot).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + + const unknownRootWithHelp = legacyRespondToComplete(legacyRoot, [ + "__complete", + "nosuch", + "--help", + ]); + expect(unknownRootWithHelp).toEqual({ + candidates: [], + directive: LegacyCompletionDirective.Default, + }); + + const knownCommandWithLeftover = legacyRespondToComplete(legacyRoot, [ + "__complete", + "db", + "bogus", + "--d", + ]); + expect(knownCommandWithLeftover?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["--debug", "--dns-resolver"]), + ); + }); + + it("does not treat a surviving bare `-` leftover as an unmatched command (pflag's stripFlags drops it)", () => { + // cobra's own stripFlags (command.go:674-710) — which legacyArgs' error + // check runs against — drops a lone `-` from its leftover count + // entirely, unlike legacyResolveCommandPath's own leftoverArgs (which + // deliberately keeps it for other purposes) (verified empirically + // against a real apps/cli-go build: `__complete - --d` still offers + // root's own --debug/--dns-resolver — CLI-1965 review finding). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "-", "--d"]); + expect(result?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["--debug", "--dns-resolver"]), + ); + }); + + it("does not apply the unmatched-root check to a genuine `help ...` request", () => { + // `help` is not a real node in this tree (see + // legacyHelpArgumentCandidates's doc comment), so the outer resolution + // always sees it as an immediate non-match at root — this must not be + // mistaken for cobra's real "unknown command" error, which never fires + // for `help` since real cobra's help command IS a real child of root. + const result = legacyRespondToComplete(legacyRoot, ["__complete", "help", "db", "d"]); + expect(result?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["diff", "dump"]), + ); + }); }); describe("required-flag short-circuit", () => { @@ -664,6 +761,132 @@ describe("legacyRespondToComplete", () => { ); }); + it("rejects a value one past int64 max for a plain (non-uint) integer flag", () => { + // Go's plain int64 flags parse via strconv.ParseInt(s, 0, 64), a + // NARROWER signed range than the uint64 bound `Flag.integer` alone + // would suggest — 9223372036854775808 is a syntactically valid uint64 + // but exceeds int64 max by one (verified empirically against a real + // apps/cli-go build: `backups restore --timestamp 9223372036854775808 + // --p` returns zero candidates with the Default directive, while + // int64's real bounds, 9223372036854775807 and -9223372036854775808, + // both still offer --profile/--project-ref — CLI-1965 review finding). + const overflow = legacyRespondToComplete(legacyRoot, [ + "__complete", + "backups", + "restore", + "--timestamp", + "9223372036854775808", + "--p", + ]); + expect(overflow).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + + const max = legacyRespondToComplete(legacyRoot, [ + "__complete", + "backups", + "restore", + "--timestamp", + "9223372036854775807", + "--p", + ]); + expect(max?.candidates.map((c) => c.name)).toContain("--profile"); + + const min = legacyRespondToComplete(legacyRoot, [ + "__complete", + "backups", + "restore", + "--timestamp", + "-9223372036854775808", + "--p", + ]); + expect(min?.candidates.map((c) => c.name)).toContain("--profile"); + }); + + it("rejects a malformed value for Go's DurationVar flags (gen types --query-timeout, gen bearer-jwt --valid-for)", () => { + // Both are declared Flag.string in TS but DurationVar in Go + // (cmd/gen.go:161,179), parsed via time.ParseDuration before Cobra + // generates completions (verified empirically against a real + // apps/cli-go build: both `bogus` values return zero candidates with + // the Default directive, while `5s`/`1h` still complete normally — + // CLI-1965 review finding). + const queryTimeout = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "types", + "--query-timeout", + "bogus", + "--l", + ]); + expect(queryTimeout).toEqual({ + candidates: [], + directive: LegacyCompletionDirective.Default, + }); + + const queryTimeoutValid = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "types", + "--query-timeout", + "5s", + "--l", + ]); + expect(queryTimeoutValid?.candidates.map((c) => c.name)).toContain("--local"); + + const validFor = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--valid-for", + "bogus", + "--p", + ]); + expect(validFor).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + + const validForValid = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--valid-for", + "1h", + "--p", + ]); + expect(validForValid?.candidates.map((c) => c.name)).toContain("--profile"); + }); + + it("rejects a malformed value for Go's TimeVar flag (gen bearer-jwt --exp, RFC3339 only)", () => { + // Declared Flag.string in TS but a TimeVar constrained to time.RFC3339 + // in Go (cmd/gen.go:178) (verified empirically against a real + // apps/cli-go build: `bogus` returns zero candidates with the Default + // directive, while a real RFC3339 timestamp still completes normally + // — CLI-1965 review finding). + const invalid = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--exp", + "bogus", + "--p", + ]); + expect(invalid).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + + const valid = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--exp", + "2024-01-02T15:04:05Z", + "--p", + ]); + expect(valid?.candidates.map((c) => c.name)).toContain("--profile"); + }); + it("rejects a negative value for storage cp --jobs even though it's a string-typed flag in TS", () => { // Go registers --jobs as a UintVarP (cmd/storage.go:107), the same as // functions deploy/migration down/db reset above — but storage cp @@ -936,7 +1159,7 @@ describe("legacyCollectInScopeFlags", () => { name: "debug", aliases: [], hidden: false, - description: "Output debug logs to stderr.", + description: "output debug logs to stderr", isVariadic: false, isBoolean: true, primitiveTag: "Boolean", @@ -982,6 +1205,43 @@ describe("legacyCollectInScopeFlags", () => { expect(outputFlags).toHaveLength(1); expect(outputFlags[0]?.description).toBe("Write explicit diff output to a file path."); }); + + it("orders flags like cobra's InheritedFlags().VisitAll then NonInheritedFlags().VisitAll — alphabetical within each block, not declaration order (CLI-1965 review)", () => { + // pflag's VisitAll sorts by canonical (long) flag name; cobra's + // completion path walks the inherited (ancestor) set first, then the + // resolved command's own set — TWO separately-sorted runs, not one + // merged alphabetical list (verified empirically against a real + // apps/cli-go build: `db dump -` lists --agent, --create-ticket, + // --debug, ... alphabetically, THEN a second alphabetical run starting + // --data-only, --db-url, --dry-run, ... — CLI-1965 review finding). + const { commandChain } = legacyResolveCommandPath(legacyRoot, ["db", "dump"]); + const names = legacyCollectInScopeFlags(legacyRoot, commandChain).map((flag) => flag.name); + + const inheritedEnd = names.indexOf("yes"); // last inherited flag, alphabetically + const ownStart = names.indexOf("data-only"); // first own/local flag, alphabetically + expect(inheritedEnd).toBeGreaterThanOrEqual(0); + expect(ownStart).toBeGreaterThan(inheritedEnd); + + const inheritedBlock = names.slice(0, ownStart); + const ownBlock = names.slice(ownStart); + expect(inheritedBlock).toEqual([...inheritedBlock].sort((a, b) => a.localeCompare(b))); + expect(ownBlock).toEqual([...ownBlock].sort((a, b) => a.localeCompare(b))); + // --help is db dump's own NonInherited flag (every command registers its + // own), not part of the shared inherited block. + expect(inheritedBlock).not.toContain("help"); + expect(ownBlock).toContain("help"); + }); + + it("orders root's own flags alphabetically end-to-end (InheritedFlags() is empty at root)", () => { + const atRoot = legacyCollectInScopeFlags( + legacyRoot, + legacyResolveCommandPath(legacyRoot, []).commandChain, + ); + // `output-format` is TS-only surface with no Go equivalent, so it isn't + // asserted here — every OTHER root flag name must still come out sorted. + const names = atRoot.map((flag) => flag.name).filter((name) => name !== "output-format"); + expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b))); + }); }); describe("legacyClassifyCompletion", () => { diff --git a/apps/cli/src/legacy/shared/legacy-parse-uint.ts b/apps/cli/src/legacy/shared/legacy-parse-uint.ts index b2a37c43c2..fc7eae66a1 100644 --- a/apps/cli/src/legacy/shared/legacy-parse-uint.ts +++ b/apps/cli/src/legacy/shared/legacy-parse-uint.ts @@ -1,12 +1,15 @@ /** - * Faithful port of Go's `strconv.ParseUint(s, 0, 64)` — the exact parser pflag - * runs for a `UintVarP`/`UintVar` flag (`uintValue.Set`, `pflag/uint.go`). + * Faithful port of Go's `strconv.ParseUint(s, 0, 64)` (`legacyParseUintBase0`) + * and `strconv.ParseInt(s, 0, 64)` (`legacyIsValidBase0Int64`) — the exact + * parsers pflag runs for `UintVarP`/`UintVar` and `Int64VarP`/`Int64Var` + * flags respectively (`uintValue.Set`/`int64Value.Set`, `pflag/{uint,int64}.go`). * Hoisted here (from its original home under `commands/storage/cp/`, CLI-1965 * review) once a second family needed it: `legacy-complete.ts` validates - * `functions deploy --jobs`/`migration down --last`/`db reset --last` — all - * three declared `Flag.integer` in TS but `UintVar`/`UintVarP` in Go — the - * same way `storage cp --jobs` already does at parse time. Operating on the - * RAW flag token (instead of a pre-normalized number) is load-bearing for + * `functions deploy --jobs`/`migration down --last`/`db reset --last` (uint) and + * `backups restore --timestamp` (int64) — all declared `Flag.integer` in TS but + * a Go pflag numeric type with a narrower, sign-and-range-sensitive parser — + * the same way `storage cp --jobs` already does at parse time. Operating on + * the RAW flag token (instead of a pre-normalized number) is load-bearing for * parity: * * - every sign prefix is rejected, including `-0` and `+1` (a numeric @@ -31,18 +34,46 @@ */ const MAX_UINT64 = (1n << 64n) - 1n; +const MAX_INT64 = (1n << 63n) - 1n; +// `strconv.ParseInt`'s negative bound has one MORE representable magnitude +// than the positive bound (two's complement) — `-9223372036854775808` is a +// valid `int64`, but `9223372036854775808` (its positive magnitude) is not. +const MAX_INT64_NEGATIVE_MAGNITUDE = 1n << 63n; export type LegacyParseUintResult = | { readonly value: number } | { readonly cause: "invalid syntax" | "value out of range" }; -export function legacyParseUintBase0(token: string): LegacyParseUintResult { +/** + * The base-0 digit grammar shared by `legacyParseUintBase0` (`ParseUint`, + * unsigned) and `legacyIsValidBase0Int64` (`ParseInt`, signed) — base + * detection (`0x`/`0o`/`0b` prefixes, else a leading `0` for octal, else + * decimal), digit accumulation, and underscore placement, all per + * `strconv/atoi.go`. Bounds the accumulated magnitude at `MAX_UINT64` — the + * widest of the two callers' limits, and therefore a safe SUPERSET bound for + * both (`MAX_INT64`/`MAX_INT64_NEGATIVE_MAGNITUDE` are both smaller): a + * magnitude that already exceeds `MAX_UINT64` is "value out of range" for + * either caller, so the exit can live here once. A value between the int64 + * bound and `MAX_UINT64` (this finding's own repro, + * `9223372036854775808` — one past int64 max, comfortably under uint64 max) + * parses successfully here and is bounded by `legacyIsValidBase0Int64`'s + * OWN, narrower check afterward instead. + * + * `token` is the value with any sign prefix already stripped by the caller + * (a sign character reaching this loop directly would fail as a non-digit, + * exactly like Go's own digit loop) — `originalToken` (WITH the sign, when + * the caller has one to give) is threaded through only for `underscoreOk`'s + * separator check, which inspects the full original spelling. + */ +function legacyParseBase0Digits( + token: string, + originalToken: string, +): { readonly n: bigint } | { readonly cause: "invalid syntax" | "value out of range" } { if (token.length === 0) return { cause: "invalid syntax" }; // Base detection for base 0 (`strconv/atoi.go`): `0x`/`0b`/`0o` prefixes // (only when at least one more character follows), else a leading `0` means - // octal, else decimal. There is NO sign handling: `-`/`+` fall through to - // the digit loop below and fail as non-digits, exactly like Go. + // octal, else decimal. let s = token; let base = 10n; if (s[0] === "0") { @@ -82,15 +113,53 @@ export function legacyParseUintBase0(token: string): LegacyParseUintResult { n = n * base + digit; if (n > MAX_UINT64) return { cause: "value out of range" }; } - if (sawUnderscore && !underscoreOk(token)) return { cause: "invalid syntax" }; - return { value: Number(n) }; + if (sawUnderscore && !underscoreOk(originalToken)) return { cause: "invalid syntax" }; + return { n }; +} + +export function legacyParseUintBase0(token: string): LegacyParseUintResult { + const parsed = legacyParseBase0Digits(token, token); + return "cause" in parsed ? parsed : { value: Number(parsed.n) }; +} + +/** + * Faithful port of Go's `strconv.ParseInt(s, 0, 64)` — the exact parser + * pflag runs for an `Int64VarP`/`Int64Var` flag (`int64Value.Set`, + * `pflag/int64.go`), e.g. `backups restore --timestamp` + * (`apps/cli-go/cmd/backups.go:43`). Only a syntax-and-range VERDICT is + * needed for completion (not the parsed value), so this returns a boolean + * rather than mirroring `LegacyParseUintResult`'s shape. + * + * Reuses {@link legacyParseBase0Digits} for the base/digit grammar (the + * same one `legacyParseUintBase0` runs) on the sign-stripped remainder, then + * applies `ParseInt`'s own two-step design: strip an optional leading + * `+`/`-`, parse the magnitude, and bound it against `int64`'s asymmetric + * two's-complement range — `-9223372036854775808` is valid, but that same + * magnitude, `9223372036854775808`, is NOT (it is one past `int64`'s + * positive bound, `9223372036854775807`) — verified empirically against a + * real `apps/cli-go` build: `backups restore --timestamp + * 9223372036854775808 --p` returns zero candidates with the Default + * directive, while `--timestamp 9223372036854775807` (`int64` max) and + * `--timestamp -9223372036854775808` (`int64` min) both still offer + * `--profile`/`--project-ref` — CLI-1965 review finding. + */ +export function legacyIsValidBase0Int64(token: string): boolean { + const isNegative = token[0] === "-"; + const unsigned = isNegative || token[0] === "+" ? token.slice(1) : token; + const parsed = legacyParseBase0Digits(unsigned, token); + if ("cause" in parsed) return false; + return parsed.n <= (isNegative ? MAX_INT64_NEGATIVE_MAGNITUDE : MAX_INT64); } /** * Go's `underscoreOK` (`strconv/atoi.go`): underscores must sit between * digits, or between the base prefix and the first digit (`0x_10` is valid). * The sign skip is unreachable through `legacyParseUintBase0` (a sign already - * fails the digit loop) but is kept for fidelity to the Go source. + * fails the digit loop before `underscoreOk` is ever reached) but IS reachable + * through `legacyIsValidBase0Int64`, which passes the original, still-signed + * token through for this check specifically (see that function's doc + * comment) — kept unconditionally rather than split per-caller so both stay + * governed by one port of Go's source. */ function underscoreOk(token: string): boolean { // `saw` tracks the class of the previous character: `^` start-of-number, diff --git a/apps/cli/src/legacy/shared/legacy-parse-uint.unit.test.ts b/apps/cli/src/legacy/shared/legacy-parse-uint.unit.test.ts index b446817b35..5185e5a4bd 100644 --- a/apps/cli/src/legacy/shared/legacy-parse-uint.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-parse-uint.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { legacyParseUintBase0 } from "./legacy-parse-uint.ts"; +import { legacyIsValidBase0Int64, legacyParseUintBase0 } from "./legacy-parse-uint.ts"; // Every expectation in this file is ground truth captured from go1.26: // `strconv.ParseUint(s, 0, 64)` — the exact call pflag makes for a `UintVarP` @@ -62,3 +62,41 @@ describe("legacyParseUintBase0 (Go strconv.ParseUint(s, 0, 64) parity)", () => { }); }); }); + +// Every expectation in this file is ground truth captured from go1.26: +// `strconv.ParseInt(s, 0, 64)` — the exact call pflag makes for an +// `Int64VarP` flag (`int64Value.Set`, `pflag/int64.go`), e.g. `backups +// restore --timestamp` (`apps/cli-go/cmd/backups.go:43`). +describe("legacyIsValidBase0Int64 (Go strconv.ParseInt(s, 0, 64) parity)", () => { + it("accepts int64's exact bounds, both signs", () => { + expect(legacyIsValidBase0Int64("9223372036854775807")).toBe(true); // int64 max + expect(legacyIsValidBase0Int64("-9223372036854775808")).toBe(true); // int64 min + }); + + it("rejects a magnitude one past int64's bound on each side — the asymmetric two's-complement range", () => { + // 9223372036854775808 is a syntactically valid uint64 (well under + // MAX_UINT64) but exceeds int64's positive bound by exactly one. + expect(legacyIsValidBase0Int64("9223372036854775808")).toBe(false); + // -9223372036854775809's magnitude, 9223372036854775809, exceeds int64's + // negative-side bound (9223372036854775808) by one too. + expect(legacyIsValidBase0Int64("-9223372036854775809")).toBe(false); + }); + + it("still enforces the uint64 ceiling for a wildly out-of-range magnitude", () => { + expect(legacyIsValidBase0Int64("18446744073709551616")).toBe(false); // one past uint64 max + }); + + it("accepts plain decimals and Go's base-0 prefix forms, signed", () => { + expect(legacyIsValidBase0Int64("0")).toBe(true); + expect(legacyIsValidBase0Int64("42")).toBe(true); + expect(legacyIsValidBase0Int64("-42")).toBe(true); + expect(legacyIsValidBase0Int64("0x10")).toBe(true); + expect(legacyIsValidBase0Int64("-0x10")).toBe(true); + }); + + it("rejects non-numeric junk the same way the uint64 parser does", () => { + expect(legacyIsValidBase0Int64("bogus")).toBe(false); + expect(legacyIsValidBase0Int64("3.5")).toBe(false); + expect(legacyIsValidBase0Int64("")).toBe(false); + }); +}); diff --git a/apps/cli/src/shared/legacy/global-flags.ts b/apps/cli/src/shared/legacy/global-flags.ts index 6e2f1f6070..03f74e840e 100644 --- a/apps/cli/src/shared/legacy/global-flags.ts +++ b/apps/cli/src/shared/legacy/global-flags.ts @@ -17,6 +17,15 @@ import { legacyViperEnvBool, legacyViperEnvBoolWithProjectFallback } from "./leg // only the values its Go counterpart does (e.g. `db query` reads `table`/`csv`, // resource commands ignore them and fall through to text). `table`/`csv` are // only meaningful to `db query`. +// +// Every description string below is copied VERBATIM (including Go's own +// lowercase, no-trailing-period house style for root persistent flags) from +// `apps/cli-go/cmd/root.go:324-333` — this text is directly user-visible now +// that native shell completion (CLI-1965) surfaces it in `__complete` +// candidate descriptions, where a prior Go-binary passthrough used to emit +// Go's own text byte-for-byte; before that, this only reached the TS-native +// `--help` renderer, whose overall layout already diverges from cobra's, so +// the mismatch was harder to notice (CLI-1965 review finding). export const LegacyOutputFlag = GlobalFlag.setting("output")({ flag: Flag.choice("output", [ "env", @@ -28,60 +37,60 @@ export const LegacyOutputFlag = GlobalFlag.setting("output")({ "csv", ] as const).pipe( Flag.withAlias("o"), - Flag.withDescription("Output format of status variables."), + Flag.withDescription("output format of status variables"), Flag.optional, ), }); export const LegacyProfileFlag = GlobalFlag.setting("profile")({ flag: Flag.string("profile").pipe( - Flag.withDescription("Use a specific profile for connecting to Supabase API."), + Flag.withDescription("use a specific profile for connecting to Supabase API"), Flag.withDefault("supabase"), ), }); export const LegacyDebugFlag = GlobalFlag.setting("debug")({ - flag: Flag.boolean("debug").pipe(Flag.withDescription("Output debug logs to stderr.")), + flag: Flag.boolean("debug").pipe(Flag.withDescription("output debug logs to stderr")), }); export const LegacyWorkdirFlag = GlobalFlag.setting("workdir")({ flag: Flag.string("workdir").pipe( - Flag.withDescription("Path to a Supabase project directory."), + Flag.withDescription("path to a Supabase project directory"), Flag.optional, ), }); export const LegacyExperimentalFlag = GlobalFlag.setting("experimental")({ - flag: Flag.boolean("experimental").pipe(Flag.withDescription("Enable experimental features.")), + flag: Flag.boolean("experimental").pipe(Flag.withDescription("enable experimental features")), }); export const LegacyNetworkIdFlag = GlobalFlag.setting("network-id")({ flag: Flag.string("network-id").pipe( - Flag.withDescription("Use the specified Docker network instead of a generated one."), + Flag.withDescription("use the specified docker network instead of a generated one"), Flag.optional, ), }); export const LegacyYesFlag = GlobalFlag.setting("yes")({ - flag: Flag.boolean("yes").pipe(Flag.withDescription("Answer yes to all prompts.")), + flag: Flag.boolean("yes").pipe(Flag.withDescription("answer yes to all prompts")), }); export const LegacyDnsResolverFlag = GlobalFlag.setting("dns-resolver")({ flag: Flag.choice("dns-resolver", ["native", "https"] as const).pipe( - Flag.withDescription("Look up domain names using the specified resolver."), + Flag.withDescription("lookup domain names using the specified resolver"), Flag.withDefault("native" as const), ), }); export const LegacyCreateTicketFlag = GlobalFlag.setting("create-ticket")({ flag: Flag.boolean("create-ticket").pipe( - Flag.withDescription("Create a support ticket for any CLI error."), + Flag.withDescription("create a support ticket for any CLI error"), ), }); export const LegacyAgentFlag = GlobalFlag.setting("agent")({ flag: Flag.choice("agent", ["auto", "yes", "no"] as const).pipe( - Flag.withDescription("Override agent detection: yes, no, or auto (default auto)."), + Flag.withDescription("Override agent detection: yes, no, or auto (default auto)"), Flag.withDefault("auto" as const), ), }); From 8ca3966720f8a403ec8b4582d27a339bb840700e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 6 Aug 2026 00:20:42 +0100 Subject: [PATCH 09/14] fix(cli): reject out-of-range RFC3339 zone offsets in shell completion (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's time.Parse(time.RFC3339, s) independently caps the offset hour at 24 (not 23) and the offset minute at 60 (not 59), rejecting anything above those bounds. The completion validator's regex had no bound on the offset at all, and its doc comment claimed Go "never range-checks the offset" — disproven empirically against a real go1.26 build (+25:00 and +00:61 both error with "time zone offset hour/minute out of range", while +24:00 and +00:60 parse successfully). Capture the offset hour/minute and validate them against the same 24/60 bounds real Go uses. --- apps/cli/src/legacy/cli/legacy-complete.ts | 41 ++++++++++------ .../legacy/cli/legacy-complete.unit.test.ts | 49 +++++++++++++++++++ 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/legacy/cli/legacy-complete.ts b/apps/cli/src/legacy/cli/legacy-complete.ts index d0362232c5..4b713049f1 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.ts @@ -765,32 +765,41 @@ function legacyIsValidGoDuration(value: string): boolean { * Mirrors Go's `time.Parse(time.RFC3339, s)` — `2006-01-02T15:04:05Z07:00` * — exact 4/2/2/2/2/2-digit date-time fields, a literal (case-sensitive) `T` * separator, an optional `.`-prefixed fractional-seconds run of any length, - * and a `Z` or `±HH:MM` offset with NO numeric bound of its own (verified - * against go1.26 `time.Parse`: `"2024-01-02T15:04:05+24:00"` parses - * successfully — Go never range-checks the offset). Hour/minute/second are - * bounded to `0-23`/`0-59`/`0-59` (Go rejects `":60"` — no leap-second - * allowance — and `"25:"` — verified empirically). Month/day validity - * (including leap years, and short months like April's 30 days) is checked - * by round-tripping the parsed year/month/day through `Date#setUTCFullYear` - * and comparing what comes back — that method (unlike the `Date` constructor - * or `Date.UTC`) does NOT special-case a 0-99 year into 1900+year, so it - * stays correct for Go's own accepted `"0000-01-02T15:04:05Z"`, and its - * normal calendar-overflow behavior (Feb 29 rolling to Mar 1 in a - * non-leap year, day 32 rolling into the next month, month 13 rolling into - * the next year) exactly reproduces Go's own leap-year and day/month-bounds - * rejections without hand-rolling the calendar math. + * and a `Z` or `±HH:MM` offset. Hour/minute/second are bounded to + * `0-23`/`0-59`/`0-59` (Go rejects `":60"` — no leap-second allowance — and + * `"25:"` — verified empirically). The offset's own hour/minute fields are + * ALSO bounded, but not to that same 0-23/0-59 range: Go's zone-offset + * parser independently caps the offset hour at 24 (not 23) and the offset + * minute at 60 (not 59), each checked in isolation rather than as a combined + * "total offset <= 24h" (re-verified against go1.26 `time.Parse` after this + * was disputed on review: `"+24:00"`, `"+23:59"`, `"+24:60"`, and `"+00:60"` + * all parse successfully; `"+25:00"` and `"+00:61"` both fail with "time + * zone offset hour/minute out of range" — CLI-1965 review finding; the + * earlier claim here that Go "never range-checks the offset" was wrong). + * Month/day validity (including leap years, and short months like April's + * 30 days) is checked by round-tripping the parsed year/month/day through + * `Date#setUTCFullYear` and comparing what comes back — that method (unlike + * the `Date` constructor or `Date.UTC`) does NOT special-case a 0-99 year + * into 1900+year, so it stays correct for Go's own accepted + * `"0000-01-02T15:04:05Z"`, and its normal calendar-overflow behavior (Feb + * 29 rolling to Mar 1 in a non-leap year, day 32 rolling into the next + * month, month 13 rolling into the next year) exactly reproduces Go's own + * leap-year and day/month-bounds rejections without hand-rolling the + * calendar math. */ const GO_RFC3339_PATTERN = - /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/; function legacyIsValidGoRfc3339(value: string): boolean { const match = GO_RFC3339_PATTERN.exec(value); if (match === null) return false; - const [, year, month, day, hour, minute, second] = match; + const [, year, month, day, hour, minute, second, offsetHour, offsetMinute] = match; const y = Number(year); const mo = Number(month); const d = Number(day); if (Number(hour) > 23 || Number(minute) > 59 || Number(second) > 59) return false; + if (offsetHour !== undefined && (Number(offsetHour) > 24 || Number(offsetMinute) > 60)) + return false; const roundTrip = new Date(0); roundTrip.setUTCFullYear(y, mo - 1, d); diff --git a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts index 61e5e33a31..0abe89b910 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts @@ -887,6 +887,55 @@ describe("legacyRespondToComplete", () => { expect(valid?.candidates.map((c) => c.name)).toContain("--profile"); }); + it("rejects an out-of-range RFC3339 zone offset for gen bearer-jwt --exp", () => { + // Go's time.Parse(time.RFC3339, s) independently caps the offset hour + // at 24 (not 23) and the offset minute at 60 (not 59) — verified + // empirically against go1.26 time.Parse: "+24:00" and "+00:60" parse + // successfully, while "+25:00" and "+00:61" both fail with "time zone + // offset hour/minute out of range" (CLI-1965 review finding). + const outOfRangeHour = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--exp", + "2024-01-02T15:04:05+25:00", + "--p", + ]); + expect(outOfRangeHour).toEqual({ + candidates: [], + directive: LegacyCompletionDirective.Default, + }); + + const outOfRangeMinute = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--exp", + "2024-01-02T15:04:05+00:61", + "--p", + ]); + expect(outOfRangeMinute).toEqual({ + candidates: [], + directive: LegacyCompletionDirective.Default, + }); + + const boundaryValid = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--exp", + "2024-01-02T15:04:05+24:00", + "--p", + ]); + expect(boundaryValid?.candidates.map((c) => c.name)).toContain("--profile"); + }); + it("rejects a negative value for storage cp --jobs even though it's a string-typed flag in TS", () => { // Go registers --jobs as a UintVarP (cmd/storage.go:107), the same as // functions deploy/migration down/db reset above — but storage cp From 48d0178a3ba36aeaf2a912b4204f61fdd5e9f1c1 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 6 Aug 2026 01:23:17 +0100 Subject: [PATCH 10/14] fix(cli): accept comma-separated RFC3339 fractional seconds in shell completion (review) Go's time.Parse(time.RFC3339, s) accepts either "." or "," before the fractional-seconds digits, but the shell-completion validator for `gen bearer-jwt --exp` only recognized ".". Verified empirically against go1.26 time.Parse before widening the regex. --- apps/cli/src/legacy/cli/legacy-complete.ts | 51 ++++++++++--------- .../legacy/cli/legacy-complete.unit.test.ts | 50 ++++++++++++++++++ 2 files changed, 78 insertions(+), 23 deletions(-) diff --git a/apps/cli/src/legacy/cli/legacy-complete.ts b/apps/cli/src/legacy/cli/legacy-complete.ts index 4b713049f1..8c48d0d73e 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.ts @@ -764,31 +764,36 @@ function legacyIsValidGoDuration(value: string): boolean { /** * Mirrors Go's `time.Parse(time.RFC3339, s)` — `2006-01-02T15:04:05Z07:00` * — exact 4/2/2/2/2/2-digit date-time fields, a literal (case-sensitive) `T` - * separator, an optional `.`-prefixed fractional-seconds run of any length, - * and a `Z` or `±HH:MM` offset. Hour/minute/second are bounded to - * `0-23`/`0-59`/`0-59` (Go rejects `":60"` — no leap-second allowance — and - * `"25:"` — verified empirically). The offset's own hour/minute fields are - * ALSO bounded, but not to that same 0-23/0-59 range: Go's zone-offset - * parser independently caps the offset hour at 24 (not 23) and the offset - * minute at 60 (not 59), each checked in isolation rather than as a combined - * "total offset <= 24h" (re-verified against go1.26 `time.Parse` after this - * was disputed on review: `"+24:00"`, `"+23:59"`, `"+24:60"`, and `"+00:60"` - * all parse successfully; `"+25:00"` and `"+00:61"` both fail with "time - * zone offset hour/minute out of range" — CLI-1965 review finding; the - * earlier claim here that Go "never range-checks the offset" was wrong). - * Month/day validity (including leap years, and short months like April's - * 30 days) is checked by round-tripping the parsed year/month/day through - * `Date#setUTCFullYear` and comparing what comes back — that method (unlike - * the `Date` constructor or `Date.UTC`) does NOT special-case a 0-99 year - * into 1900+year, so it stays correct for Go's own accepted - * `"0000-01-02T15:04:05Z"`, and its normal calendar-overflow behavior (Feb - * 29 rolling to Mar 1 in a non-leap year, day 32 rolling into the next - * month, month 13 rolling into the next year) exactly reproduces Go's own - * leap-year and day/month-bounds rejections without hand-rolling the - * calendar math. + * separator, an optional fractional-seconds run of any length introduced by + * EITHER `.` or `,` (Go's `time` package accepts both spellings of the + * decimal mark per RFC 3339/ISO 8601 — verified empirically against go1.26 + * `time.Parse`: `"2024-01-02T15:04:05,5Z"` parses identically to + * `"...05.5Z"`; a bare separator with no following digit, e.g. `",Z"`, still + * fails, and mixing both separators in one timestamp, e.g. `".5,5Z"`, still + * fails too — CLI-1965 review finding), and a `Z` or `±HH:MM` offset. + * Hour/minute/second are bounded to `0-23`/`0-59`/`0-59` (Go rejects `":60"` + * — no leap-second allowance — and `"25:"` — verified empirically). The + * offset's own hour/minute fields are ALSO bounded, but not to that same + * 0-23/0-59 range: Go's zone-offset parser independently caps the offset + * hour at 24 (not 23) and the offset minute at 60 (not 59), each checked in + * isolation rather than as a combined "total offset <= 24h" (re-verified + * against go1.26 `time.Parse` after this was disputed on review: `"+24:00"`, + * `"+23:59"`, `"+24:60"`, and `"+00:60"` all parse successfully; `"+25:00"` + * and `"+00:61"` both fail with "time zone offset hour/minute out of range" + * — CLI-1965 review finding; the earlier claim here that Go "never + * range-checks the offset" was wrong). Month/day validity (including leap + * years, and short months like April's 30 days) is checked by round-tripping + * the parsed year/month/day through `Date#setUTCFullYear` and comparing what + * comes back — that method (unlike the `Date` constructor or `Date.UTC`) + * does NOT special-case a 0-99 year into 1900+year, so it stays correct for + * Go's own accepted `"0000-01-02T15:04:05Z"`, and its normal + * calendar-overflow behavior (Feb 29 rolling to Mar 1 in a non-leap year, + * day 32 rolling into the next month, month 13 rolling into the next year) + * exactly reproduces Go's own leap-year and day/month-bounds rejections + * without hand-rolling the calendar math. */ const GO_RFC3339_PATTERN = - /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/; + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:[.,]\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/; function legacyIsValidGoRfc3339(value: string): boolean { const match = GO_RFC3339_PATTERN.exec(value); diff --git a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts index 0abe89b910..1bd5b22401 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts @@ -936,6 +936,56 @@ describe("legacyRespondToComplete", () => { expect(boundaryValid?.candidates.map((c) => c.name)).toContain("--profile"); }); + it("accepts a comma-separated fractional second for gen bearer-jwt --exp", () => { + // Go's time.Parse(time.RFC3339, s) accepts either `.` or `,` before the + // fractional-seconds digits (verified empirically against go1.26 + // time.Parse: "2024-01-02T15:04:05,5Z" parses identically to + // "...05.5Z"; a bare "," with no following digit, or mixing both + // separators in one timestamp, both still fail — CLI-1965 review + // finding). + const commaFraction = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--exp", + "2024-01-02T15:04:05,5Z", + "--p", + ]); + expect(commaFraction?.candidates.map((c) => c.name)).toContain("--profile"); + + const emptyCommaFraction = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--exp", + "2024-01-02T15:04:05,Z", + "--p", + ]); + expect(emptyCommaFraction).toEqual({ + candidates: [], + directive: LegacyCompletionDirective.Default, + }); + + const mixedSeparators = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--exp", + "2024-01-02T15:04:05.5,5Z", + "--p", + ]); + expect(mixedSeparators).toEqual({ + candidates: [], + directive: LegacyCompletionDirective.Default, + }); + }); + it("rejects a negative value for storage cp --jobs even though it's a string-typed flag in TS", () => { // Go registers --jobs as a UintVarP (cmd/storage.go:107), the same as // functions deploy/migration down/db reset above — but storage cp From d82de4529aefc2e697ccaa0e3f9bd026dd5d76be Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 6 Aug 2026 02:24:30 +0100 Subject: [PATCH 11/14] fix(cli): enforce Go int64 nanosecond overflow bound in shell completion duration validation (review) gen types --query-timeout/gen bearer-jwt --valid-for's regex-only duration check accepted syntactically valid magnitudes that overflow time.Duration's int64 nanosecond range (e.g. 2562048h, one hour past Go's real ~292-year max), unlike real time.ParseDuration. Replaced with a BigInt accumulator that mirrors Go's actual algorithm (time/format.go) term-by-term, including its uint64 overflow bound and asymmetric final int64 range check, and verified across 30+ edge cases against a real apps/cli-go build and go1.26's time.ParseDuration directly. --- apps/cli/src/legacy/cli/legacy-complete.ts | 150 +++++++++++++++--- .../legacy/cli/legacy-complete.unit.test.ts | 30 ++++ 2 files changed, 160 insertions(+), 20 deletions(-) diff --git a/apps/cli/src/legacy/cli/legacy-complete.ts b/apps/cli/src/legacy/cli/legacy-complete.ts index 8c48d0d73e..3877ec9b8b 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.ts @@ -735,30 +735,140 @@ const LEGACY_COMPLETION_DURATION_FLAGS: ReadonlySet = new Set([ */ const LEGACY_COMPLETION_RFC3339_FLAGS: ReadonlySet = new Set(["gen bearer-jwt:exp"]); +/** Nanosecond scale for each unit `time.ParseDuration`'s `unitMap` accepts (`time/format.go`). */ +const GO_DURATION_UNIT_NANOS: ReadonlyMap = new Map([ + ["ns", 1n], + ["us", 1_000n], + ["µs", 1_000n], // U+00B5 micro sign + ["μs", 1_000n], // U+03BC Greek mu + ["ms", 1_000_000n], + ["s", 1_000_000_000n], + ["m", 60_000_000_000n], + ["h", 3_600_000_000_000n], +]); + +// `time.ParseDuration` accumulates into a `uint64` and range-checks against +// `1<<63` mid-parse, only narrowing to the `int64` max (`1<<63 - 1`) in the +// final non-negative check — see `legacyIsValidGoDuration` below. +const GO_DURATION_UINT64_OVERFLOW_BOUND = 1n << 63n; +const GO_DURATION_MAX_INT64 = (1n << 63n) - 1n; + +function legacyIsAsciiDigit(char: string | undefined): boolean { + return char !== undefined && char >= "0" && char <= "9"; +} + /** - * Mirrors Go's `time.ParseDuration` grammar (`time/format.go`): an optional - * sign, then either the literal `0` alone, or one or more - * `` terms concatenated (`1h30m`, `1.5h`, `.5s`) — every unit - * pflag's duration parser accepts: `ns`, `us`/`µs`/`μs`, `ms`, `s`, `m`, `h`. - * A bare number with no unit (`"5"`), a unit with no leading digits (`"h"`), - * or anything that fails to fully consume (trailing/leading garbage) is - * rejected, matching Go's "missing unit"/"invalid duration" errors (verified - * against go1.26 `time.ParseDuration`: `"300ms"`/`"1.5h"`/`"2h45m"`/ - * `"-1.5h"`/`"0"`/`".5s"` → valid; `"bogus"`/`"5"`/`"0.0"`/`"1_0s"` → - * invalid). + * Faithful port of Go's `time.ParseDuration` (`time/format.go`) — the exact + * parser pflag runs for a `DurationVar` flag's `Set` — as a syntax-and-range + * VERDICT (completion only needs a boolean, not the parsed `Duration`, + * mirroring `legacyIsValidBase0Int64`'s own shape). An optional sign, then + * either the literal `0` alone, or one or more `[.]` terms + * concatenated (`1h30m`, `1.5h`, `.5s`), accumulating nanoseconds as Go does: + * `BigInt` for the integer/fraction accumulators (Go's `uint64`, which this + * mirrors exactly — no JS `Number` precision loss), and a plain `Number` + * multiply-then-`Math.trunc` for the one step Go itself does in `float64` + * (`uint64(float64(f) * (float64(unit) / scale))`) — since a JS `number` IS + * an IEEE-754 double, `Number(bigIntValue)` round-trips through the exact + * same conversion Go's `float64(f)` does, so this step is bit-for-bit + * identical to Go's, not merely an approximation of it. * - * Known residual: Go's accumulator additionally overflows (→ "invalid - * duration") for a magnitude that, once unit-scaled, exceeds `int64` - * nanoseconds (e.g. a ~20-digit hour count) — this syntax-only check doesn't - * reproduce that overflow bound, the same class of residual - * `legacyParseUintBase0`'s own doc comment already accepts for values above - * 2^53. Unreachable through any realistic completion input. + * This replaces an earlier regex-only grammar check whose own doc comment + * dismissed the overflow bound as "unreachable through any realistic + * completion input" — disputed and disproven on review (CLI-1965): Go's + * `int64` nanosecond range caps out at ~292 years, so `--query-timeout + * 2562048h` (one hour past the real max, a plausible fat-fingered value, not + * a contrived ~20-digit magnitude) is exactly the kind of input real + * completion traffic can produce, and the old check silently accepted it. + * Verified empirically against a real `apps/cli-go` build (`gen types + * --query-timeout 2562048h --l` returns zero candidates with the Default + * directive, matching `bogus`) and cross-checked this implementation against + * go1.26 `time.ParseDuration` across sign/fraction/multi-term/overflow edge + * cases, including the exact `int64` boundary (`2562047h47m16.854775807s` → + * valid, one ns more → invalid) and its negative-side asymmetry + * (`-2562047h47m16.854775808s`, `int64` min, valid — one ns more still + * invalid) — same two's-complement asymmetry `legacyIsValidBase0Int64` + * already encodes for `MAX_INT64_NEGATIVE_MAGNITUDE`. */ -const GO_DURATION_PATTERN = - /^[+-]?(?:0|(?:(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:ns|us|µs|μs|ms|s|m|h))+)$/; - function legacyIsValidGoDuration(value: string): boolean { - return GO_DURATION_PATTERN.test(value); + let rest = value; + let negative = false; + if (rest.length > 0 && (rest[0] === "-" || rest[0] === "+")) { + negative = rest[0] === "-"; + rest = rest.slice(1); + } + if (rest === "0") return true; + if (rest === "") return false; + + let total = 0n; + while (rest.length > 0) { + if (!(rest[0] === "." || legacyIsAsciiDigit(rest[0]))) return false; + + // `leadingInt`: digits before the decimal point. Go returns an error + // immediately on overflow rather than continuing to consume digits. + let i = 0; + let intPart = 0n; + while (legacyIsAsciiDigit(rest[i])) { + if (intPart > GO_DURATION_UINT64_OVERFLOW_BOUND / 10n) return false; + intPart = intPart * 10n + BigInt(rest[i] as string); + if (intPart > GO_DURATION_UINT64_OVERFLOW_BOUND) return false; + i++; + } + const hasIntDigits = i > 0; + rest = rest.slice(i); + + // `leadingFraction`: digits after `.`. Go does NOT error on overflow + // here — it just stops accumulating precision and keeps consuming. + let fracPart = 0n; + let scale = 1n; + let hasFracDigits = false; + if (rest.length > 0 && rest[0] === ".") { + rest = rest.slice(1); + let j = 0; + let fracOverflowed = false; + while (legacyIsAsciiDigit(rest[j])) { + if (!fracOverflowed) { + if (fracPart > GO_DURATION_MAX_INT64 / 10n) { + fracOverflowed = true; + } else { + const next = fracPart * 10n + BigInt(rest[j] as string); + if (next > GO_DURATION_UINT64_OVERFLOW_BOUND) { + fracOverflowed = true; + } else { + fracPart = next; + scale *= 10n; + } + } + } + j++; + } + hasFracDigits = j > 0; + rest = rest.slice(j); + } + if (!hasIntDigits && !hasFracDigits) return false; + + // Consume the unit: every character up to the next digit/`.`. + let k = 0; + while (k < rest.length && !(rest[k] === "." || legacyIsAsciiDigit(rest[k]))) k++; + if (k === 0) return false; // missing unit + const unitNanos = GO_DURATION_UNIT_NANOS.get(rest.slice(0, k)); + rest = rest.slice(k); + if (unitNanos === undefined) return false; // unknown unit + + if (intPart > GO_DURATION_UINT64_OVERFLOW_BOUND / unitNanos) return false; + let termNanos = intPart * unitNanos; + if (fracPart > 0n) { + const fractional = Number(fracPart) * (Number(unitNanos) / Number(scale)); + termNanos += BigInt(Math.trunc(fractional)); + if (termNanos > GO_DURATION_UINT64_OVERFLOW_BOUND) return false; + } + total += termNanos; + if (total > GO_DURATION_UINT64_OVERFLOW_BOUND) return false; + } + + // Go's final range check only runs for the non-negative case — the + // negative side already got the one-larger `1<<63` bound above, matching + // `int64`'s two's-complement asymmetry (see the doc comment). + return negative || total <= GO_DURATION_MAX_INT64; } /** diff --git a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts index 1bd5b22401..61b6fc77ce 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts @@ -856,6 +856,36 @@ describe("legacyRespondToComplete", () => { expect(validForValid?.candidates.map((c) => c.name)).toContain("--profile"); }); + it("rejects a duration one unit past Go's int64 nanosecond range for Go's DurationVar flags", () => { + // Go's DurationVar parses via time.ParseDuration, which accumulates + // into an int64 nanosecond count — a syntactically well-formed + // duration can still overflow that range (verified empirically + // against a real apps/cli-go build: `gen types --query-timeout + // 2562048h --l` — one hour past the real max — returns zero + // candidates with the Default directive, matching `bogus`, while the + // exact int64 max, `2562047h47m16.854775807s`, still completes + // normally — CLI-1965 review finding). + const overflow = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "types", + "--query-timeout", + "2562048h", + "--l", + ]); + expect(overflow).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + + const max = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "types", + "--query-timeout", + "2562047h47m16.854775807s", + "--l", + ]); + expect(max?.candidates.map((c) => c.name)).toContain("--local"); + }); + it("rejects a malformed value for Go's TimeVar flag (gen bearer-jwt --exp, RFC3339 only)", () => { // Declared Flag.string in TS but a TimeVar constrained to time.RFC3339 // in Go (cmd/gen.go:178) (verified empirically against a real From 905019210bbe1aee40b9c39e186807d95e97e118 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 6 Aug 2026 03:23:01 +0100 Subject: [PATCH 12/14] fix(cli): reject a dangling flag's missing value before a separate attached-value flag in shell completion (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toComplete containing `=` only ever resolves toComplete's OWN flag name (cobra's checkIfFlagCompletion flagWithEqual branch) — it never rescues a DIFFERENT, earlier dangling flag out of finalArgs, so the real ParseFlags() call still fails on it. `trailingMissingValueIsFatal` dropped the `!toComplete.includes("=")` conjunct accordingly: only whether toComplete is flag-shaped matters, not whether it has an `=`. Verified empirically against a real apps/cli-go build: `sso add --type saml --metadata-file --attribute-mapping-file=` returns zero candidates with the Default directive (Go's error is "flag needs an argument: --metadata-file"), while the previous TS logic incorrectly reached file completion for --attribute-mapping-file's own json extension. --- apps/cli/src/legacy/cli/legacy-complete.ts | 44 +++++++++++++------ .../legacy/cli/legacy-complete.unit.test.ts | 22 ++++++++++ 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/apps/cli/src/legacy/cli/legacy-complete.ts b/apps/cli/src/legacy/cli/legacy-complete.ts index 3877ec9b8b..0d2f9758f2 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.ts @@ -1094,21 +1094,34 @@ function legacyResolveShortFlagCluster( * (b) resolves to a real flag whose value `legacyIsValidFlagValue` rejects, * or (c) resolves to a real, non-boolean flag with NO value available at all * — no attached suffix and no following token — AND `toComplete` itself is a - * bare flag-shaped token (starts with `-`, no `=`). + * bare flag-shaped token (starts with `-`). * * That last condition mirrors a real two-part cobra/pflag interaction: * cobra's `checkIfFlagCompletion` only rescues a trailing incomplete flag * from `ParseFlags()` (treating it as "the flag currently being * value-completed" instead of a parse error) when `toComplete` is EMPTY or - * otherwise not itself flag-shaped (`completions.go:666-687`); when - * `toComplete` IS flag-shaped with no `=`, that rescue never happens and the + * otherwise not itself flag-shaped (`completions.go:666-687`, the `prevArg` + * branch, which strips the dangling flag out of `finalArgs` before + * `ParseFlags` ever sees it). When `toComplete` IS flag-shaped, that rescue + * never happens — `checkIfFlagCompletion` either returns immediately without + * touching `finalArgs` (no `=`, `completions.go`'s "Normal flag completion" + * early return) or extracts a flag name from `toComplete`'s OWN prefix + * before its `=` (`completions.go`'s `flagWithEqual` branch) — neither path + * strips a DIFFERENT, already-dangling flag earlier in `finalArgs`, so the * real `finalCmd.ParseFlags()` call (`completions.go:373-375`) fails - * outright on the dangling flag (verified empirically against a real - * `apps/cli-go` build: `__complete -o --d` returns zero candidates with the - * Default directive — Go's `ParseFlags` error is "flag needs an argument: - * 'o' in -o" — while `__complete -o ''` and `__complete -o pre` both instead - * fall through to flag-VALUE completion for `--output`, per - * `legacyClassifyCompletion`'s Case 2 — CLI-1965 review finding). + * outright on it. Whether `toComplete` itself contains `=` is irrelevant: + * that `=` only ever resolves `toComplete`'s own flag name, never rescues an + * earlier dangling one (verified empirically against a real `apps/cli-go` + * build: `__complete -o --d` returns zero candidates with the Default + * directive — Go's `ParseFlags` error is "flag needs an argument: 'o' in + * -o" — while `__complete -o ''` and `__complete -o pre` both instead fall + * through to flag-VALUE completion for `--output`, per + * `legacyClassifyCompletion`'s Case 2; `__complete sso add --type saml + * --metadata-file --attribute-mapping-file=` ALSO returns zero candidates + * with the Default directive — Go's `ParseFlags` error is "flag needs an + * argument: --metadata-file" — even though the current token has an `=` and + * identifies a wholly separate flag, not `--metadata-file`'s value — + * CLI-1965 review finding). * * Long flags (`--foo`, `--foo=bar`) resolve via `legacyResolveFlagFromToken` * (no first/last-character ambiguity for a `--name` token). Short flags @@ -1145,11 +1158,14 @@ function legacyFindUnresolvedFlagToken( matchedPath: ReadonlyArray, ): string | undefined { // See this function's doc comment: only a `toComplete` that's itself a - // bare flag-shaped token (no `=`) blocks cobra's "rescue" of a trailing, - // value-less flag — every other shape of `toComplete` leaves it for - // flag-VALUE completion instead, so a missing value at the end of - // `trimmedArgs` is not, by itself, unresolved in that case. - const trailingMissingValueIsFatal = toComplete.startsWith("-") && !toComplete.includes("="); + // bare flag-shaped token blocks cobra's "rescue" of a trailing, + // value-less flag — whether that token also contains `=` is irrelevant, + // since the `=` only ever resolves `toComplete`'s OWN flag name, never an + // earlier, different dangling flag. Every non-flag-shaped `toComplete` + // leaves the trailing flag for flag-VALUE completion instead, so a + // missing value at the end of `trimmedArgs` is not, by itself, unresolved + // in that case. + const trailingMissingValueIsFatal = toComplete.startsWith("-"); let index = 0; while (index < trimmedArgs.length) { diff --git a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts index 61b6fc77ce..05ec0b432d 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts @@ -519,6 +519,28 @@ describe("legacyRespondToComplete", () => { expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); }, ); + + it("rejects a dangling value-taking flag even when toComplete is a DIFFERENT flag's attached-value token", () => { + // A `toComplete` containing `=` still identifies its OWN flag name + // (checkIfFlagCompletion's flagWithEqual branch) — that never rescues + // an earlier, different dangling flag out of finalArgs, so + // ParseFlags() still fails on it (verified empirically against a real + // apps/cli-go build: `sso add --type saml --metadata-file + // --attribute-mapping-file=` returns zero candidates with the Default + // directive — Go's error is "flag needs an argument: --metadata-file" + // — even though `--attribute-mapping-file` is itself a real flag with + // a registered file-extension completion). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + "add", + "--type", + "saml", + "--metadata-file", + "--attribute-mapping-file=", + ]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); }); describe("changed-flag tracking honors a long flag's real value consumption (CLI-1965 review)", () => { From 7f546969b64c82b33f5483f06a4db1f0b15d4997 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 6 Aug 2026 15:59:47 +0100 Subject: [PATCH 13/14] fix(cli): remove Go's own completion command, now unreachable (CLI-1965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TS shim intercepts completion/__complete/__completeNoDesc before ever delegating to the bundled Go binary, so cobra's own `completion` command in apps/cli-go is dead weight. Disable it via CompletionOptions.DisableDefaultCmd on rootCmd, drop the now-pointless MarkFlagFilename completion hints on sso add/update (no runtime effect once completion is disabled), and stop the docs YAML generator from forcing completion registration for a command that no longer exists. Cobra unconditionally re-registers the hidden __complete/__completeNoDesc responder on every ExecuteC() with no opt-out (command.go's initCompleteCmd), so that protocol handler stays technically present — documented in root.go since fighting the framework for something already unreachable through the shipped CLI isn't worth it. Also fixes .github/workflows/verify-install-channels.yml's post-release "Verify Go sidecar" step, which ran `supabase completion bash` specifically because that used to proxy to the Go binary. It stopped being true earlier in this PR once completion went native in TS, so the probe silently became a no-op across all three install channels. Replace it with a direct existence+executable check for the supabase-go(.exe) sidecar in each channel's real install directory — Scoop needed its own path (~/scoop/apps/$SCOOP_NAME/current/) since its manifest only shims supabase.exe, not the sidecar, so dirname-of-resolved-supabase would have pointed at the shim directory instead. --- .github/workflows/verify-install-channels.yml | 89 ++++++++++--------- apps/cli-go/cmd/root.go | 11 +++ apps/cli-go/cmd/sso.go | 4 - apps/cli-go/docs/main.go | 1 - 4 files changed, 60 insertions(+), 45 deletions(-) diff --git a/.github/workflows/verify-install-channels.yml b/.github/workflows/verify-install-channels.yml index 606067cbaa..cf5a6983bb 100644 --- a/.github/workflows/verify-install-channels.yml +++ b/.github/workflows/verify-install-channels.yml @@ -17,9 +17,19 @@ name: Verify Install Channels # instead of trusting the manifest the publish step wrote. # # Each leg goes beyond `supabase --version` (handled by the Bun wrapper without -# touching the sidecar) and runs `supabase completion bash`, a Go-proxied -# command, so a package that omits or misplaces the colocated `supabase-go` -# sidecar fails here instead of silently shipping broken proxied commands. +# touching the sidecar) and directly checks that the `supabase-go` sidecar +# binary is present and executable in the channel's install directory, so a +# package that omits or misplaces it fails here instead of silently shipping +# a CLI whose still-Go-proxied commands (see docs/go-cli-porting-status.md) +# would fail for every user. +# +# This used to run `supabase completion bash`, since that command was +# Go-proxied. It no longer is (CLI-1965 ported shell completion to native +# TypeScript, and the Go CLI's own completion command was subsequently +# removed too), so that probe silently stopped testing the sidecar at all. +# Checking for the sidecar file directly instead of routing through some +# still-proxied command avoids repeating that mistake as more commands get +# natively ported. on: workflow_call: @@ -156,20 +166,20 @@ jobs: - name: Verify Go sidecar run: | set -euo pipefail - # `completion bash` is proxied to the colocated `supabase-go` sidecar, - # so this fails (NotFound: ChildProcess.spawn) if the package omitted - # or misplaced supabase-go, even though `--version` above passed. - out="$(supabase completion bash 2>&1)" || { - echo "${out}" - echo "Go sidecar probe failed: 'supabase completion bash' did not exit 0" >&2 + # Homebrew's `bin.install` symlinks both `supabase` and `supabase-go` + # into the same prefix bin/ directory, so the sidecar must sit right + # next to whichever `supabase` resolved from PATH. + bin_dir="$(dirname "$(command -v supabase)")" + sidecar="${bin_dir}/supabase-go" + if [ ! -e "${sidecar}" ]; then + echo "Go sidecar probe failed: ${sidecar} does not exist" >&2 exit 1 - } - printf '%s' "${out}" | grep -q "supabase" || { - echo "${out}" - echo "Go sidecar probe failed: unexpected completion output" >&2 + fi + if [ ! -x "${sidecar}" ]; then + echo "Go sidecar probe failed: ${sidecar} exists but is not executable" >&2 exit 1 - } - echo "Go sidecar probe OK" + fi + echo "Go sidecar probe OK: ${sidecar}" scoop: name: Scoop (${{ inputs.scoop_name }}) @@ -212,20 +222,19 @@ jobs: shell: bash run: | set -euo pipefail - # `completion bash` is proxied to the colocated `supabase-go` sidecar, - # so this fails if the package omitted or misplaced supabase-go.exe, - # even though `--version` above passed. - out="$(supabase completion bash 2>&1)" || { - echo "${out}" - echo "Go sidecar probe failed: 'supabase completion bash' did not exit 0" >&2 - exit 1 - } - printf '%s' "${out}" | grep -q "supabase" || { - echo "${out}" - echo "Go sidecar probe failed: unexpected completion output" >&2 + # Scoop's manifest only declares `supabase.exe` in `bin` (see + # apps/cli/scripts/update-scoop.ts), so only `supabase` gets a shim in + # ~/scoop/shims — `dirname "$(command -v supabase)"` would resolve to + # the shim directory, not the real install directory supabase-go.exe + # actually lives in. Go straight to the app's current version + # directory instead, which Scoop always maintains regardless of shims. + app_dir="${HOME}/scoop/apps/${SCOOP_NAME}/current" + sidecar="${app_dir}/supabase-go.exe" + if [ ! -e "${sidecar}" ]; then + echo "Go sidecar probe failed: ${sidecar} does not exist" >&2 exit 1 - } - echo "Go sidecar probe OK" + fi + echo "Go sidecar probe OK: ${sidecar}" install-script: name: install script (${{ matrix.runner }}) @@ -270,17 +279,17 @@ jobs: shell: bash run: | set -euo pipefail - # `completion bash` is proxied to the colocated `supabase-go` sidecar, - # so this fails if the install script did not place supabase-go next - # to supabase, even though `--version` above passed. - out="$(supabase completion bash 2>&1)" || { - echo "${out}" - echo "Go sidecar probe failed: 'supabase completion bash' did not exit 0" >&2 + # The install script places `supabase-go` right next to `supabase`, + # so the sidecar must sit in the same directory `supabase` resolved + # from on PATH. + bin_dir="$(dirname "$(command -v supabase)")" + sidecar="${bin_dir}/supabase-go" + if [ ! -e "${sidecar}" ]; then + echo "Go sidecar probe failed: ${sidecar} does not exist" >&2 exit 1 - } - printf '%s' "${out}" | grep -q "supabase" || { - echo "${out}" - echo "Go sidecar probe failed: unexpected completion output" >&2 + fi + if [ ! -x "${sidecar}" ]; then + echo "Go sidecar probe failed: ${sidecar} exists but is not executable" >&2 exit 1 - } - echo "Go sidecar probe OK" + fi + echo "Go sidecar probe OK: ${sidecar}" diff --git a/apps/cli-go/cmd/root.go b/apps/cli-go/cmd/root.go index f83eb6d11b..2ab16e2ae2 100644 --- a/apps/cli-go/cmd/root.go +++ b/apps/cli-go/cmd/root.go @@ -320,6 +320,17 @@ func init() { viper.AutomaticEnv() }) + // Shell tab-completion is fully native in the TS shim now (CLI-1965); the + // TS entrypoint intercepts completion/__complete/__completeNoDesc before + // ever delegating to this binary, so cobra's own completion command is + // unreachable dead weight here. This only removes the visible + // `completion ` command — cobra's ExecuteC() unconditionally + // (re-)registers the hidden __complete/__completeNoDesc responder on + // every run with no opt-out (command.go's initCompleteCmd), so that + // protocol handler stays present but, same as above, unreachable through + // the shipped CLI. + rootCmd.CompletionOptions.DisableDefaultCmd = true + flags := rootCmd.PersistentFlags() flags.Bool("yes", false, "answer yes to all prompts") flags.Bool("debug", false, "output debug logs to stderr") diff --git a/apps/cli-go/cmd/sso.go b/apps/cli-go/cmd/sso.go index 7c8bc9150a..3a2cc03141 100644 --- a/apps/cli-go/cmd/sso.go +++ b/apps/cli-go/cmd/sso.go @@ -163,8 +163,6 @@ func init() { ssoAddFlags.Var(&ssoNameIDFormat, "name-id-format", "URI reference representing the classification of string-based identifier information.") ssoAddCmd.MarkFlagsMutuallyExclusive("metadata-file", "metadata-url") cobra.CheckErr(ssoAddCmd.MarkFlagRequired("type")) - cobra.CheckErr(ssoAddCmd.MarkFlagFilename("metadata-file", "xml")) - cobra.CheckErr(ssoAddCmd.MarkFlagFilename("attribute-mapping-file", "json")) ssoUpdateFlags := ssoUpdateCmd.Flags() ssoUpdateFlags.StringSliceVar(&ssoDomains, "domains", []string{}, "Replace domains with this comma separated list of email domains.") @@ -178,8 +176,6 @@ func init() { ssoUpdateCmd.MarkFlagsMutuallyExclusive("metadata-file", "metadata-url") ssoUpdateCmd.MarkFlagsMutuallyExclusive("domains", "add-domains") ssoUpdateCmd.MarkFlagsMutuallyExclusive("domains", "remove-domains") - cobra.CheckErr(ssoUpdateCmd.MarkFlagFilename("metadata-file", "xml")) - cobra.CheckErr(ssoUpdateCmd.MarkFlagFilename("attribute-mapping-file", "json")) ssoShowFlags := ssoShowCmd.Flags() ssoShowFlags.BoolVar(&ssoMetadata, "metadata", false, "Show SAML 2.0 XML Metadata only") diff --git a/apps/cli-go/docs/main.go b/apps/cli-go/docs/main.go index bfacba3118..011d7ac724 100644 --- a/apps/cli-go/docs/main.go +++ b/apps/cli-go/docs/main.go @@ -47,7 +47,6 @@ func generate(version string) error { return err } root := cli.GetRootCmd() - root.InitDefaultCompletionCmd() root.InitDefaultHelpFlag() spec := SpecDoc{ Clispec: "001", From 5982223fae67250b7f8bb5f74a6c7ffab3cd8503 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 6 Aug 2026 16:32:04 +0100 Subject: [PATCH 14/14] fix(cli): restore cli_command_executed telemetry for native __complete requests (CLI-1965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every tab press against the old Go-backed CLI silently fired cli_command_executed: cobra's hidden __complete command runs through root's full PersistentPreRunE (verified empirically against a real apps/cli-go build), and Execute() captures the event for every resolved command regardless of the handler's own outcome. The native legacyTryComplete interceptor added by CLI-1965 returns before runCli's analytics pipeline ever bootstraps, so that event stopped firing entirely for completion requests — a real regression in usage/experiment data, not a cosmetic gap. legacyTryComplete is now async and awaits a new captureTelemetry dependency (matching Go: command is always the literal "__complete", never the "__completeNoDesc" alias it may have been invoked as, exit_code 0/1, output_format fixed to "text" since __complete never parses --output). Deliberately narrower than withLegacyCommandInstrumentation, which needs services (CommandRuntime/Output/ProcessControl/Stdio) this pre-Effect-bootstrap interceptor doesn't have; capture goes straight through Analytics + legacyAnalyticsLayer instead, bounded by a 2s timeout and Effect.ignore so a network hiccup never blocks a user's tab press. Also deliberately does NOT reproduce the rest of Go's Execute()/ PersistentPreRunE for __complete — profile loading, the workdir change, or the GitHub upgrade-version check — none of that has any bearing on the analytics contract, and real generated completion scripts discard this process's stderr outright, so reproducing the upgrade message would be a pure regression, not a parity fix. Documented in both the new code and completion's SIDE_EFFECTS.md so a future reviewer doesn't "fix" it back toward full Execute() parity. Adds standalone-analytics-config.layer.ts (shared/telemetry/) so this one caller outside runCli's own composed layer tree can still resolve legacyAnalyticsLayer's CliConfig/RuntimeInfo/Tty requirements. Addresses review: https://github.com/supabase/cli/pull/6083#discussion_r3728976018 --- .../cli/legacy-complete.integration.test.ts | 117 +++++++++++++++++ apps/cli/src/legacy/cli/legacy-complete.ts | 122 +++++++++++++++++- .../legacy/cli/legacy-complete.unit.test.ts | 24 ++-- apps/cli/src/legacy/cli/main.ts | 2 +- .../commands/completion/SIDE_EFFECTS.md | 40 +++++- .../standalone-analytics-config.layer.ts | 26 ++++ 6 files changed, 316 insertions(+), 15 deletions(-) create mode 100644 apps/cli/src/legacy/cli/legacy-complete.integration.test.ts create mode 100644 apps/cli/src/shared/telemetry/standalone-analytics-config.layer.ts diff --git a/apps/cli/src/legacy/cli/legacy-complete.integration.test.ts b/apps/cli/src/legacy/cli/legacy-complete.integration.test.ts new file mode 100644 index 0000000000..8e2bbec0a3 --- /dev/null +++ b/apps/cli/src/legacy/cli/legacy-complete.integration.test.ts @@ -0,0 +1,117 @@ +import { Effect, Layer } from "effect"; +import { describe, expect, it } from "vitest"; +import { CurrentAnalyticsContext } from "../../shared/telemetry/analytics-context.ts"; +import { Analytics } from "../../shared/telemetry/analytics.service.ts"; +import { EventCommandExecuted, PropExitCode } from "../../shared/telemetry/event-catalog.ts"; +import { + legacyCaptureCompleteTelemetryEffect, + legacyTryComplete, + type LegacyCompleteDeps, +} from "./legacy-complete.ts"; +import { legacyRoot } from "./root.ts"; + +// `mockAnalytics()` (`tests/helpers/mocks.ts`, the double `bash.integration.test.ts` +// uses for the same `cli_command_executed` assertion on the static completion +// leaves) records only the direct `capture(event, properties)` arguments — it +// never reads `CurrentAnalyticsContext`, so it can't see the `command` value +// `withAnalyticsContext` attaches. This local double mirrors the REAL +// `legacyAnalyticsLayer`'s own capture implementation just enough to merge +// that context in, so this file can assert on `command` the same way the +// review finding (CLI-1965) requires. +function mockAnalyticsWithContext() { + const captured: Array<{ + event: string; + properties: Record; + command: string | undefined; + }> = []; + return { + layer: Layer.succeed( + Analytics, + Analytics.of({ + capture: (event, properties = {}) => + Effect.gen(function* () { + const context = yield* CurrentAnalyticsContext; + captured.push({ event, properties, command: context.command }); + }), + identify: () => Effect.void, + alias: () => Effect.void, + groupIdentify: () => Effect.void, + }), + ), + captured, + }; +} + +function makeCaptureTelemetry( + analyticsLayer: Layer.Layer, +): LegacyCompleteDeps["captureTelemetry"] { + return (exitCode, durationMs) => + Effect.runPromise( + legacyCaptureCompleteTelemetryEffect(exitCode, durationMs).pipe( + Effect.provide(analyticsLayer), + ), + ); +} + +function makeDeps( + argv: ReadonlyArray, + captureTelemetry: LegacyCompleteDeps["captureTelemetry"], +) { + const stdoutWrites: Array = []; + const exits: Array = []; + const deps: LegacyCompleteDeps = { + root: legacyRoot, + argv, + env: {}, + stdoutWrite: (message) => { + stdoutWrites.push(message); + }, + exit: (code) => { + exits.push(code); + }, + captureTelemetry, + }; + return { deps, stdoutWrites, exits }; +} + +describe("legacy __complete telemetry (CLI-1965 review finding)", () => { + it("fires cli_command_executed with command: __complete and exit_code: 0 for a normal completion request", async () => { + const analytics = mockAnalyticsWithContext(); + const { deps } = makeDeps( + ["__complete", "migration", "li"], + makeCaptureTelemetry(analytics.layer), + ); + + expect(await legacyTryComplete(deps)).toBe(true); + + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event).toBeDefined(); + expect(event?.command).toBe("__complete"); + expect(event?.properties[PropExitCode]).toBe(0); + }); + + it("records exit_code: 1 for an unresolvable completion request (zero completion args)", async () => { + const analytics = mockAnalyticsWithContext(); + const { deps } = makeDeps(["__complete"], makeCaptureTelemetry(analytics.layer)); + + expect(await legacyTryComplete(deps)).toBe(true); + + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event).toBeDefined(); + expect(event?.properties[PropExitCode]).toBe(1); + }); + + it("records command: __complete — never __completeNoDesc — when invoked via the no-descriptions alias", async () => { + const analytics = mockAnalyticsWithContext(); + const { deps } = makeDeps( + ["__completeNoDesc", "migration", "li"], + makeCaptureTelemetry(analytics.layer), + ); + + await legacyTryComplete(deps); + + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event?.command).toBe("__complete"); + expect(event?.command).not.toBe("__completeNoDesc"); + }); +}); diff --git a/apps/cli/src/legacy/cli/legacy-complete.ts b/apps/cli/src/legacy/cli/legacy-complete.ts index 0d2f9758f2..431e9c472b 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.ts @@ -1,4 +1,5 @@ -import { Option } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Layer, Option } from "effect"; import { GlobalFlag } from "effect/unstable/cli"; import type { Command, Param, Primitive } from "effect/unstable/cli"; import process from "node:process"; @@ -9,6 +10,16 @@ import { import { legacyUnwrapParam } from "../shared/legacy-param-introspection.ts"; import { legacyIsValidBase0Int64, legacyParseUintBase0 } from "../shared/legacy-parse-uint.ts"; import { legacyParseStringSliceFlag } from "../shared/legacy-string-slice-flag.ts"; +import { withAnalyticsContext } from "../../shared/telemetry/analytics-context.ts"; +import { Analytics } from "../../shared/telemetry/analytics.service.ts"; +import { + EventCommandExecuted, + PropDurationMs, + PropExitCode, + PropOutputFormat, +} from "../../shared/telemetry/event-catalog.ts"; +import { standaloneAnalyticsConfigLayer } from "../../shared/telemetry/standalone-analytics-config.layer.ts"; +import { legacyAnalyticsLayer } from "../telemetry/legacy-analytics.layer.ts"; /** * Native TypeScript reimplementation of cobra's dynamic-completion protocol @@ -105,6 +116,16 @@ export interface LegacyCompleteDeps { readonly env: Readonly>; readonly stdoutWrite: (message: string) => void; readonly exit: (code: number) => void; + /** + * Fires the `cli_command_executed` telemetry capture for this request — + * see `legacyCaptureCompleteTelemetryEffect`'s doc comment for what it + * records and why. Injected the same way `stdoutWrite`/`exit` already are, + * so tests can run `legacyCaptureCompleteTelemetryEffect` against a mocked + * `Analytics` boundary without spawning a real subprocess or touching the + * real, consent-gated production layer `legacyDefaultCompleteDeps` wires by + * default — see `legacy-complete.integration.test.ts`. + */ + readonly captureTelemetry: (exitCode: number, durationMs: number) => Promise; } /* ========================================================================== */ @@ -1676,23 +1697,117 @@ export function legacyFormatCompletionResponse( /* Entry point */ /* ========================================================================== */ +/** + * The `cli_command_executed` capture for a `__complete`/`__completeNoDesc` + * request, matching Go's `Execute()` (`apps/cli-go/cmd/root.go:168-204`), + * which captures this event for every resolved command — including cobra's + * hidden `__complete` — regardless of the handler's own outcome (CLI-1965 + * review finding: the deleted Go passthrough fired this on every tab press; + * this native interceptor silently stopped firing it). `command` is always + * the literal `"__complete"`, never `"__completeNoDesc"`: cobra registers + * `__completeNoDesc` as an ALIAS of `__complete` (`completions.go:234`), and + * Go's `commandName()` derives the recorded value from `cmd.Name()` — the + * command's own primary name, not the alias it was invoked as. `output_format` + * is the fixed literal `"text"`: `__complete` is `DisableFlagParsing: true`, + * so there is no resolved `--output`/`-o` value to mirror here. + * + * Deliberately narrower than `withLegacyCommandInstrumentation` + * (`legacy/telemetry/legacy-command-instrumentation.ts`): that wrapper is + * shaped for a real Effect `Command` handler running inside `runCli`'s full + * runtime (`CommandRuntime`/`Output`/`ProcessControl`/`Stdio`), none of which + * exist here — this interceptor runs before Effect's argv parser, before + * `runCli` ever bootstraps. This also deliberately does NOT reproduce the + * rest of Go's `Execute()`/`PersistentPreRunE` — profile loading, the workdir + * change, or the GitHub upgrade check — none of that has any bearing on the + * analytics contract, and real generated completion scripts discard this + * process's stderr outright, so reproducing the upgrade message would be a + * pure regression. Do not "fix" this back toward full `Execute()` parity. + * + * Only requires `Analytics` from context (not the concrete production + * layer) so tests can provide `mockAnalytics()` directly instead of the + * real, consent-gated `legacyAnalyticsLayer` — see + * `legacyCaptureCompleteTelemetry` below for the production wiring, and + * `legacy-complete.integration.test.ts` for the test double usage. + */ +export function legacyCaptureCompleteTelemetryEffect( + exitCode: number, + durationMs: number, +): Effect.Effect { + return Effect.gen(function* () { + const analytics = yield* Analytics; + yield* analytics.capture(EventCommandExecuted, { + [PropExitCode]: exitCode, + [PropDurationMs]: durationMs, + [PropOutputFormat]: "text", + }); + }).pipe( + withAnalyticsContext({ + command_run_id: crypto.randomUUID(), + command: "__complete", + flags: undefined, + }), + ); +} + +const LEGACY_COMPLETE_TELEMETRY_TIMEOUT = "2 seconds"; + +// `legacyAnalyticsLayer` on its own still needs `CliConfig | RuntimeInfo | Tty` +// (via the `telemetryRuntimeLayer` it folds in) on top of the `FileSystem`/ +// `Path` platform layer — `shared/cli/run.ts` normally supplies those as part +// of its own much larger composed tree. `standaloneAnalyticsConfigLayer` +// packages the same small set for a caller running outside that tree. +const legacyCompleteAnalyticsLayer = legacyAnalyticsLayer.pipe( + Layer.provide(standaloneAnalyticsConfigLayer), + Layer.provide(BunServices.layer), +); + +/** + * Production default for `LegacyCompleteDeps.captureTelemetry`: runs + * `legacyCaptureCompleteTelemetryEffect` against the real, consent-gated + * `legacyAnalyticsLayer`. + * + * Best-effort and bounded: a missing consent, network hiccup, or DNS failure + * must never hang or fail a user's tab press. `deps.exit` (see + * `legacyTryComplete` below) ultimately calls `process.exit`, which kills the + * process immediately without waiting for pending async work — callers must + * `await` this BEFORE exiting, or the capture will very likely never reach + * PostHog. + */ +function legacyCaptureCompleteTelemetry(exitCode: number, durationMs: number): Promise { + return Effect.runPromise( + legacyCaptureCompleteTelemetryEffect(exitCode, durationMs).pipe( + Effect.provide(legacyCompleteAnalyticsLayer), + Effect.timeout(LEGACY_COMPLETE_TELEMETRY_TIMEOUT), + Effect.ignore, + ), + ); +} + /** * Entry-point interceptor with the same shape/contract as the old * `tryCompletePassthrough`: runs before Effect's CLI argv parser, returns * `false` immediately (no side effects) when `deps.argv[0]` isn't a - * completion request, otherwise fully handles it and returns `true`. + * completion request, otherwise fully handles it and returns `true`. Async + * only because of `deps.captureTelemetry` above — every other helper in this + * file (`legacyRespondToComplete` and everything it calls) stays pure and + * synchronous; awaiting the capture here, before `deps.exit(...)`, is what + * lets it actually reach PostHog (see `legacyCaptureCompleteTelemetry`'s doc + * comment). */ -export function legacyTryComplete(deps: LegacyCompleteDeps): boolean { +export async function legacyTryComplete(deps: LegacyCompleteDeps): Promise { if (deps.argv[0] !== "__complete" && deps.argv[0] !== "__completeNoDesc") return false; + const startedAt = Date.now(); const response = legacyRespondToComplete(deps.root, deps.argv); if (response === undefined) { + await deps.captureTelemetry(1, Date.now() - startedAt); deps.exit(1); return true; } const includeDescriptions = legacyResolveIncludeDescriptions(deps.argv[0], deps.env); deps.stdoutWrite(legacyFormatCompletionResponse(response, includeDescriptions)); + await deps.captureTelemetry(0, Date.now() - startedAt); deps.exit(0); return true; } @@ -1708,5 +1823,6 @@ export function legacyDefaultCompleteDeps(root: Command.Command.Any): LegacyComp exit: (code) => { process.exit(code); }, + captureTelemetry: legacyCaptureCompleteTelemetry, }; } diff --git a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts index 05ec0b432d..4bb7ec1cf3 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts @@ -1524,36 +1524,44 @@ describe("legacyTryComplete", () => { exit: (code) => { exits.push(code); }, + // A no-op here keeps this suite's own focus (candidate-computation + // wiring, not telemetry) pure and synchronous-fast — the real + // production capture (`legacyCaptureCompleteTelemetryEffect`, + // `legacyDefaultCompleteDeps`'s own default) is covered separately in + // `legacy-complete.integration.test.ts`. + captureTelemetry: async () => {}, ...overrides, }; return { deps, stdoutWrites, exits }; } - it("returns false and does nothing for non-__complete argv", () => { + // `legacyTryComplete` returns `Promise` (CLI-1965 review finding — + // it now awaits `deps.captureTelemetry` before calling `deps.exit`). + it("returns false and does nothing for non-__complete argv", async () => { const { deps, stdoutWrites, exits } = makeDeps({ argv: ["migration", "list"] }); - expect(legacyTryComplete(deps)).toBe(false); + expect(await legacyTryComplete(deps)).toBe(false); expect(stdoutWrites).toEqual([]); expect(exits).toEqual([]); }); - it("writes the formatted response to stdout and exits 0 for a real completion request", () => { + it("writes the formatted response to stdout and exits 0 for a real completion request", async () => { const { deps, stdoutWrites, exits } = makeDeps(); - expect(legacyTryComplete(deps)).toBe(true); + expect(await legacyTryComplete(deps)).toBe(true); expect(stdoutWrites).toHaveLength(1); expect(stdoutWrites[0]).toContain("list\t"); expect(stdoutWrites[0]).toMatch(/:4\n$/); expect(exits).toEqual([0]); }); - it("respects __completeNoDesc by stripping descriptions from the written response", () => { + it("respects __completeNoDesc by stripping descriptions from the written response", async () => { const { deps, stdoutWrites } = makeDeps({ argv: ["__completeNoDesc", "migration", "li"] }); - legacyTryComplete(deps); + await legacyTryComplete(deps); expect(stdoutWrites[0]).toBe("list\n:4\n"); }); - it("exits 1 and does not write anything to stdout for zero completion args", () => { + it("exits 1 and does not write anything to stdout for zero completion args", async () => { const { deps, stdoutWrites, exits } = makeDeps({ argv: ["__complete"] }); - expect(legacyTryComplete(deps)).toBe(true); + expect(await legacyTryComplete(deps)).toBe(true); expect(stdoutWrites).toEqual([]); expect(exits).toEqual([1]); }); diff --git a/apps/cli/src/legacy/cli/main.ts b/apps/cli/src/legacy/cli/main.ts index 1d62de342b..d8916ce423 100644 --- a/apps/cli/src/legacy/cli/main.ts +++ b/apps/cli/src/legacy/cli/main.ts @@ -4,6 +4,6 @@ import { legacyAnalyticsLayer } from "../telemetry/legacy-analytics.layer.ts"; import { legacyDefaultCompleteDeps, legacyTryComplete } from "./legacy-complete.ts"; import { legacyRoot } from "./root.ts"; -if (!legacyTryComplete(legacyDefaultCompleteDeps(legacyRoot))) { +if (!(await legacyTryComplete(legacyDefaultCompleteDeps(legacyRoot)))) { await runCli(legacyRoot, { analyticsLayer: legacyAnalyticsLayer }); } diff --git a/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md index 04cbd5c69c..a4e4b3e880 100644 --- a/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md @@ -8,9 +8,15 @@ ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +These are written by the dynamic `__complete`/`__completeNoDesc` responder +(`legacy/cli/legacy-complete.ts`), not by `supabase completion ` itself — +documented here for the same reason the Environment Variables section below +covers that responder's own env vars: this is the only `SIDE_EFFECTS.md` for +the completion family. + +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/telemetry.json` | JSON | Best-effort, on every `__complete`/`__completeNoDesc` request — written by the shared `TelemetryRuntime`/consent bootstrap the `cli_command_executed` capture below runs through (`legacy/telemetry/legacy-telemetry-state.layer.ts`'s file, same path/format), regardless of whether the PostHog delivery itself succeeds. | ## API Routes @@ -31,6 +37,34 @@ ever reached via a script this family generates. | `SUPABASE_COMPLETION_DESCRIPTIONS` | Program-specific override for whether `__complete` includes descriptions (Go `strconv.ParseBool` spellings: `1/t/T/TRUE/true/True` = include, `0/f/F/FALSE/false/False` = omit; anything else ignored). Checked before the generic var below. Has no effect on `__completeNoDesc`, which always omits descriptions regardless. | No | | `COBRA_COMPLETION_DESCRIPTIONS` | Generic fallback for the above, checked only when `SUPABASE_COMPLETION_DESCRIPTIONS` is unset or empty (cobra's real `getEnvConfig` precedence). | No | +### Telemetry + +Every `__complete`/`__completeNoDesc` request also fires the same +`cli_command_executed` PostHog event Go's `Execute()` fired for every resolved +command, including cobra's hidden `__complete` (`apps/cli-go/cmd/root.go:168-204`; +CLI-1965 review finding — the deleted Go binary passthrough fired this on every +tab press, and the native TS interceptor silently stopped doing so until this +was added). `command` is always the literal `"__complete"`, never +`"__completeNoDesc"` (cobra registers the latter as an alias of the former, and +Go's own telemetry records the alias-invariant primary name); `exit_code` is +`0` for a normal response (even with zero matching candidates) and `1` for an +unresolvable request (no completion args at all, see Exit Codes above); +`output_format` is always the fixed literal `"text"`, since `__complete` never +parses `--output`/`-o`. The capture is best-effort and bounded by a short +timeout (`legacy/cli/legacy-complete.ts`'s `legacyCaptureCompleteTelemetry`) — +a missing consent, network hiccup, or DNS failure never blocks or fails the +completion response itself, only adds a small delay to the process's own exit +while it's awaited. + +This deliberately does **not** reproduce the rest of Go's +`Execute()`/`PersistentPreRunE` for `__complete`: profile loading, the workdir +change, and the GitHub upgrade-version check (a real HTTP GET to +`api.github.com`, throttled to roughly once per 10h by Go's own cache file) are +all out of scope. None of those have any bearing on the analytics contract, and +real generated completion shell scripts always discard this process's stderr, +so reproducing Go's upgrade message would be a pure regression, not a parity +fix. + ## Exit Codes | Code | Condition | diff --git a/apps/cli/src/shared/telemetry/standalone-analytics-config.layer.ts b/apps/cli/src/shared/telemetry/standalone-analytics-config.layer.ts new file mode 100644 index 0000000000..3c25998087 --- /dev/null +++ b/apps/cli/src/shared/telemetry/standalone-analytics-config.layer.ts @@ -0,0 +1,26 @@ +import { Layer } from "effect"; +import { cliConfigLayer } from "../../next/config/cli-config.layer.ts"; +import { projectContextLayer } from "../../next/config/project-context.layer.ts"; +import { runtimeInfoLayer } from "../runtime/runtime-info.layer.ts"; +import { ttyLayer } from "../runtime/tty.layer.ts"; + +/** + * Resolves `CliConfig | RuntimeInfo | Tty` — the services `telemetryRuntimeLayer` + * (and, transitively, `analyticsLayer`/`legacyAnalyticsLayer`) need beyond the + * `FileSystem`/`Path` platform layer — for callers that build and run an + * `Analytics`-capturing effect OUTSIDE `runCli`'s own composed layer tree + * (`shared/cli/run.ts` already wires the equivalent of this inline for every + * command run via its own `cliConfigLayerFor`/`projectContextLayerFor` + * helpers). The one caller today is `legacy/cli/legacy-complete.ts`'s + * `__complete`/`__completeNoDesc` telemetry capture, which fires before + * `runCli` ever bootstraps. + * + * Still requires the platform layer (`FileSystem`/`Path`, e.g. + * `@effect/platform-bun`'s `BunServices.layer`) to be provided separately by + * the caller, matching `run.ts`'s own top-level `Effect.provide(BunServices.layer)`. + */ +export const standaloneAnalyticsConfigLayer = Layer.mergeAll( + cliConfigLayer.pipe(Layer.provide(projectContextLayer), Layer.provide(runtimeInfoLayer)), + runtimeInfoLayer, + ttyLayer, +);