diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index be9eb45706..1b29aba459 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -78,10 +78,28 @@ export function shouldUseGlobalSignalInterrupt(args: ReadonlyArray): boo ); } -function formatterLayerFor(format: OutputFormat) { +function formatterLayerFor( + rootCommand: Command.Command.Any, + args: ReadonlyArray, + format: OutputFormat, +) { + const context = { rootCommand, args }; return format === "json" || format === "stream-json" - ? CliOutput.layer(jsonCliOutputFormatter()) - : CliOutput.layer(textCliOutputFormatter()); + ? CliOutput.layer(jsonCliOutputFormatter(context)) + : CliOutput.layer(textCliOutputFormatter(context)); +} + +function isErrorRecord(error: unknown): error is Record { + return typeof error === "object" && error !== null; +} + +function isExplicitHelpCause(cause: Cause.Cause): boolean { + const error = Cause.findErrorOption(cause); + if (error._tag !== "Some" || !isErrorRecord(error.value)) return false; + if (error.value["_tag"] !== "ShowHelp") return false; + + const errors = error.value["errors"]; + return !Array.isArray(errors) || errors.length === 0; } function projectContextLayerFor(runtimeLayer: Layer.Layer) { @@ -141,7 +159,7 @@ function cliProgramFor( ), ); return Command.runWith(rootCommand, { version: CLI_VERSION })(args).pipe( - Effect.provide(formatterLayerFor(outputFormat)), + Effect.provide(formatterLayerFor(rootCommand, args, outputFormat)), Effect.provide(options.analyticsLayer), Effect.provide(tracingLayer), Effect.provide(telemetryRuntimeLayer), @@ -211,7 +229,7 @@ export async function runCli(rootCommand: Command.Command.Any, options: RunCliOp const exit = yield* program.pipe(Effect.exit); if (Exit.isFailure(exit)) { const interrupted = Cause.hasInterruptsOnly(exit.cause); - if (!interrupted) { + if (!interrupted && !isExplicitHelpCause(exit.cause)) { yield* output.fail(normalizeCause(exit.cause)); } return yield* processControl.exit(interrupted ? 130 : 1); diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts new file mode 100644 index 0000000000..1cc5124f98 --- /dev/null +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts @@ -0,0 +1,235 @@ +import type { CliError, Command, HelpDoc } from "effect/unstable/cli"; + +export interface CliErrorSuggestionContext { + readonly rootCommand: Command.Command.Any; + readonly args: ReadonlyArray; +} + +export interface FormattedCliError { + readonly _tag: string; + readonly message: string; + readonly source: CliError.CliError; + readonly changed: boolean; +} + +export interface FormattedCliErrors { + readonly errors: ReadonlyArray; + readonly changed: boolean; +} + +interface CommandWithHelpDoc extends Command.Command.Any { + readonly buildHelpDoc: (path: ReadonlyArray) => HelpDoc.HelpDoc; +} + +interface MatchingCommand { + readonly command: Command.Command.Any; + readonly commandPath: ReadonlyArray; + readonly flag: HelpDoc.FlagDoc; +} + +function hasHelpDoc(command: Command.Command.Any): command is CommandWithHelpDoc { + return "buildHelpDoc" in command && typeof command.buildHelpDoc === "function"; +} + +function helpDocFor( + command: Command.Command.Any, + commandPath: ReadonlyArray, +): HelpDoc.HelpDoc | undefined { + return hasHelpDoc(command) ? command.buildHelpDoc(commandPath) : undefined; +} + +function findCommand( + root: Command.Command.Any, + pathWithoutRoot: ReadonlyArray, +): Command.Command.Any | undefined { + let current = root; + for (const segment of pathWithoutRoot) { + let next: Command.Command.Any | undefined; + for (const group of current.subcommands) { + next = group.commands.find( + (command) => command.name === segment || command.alias === segment, + ); + if (next) break; + } + if (!next) return undefined; + current = next; + } + return current; +} + +function collectDescendants( + command: Command.Command.Any, + commandPath: ReadonlyArray, +): ReadonlyArray { + const matches: Array = []; + const visit = (current: Command.Command.Any, path: ReadonlyArray) => { + for (const group of current.subcommands) { + for (const child of group.commands) { + if (child.hidden) continue; + + const childPath = [...path, child.name]; + const helpDoc = helpDocFor(child, childPath); + if (helpDoc) { + for (const flag of helpDoc.flags) { + matches.push({ command: child, commandPath: childPath, flag }); + } + } + visit(child, childPath); + } + } + }; + visit(command, commandPath); + return matches; +} + +function optionToken(option: string): string { + const withoutValue = option.split("=", 1)[0] ?? option; + return withoutValue; +} + +function normalizeOption(option: string): string { + const withoutValue = optionToken(option); + if (withoutValue.startsWith("--")) return withoutValue.slice(2); + if (withoutValue.startsWith("-")) return withoutValue.slice(1); + return withoutValue; +} + +function flagMatchesOption(flag: HelpDoc.FlagDoc, option: string): boolean { + const optionName = normalizeOption(option); + if (flag.name === optionName) return true; + if (flag.type === "boolean" && optionName === `no-${flag.name}`) return true; + return flag.aliases.includes(option); +} + +function findPathEndIndex( + args: ReadonlyArray, + pathWithoutRoot: ReadonlyArray, +): number | undefined { + if (pathWithoutRoot.length === 0) return 0; + for (let start = 0; start <= args.length - pathWithoutRoot.length; start++) { + let matches = true; + for (let offset = 0; offset < pathWithoutRoot.length; offset++) { + if (args[start + offset] !== pathWithoutRoot[offset]) { + matches = false; + break; + } + } + if (matches) return start + pathWithoutRoot.length; + } + return undefined; +} + +function inferAttemptedCommand( + args: ReadonlyArray, + currentPath: ReadonlyArray, + matches: ReadonlyArray, +): MatchingCommand | undefined { + const pathEnd = findPathEndIndex(args, currentPath.slice(1)); + const searchArgs = pathEnd === undefined ? args : args.slice(pathEnd); + for (const arg of searchArgs) { + if (arg.startsWith("-")) continue; + const match = matches.find((candidate) => { + const leaf = candidate.commandPath[candidate.commandPath.length - 1]; + return leaf === arg || candidate.command.alias === arg; + }); + if (match) return match; + } + return matches.length === 1 ? matches[0] : undefined; +} + +function formatCommandList(matches: ReadonlyArray): string { + const commands = [...new Set(matches.map((match) => `\`${match.commandPath.join(" ")}\``))]; + if (commands.length === 1) return commands[0] ?? ""; + if (commands.length === 2) return `${commands[0]} and ${commands[1]}`; + return `${commands.slice(0, -1).join(", ")}, and ${commands[commands.length - 1]}`; +} + +function formatFlagUsage(option: string, flag: HelpDoc.FlagDoc): string { + const flagToken = optionToken(option); + return flag.type === "boolean" ? flagToken : `${flagToken} `; +} + +function findValueAfterOption(args: ReadonlyArray, option: string): string | undefined { + const flagToken = optionToken(option); + if (option !== flagToken) return undefined; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (!arg) continue; + if (arg === flagToken) { + const next = args[index + 1]; + return next && !next.startsWith("-") ? next : undefined; + } + if (arg.startsWith(`${flagToken}=`)) return undefined; + } + return undefined; +} + +function buildSubcommandFlagHint( + error: CliError.UnrecognizedOption, + context: CliErrorSuggestionContext, +): { readonly hint: string; readonly consumedValue?: string } | undefined { + if (!error.command || error.command.length === 0) return undefined; + + const current = findCommand(context.rootCommand, error.command.slice(1)); + if (!current || current.subcommands.length === 0) return undefined; + + const matches = collectDescendants(current, error.command).filter((match) => + flagMatchesOption(match.flag, error.option), + ); + if (matches.length === 0) return undefined; + + const attempted = inferAttemptedCommand(context.args, error.command, matches); + const flagToken = optionToken(error.option); + const availableOn = + matches.length === 1 + ? `a flag for ${formatCommandList(matches)}` + : `available on ${formatCommandList(matches)}`; + const example = attempted + ? `, for example:\n ${attempted.commandPath.join(" ")} ${formatFlagUsage(error.option, attempted.flag)}` + : "."; + const consumedValue = + attempted && attempted.flag.type !== "boolean" + ? findValueAfterOption(context.args, error.option) + : undefined; + + return { + hint: `${flagToken} is ${availableOn}. Pass it after the subcommand${example}`, + ...(consumedValue ? { consumedValue } : {}), + }; +} + +export function formatCliErrorsForDisplay( + errors: ReadonlyArray, + context?: CliErrorSuggestionContext, +): FormattedCliErrors { + const suppressedUnknownSubcommands = new Set(); + const formatted: Array = []; + let changed = false; + + for (const error of errors) { + if (error._tag === "UnrecognizedOption" && context) { + const hint = buildSubcommandFlagHint(error, context); + if (hint) { + if (hint.consumedValue) suppressedUnknownSubcommands.add(hint.consumedValue); + changed = true; + formatted.push({ + _tag: error._tag, + message: `${error.message}\n\n Hint: ${hint.hint}`, + source: error, + changed: true, + }); + continue; + } + } + + if (error._tag === "UnknownSubcommand" && suppressedUnknownSubcommands.has(error.subcommand)) { + changed = true; + continue; + } + + formatted.push({ _tag: error._tag, message: error.message, source: error, changed: false }); + } + + return { errors: formatted, changed }; +} diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts new file mode 100644 index 0000000000..079256d354 --- /dev/null +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts @@ -0,0 +1,125 @@ +import { CliError, Command } from "effect/unstable/cli"; +import { describe, expect, it } from "vitest"; +import { legacyBranchesCommand } from "../../legacy/commands/branches/branches.command.ts"; +import { legacyNetworkRestrictionsCommand } from "../../legacy/commands/network-restrictions/network-restrictions.command.ts"; +import { formatCliErrorsForDisplay } from "./subcommand-flag-suggestions.ts"; + +const testRoot = Command.make("supabase").pipe( + Command.withSubcommands([legacyBranchesCommand, legacyNetworkRestrictionsCommand]), +); + +describe("subcommand flag placement suggestions", () => { + it("suggests moving a subcommand flag after the attempted subcommand", () => { + const errors = formatCliErrorsForDisplay( + [ + new CliError.UnrecognizedOption({ + option: "--project-ref", + command: ["supabase", "network-restrictions"], + suggestions: [], + }), + new CliError.UnknownSubcommand({ + subcommand: "jacraenyzrorgjhsdvvf", + parent: ["supabase", "network-restrictions"], + suggestions: [], + }), + ], + { + rootCommand: testRoot, + args: [ + "network-restrictions", + "--project-ref", + "jacraenyzrorgjhsdvvf", + "get", + "--experimental", + ], + }, + ); + + expect(errors.changed).toBe(true); + expect(errors.errors).toHaveLength(1); + expect(errors.errors[0]?.message).toContain( + "Unrecognized flag: --project-ref in command supabase network-restrictions", + ); + expect(errors.errors[0]?.message).toContain( + "Hint: --project-ref is available on `supabase network-restrictions get` and `supabase network-restrictions update`.", + ); + expect(errors.errors[0]?.message).toContain( + "supabase network-restrictions get --project-ref ", + ); + expect(errors.errors[0]?.message).not.toContain("Unknown subcommand"); + }); + + it("leaves unrelated unrecognized flags unchanged", () => { + const errors = formatCliErrorsForDisplay( + [ + new CliError.UnrecognizedOption({ + option: "--definitely-not-a-child-flag", + command: ["supabase", "network-restrictions"], + suggestions: [], + }), + ], + { + rootCommand: testRoot, + args: ["network-restrictions", "--definitely-not-a-child-flag", "get"], + }, + ); + + expect(errors.changed).toBe(false); + expect(errors.errors).toHaveLength(1); + expect(errors.errors[0]?.changed).toBe(false); + expect(errors.errors[0]?.message).toBe( + "Unrecognized flag: --definitely-not-a-child-flag in command supabase network-restrictions", + ); + }); + + it("omits hidden subcommands from placement hints", () => { + const errors = formatCliErrorsForDisplay( + [ + new CliError.UnrecognizedOption({ + option: "--project-ref", + command: ["supabase", "branches"], + suggestions: [], + }), + new CliError.UnknownSubcommand({ + subcommand: "abcdefghijklmnopqrst", + parent: ["supabase", "branches"], + suggestions: [], + }), + ], + { + rootCommand: testRoot, + args: ["branches", "--project-ref", "abcdefghijklmnopqrst", "get"], + }, + ); + + expect(errors.changed).toBe(true); + expect(errors.errors).toHaveLength(1); + expect(errors.errors[0]?.message).toContain("`supabase branches get`"); + expect(errors.errors[0]?.message).not.toContain("branches disable"); + }); + + it("normalizes assigned flags in placement examples", () => { + const errors = formatCliErrorsForDisplay( + [ + new CliError.UnrecognizedOption({ + option: "--project-ref=jacraenyzrorgjhsdvvf", + command: ["supabase", "network-restrictions"], + suggestions: [], + }), + ], + { + rootCommand: testRoot, + args: ["network-restrictions", "--project-ref=jacraenyzrorgjhsdvvf", "get"], + }, + ); + + expect(errors.changed).toBe(true); + expect(errors.errors[0]?.message).toContain( + "Hint: --project-ref is available on `supabase network-restrictions get` and `supabase network-restrictions update`.", + ); + expect(errors.errors[0]?.message).toContain( + "supabase network-restrictions get --project-ref ", + ); + expect(errors.errors[0]?.message).not.toContain("--project-ref=jacraenyzrorgjhsdvvf "); + }); +}); diff --git a/apps/cli/src/shared/output/json-formatter.ts b/apps/cli/src/shared/output/json-formatter.ts index e5fb754aa9..f0c127f5dd 100644 --- a/apps/cli/src/shared/output/json-formatter.ts +++ b/apps/cli/src/shared/output/json-formatter.ts @@ -1,6 +1,8 @@ import type { CliOutput, HelpDoc } from "effect/unstable/cli"; +import type { CliErrorSuggestionContext } from "../cli/subcommand-flag-suggestions.ts"; +import { formatCliErrorsForDisplay } from "../cli/subcommand-flag-suggestions.ts"; -export function jsonCliOutputFormatter(): CliOutput.Formatter { +export function jsonCliOutputFormatter(context?: CliErrorSuggestionContext): CliOutput.Formatter { return { formatHelpDoc: (doc: HelpDoc.HelpDoc) => JSON.stringify({ _tag: "Help", doc }), formatCliError: (error) => @@ -11,7 +13,10 @@ export function jsonCliOutputFormatter(): CliOutput.Formatter { formatErrors: (errors) => JSON.stringify({ _tag: "Errors", - errors: errors.map((e) => ({ code: e._tag, message: e.message })), + errors: formatCliErrorsForDisplay(errors, context).errors.map((e) => ({ + code: e._tag, + message: e.message, + })), }), }; } diff --git a/apps/cli/src/shared/output/text-formatter.ts b/apps/cli/src/shared/output/text-formatter.ts index 4b497742ed..4829507963 100644 --- a/apps/cli/src/shared/output/text-formatter.ts +++ b/apps/cli/src/shared/output/text-formatter.ts @@ -1,9 +1,46 @@ import { CliOutput } from "effect/unstable/cli"; +import type { + CliErrorSuggestionContext, + FormattedCliError, +} from "../cli/subcommand-flag-suggestions.ts"; +import { formatCliErrorsForDisplay } from "../cli/subcommand-flag-suggestions.ts"; -export function textCliOutputFormatter(): CliOutput.Formatter { +function stripSingleErrorHeader(text: string): string { + const prefix = "\nERROR\n "; + return text.startsWith(prefix) ? text.slice(prefix.length) : text.trimStart(); +} + +export function textCliOutputFormatter(context?: CliErrorSuggestionContext): CliOutput.Formatter { const base = CliOutput.defaultFormatter({ colors: false }); + const formatErrorMessage = (error: FormattedCliError) => + error.changed ? error.message : stripSingleErrorHeader(base.formatErrors([error.source])); + return { ...base, + formatErrors: (errors) => { + const formatted = formatCliErrorsForDisplay(errors, context); + if (!formatted.changed) return base.formatErrors(errors); + if (formatted.errors.length === 0) return ""; + if (formatted.errors.length === 1) { + const [error] = formatted.errors; + if (!error) return ""; + return error.changed ? `\nERROR\n ${error.message}` : base.formatErrors([error.source]); + } + + const sections = ["\nERRORS"]; + const grouped = new Map>(); + for (const error of formatted.errors) { + const group = grouped.get(error._tag) ?? []; + group.push(error); + grouped.set(error._tag, group); + } + for (const group of grouped.values()) { + for (const error of group) { + sections.push(` ${formatErrorMessage(error)}`); + } + } + return sections.join("\n"); + }, formatVersion: (_name, version) => version, }; } diff --git a/apps/cli/src/shared/output/text-formatter.unit.test.ts b/apps/cli/src/shared/output/text-formatter.unit.test.ts new file mode 100644 index 0000000000..f49cf597ea --- /dev/null +++ b/apps/cli/src/shared/output/text-formatter.unit.test.ts @@ -0,0 +1,56 @@ +import { CliError, Command } from "effect/unstable/cli"; +import { describe, expect, it } from "vitest"; +import { legacyNetworkRestrictionsCommand } from "../../legacy/commands/network-restrictions/network-restrictions.command.ts"; +import { textCliOutputFormatter } from "./text-formatter.ts"; + +const testRoot = Command.make("supabase").pipe( + Command.withSubcommands([legacyNetworkRestrictionsCommand]), +); + +describe("textCliOutputFormatter", () => { + it("preserves default parser suggestions for unchanged errors", () => { + const formatter = textCliOutputFormatter(); + + const text = formatter.formatErrors([ + new CliError.UnrecognizedOption({ + option: "--pla", + command: ["supabase", "projects", "create"], + suggestions: ["--plan"], + }), + ]); + + expect(text).toContain("Unrecognized flag: --pla in command supabase projects create"); + expect(text).toContain("Did you mean this?"); + expect(text).toContain("--plan"); + }); + + it("preserves default parser suggestions for unchanged siblings in rewritten errors", () => { + const formatter = textCliOutputFormatter({ + rootCommand: testRoot, + args: ["network-restrictions", "--project-ref", "jacraenyzrorgjhsdvvf", "get", "--pla"], + }); + + const text = formatter.formatErrors([ + new CliError.UnrecognizedOption({ + option: "--project-ref", + command: ["supabase", "network-restrictions"], + suggestions: [], + }), + new CliError.UnknownSubcommand({ + subcommand: "jacraenyzrorgjhsdvvf", + parent: ["supabase", "network-restrictions"], + suggestions: [], + }), + new CliError.UnrecognizedOption({ + option: "--pla", + command: ["supabase", "projects", "create"], + suggestions: ["--plan"], + }), + ]); + + expect(text).toContain("Hint: --project-ref is available on"); + expect(text).toContain("Unrecognized flag: --pla in command supabase projects create"); + expect(text).toContain("Did you mean this?"); + expect(text).toContain("--plan"); + }); +});