diff --git a/.changeset/codegen-cli-arguments.md b/.changeset/codegen-cli-arguments.md new file mode 100644 index 000000000..ccd04af0e --- /dev/null +++ b/.changeset/codegen-cli-arguments.md @@ -0,0 +1,6 @@ +--- +"@croco/openapi-spec": patch +"@croco/rpc-codegen": patch +--- + +OpenAPI and RPC code generation commands now fail with stable diagnostics when options are misspelled or unsupported positional arguments are provided. diff --git a/packages/openapi-spec/src/libs/cli.ts b/packages/openapi-spec/src/libs/cli.ts index 63251d2db..78f2bbeb9 100644 --- a/packages/openapi-spec/src/libs/cli.ts +++ b/packages/openapi-spec/src/libs/cli.ts @@ -22,7 +22,7 @@ type CliOptions = { type CliParseResult = | { readonly kind: "help" } - | { readonly kind: "invalid" } + | { readonly kind: "invalid"; readonly diagnostic?: string } | { readonly kind: "run"; readonly options: CliOptions }; type CliIo = { @@ -42,6 +42,9 @@ export async function runCli(args: readonly string[], io: CliIo = defaultCliIo): } if (result.kind === "invalid") { + if (result.diagnostic) { + io.stdout(result.diagnostic); + } printHelp(io); return 1; } @@ -97,13 +100,19 @@ export async function runCli(args: readonly string[], io: CliIo = defaultCliIo): } export function parseArgs(args: readonly string[]): CliParseResult { - if (args.includes("--help") || args.includes("-h")) { + const unsupportedArgument = findUnsupportedArgument(args); + + if (unsupportedArgument) { + return { kind: "invalid", diagnostic: unsupportedArgument }; + } + + if (args.includes(CLI_FLAGS.boolean.help) || args.includes(CLI_FLAGS.boolean.helpShort)) { return { kind: "help" }; } - const controllers = getFlagValue(args, "--controllers"); - const outFile = getFlagValue(args, "--out"); - const check = args.includes("--check"); + const controllers = getFlagValue(args, CLI_FLAGS.value.controllers); + const outFile = getFlagValue(args, CLI_FLAGS.value.out); + const check = args.includes(CLI_FLAGS.boolean.check); const strictProblems = parseStrictProblems(args); const strictSchemas = parseStrictSchemas(args); @@ -116,32 +125,32 @@ export function parseArgs(args: readonly string[]): CliParseResult { options: { controllers, outFile, - title: getFlagValue(args, "--title") ?? "Croco API", - version: getFlagValue(args, "--version") ?? "1.0.0", - servers: getFlagValues(args, "--server").map((url) => ({ url })), - bearerAuthScheme: args.includes("--bearer-auth") - ? (getFlagValue(args, "--bearer-auth") ?? "bearerAuth") + title: getFlagValue(args, CLI_FLAGS.value.title) ?? "Croco API", + version: getFlagValue(args, CLI_FLAGS.value.version) ?? "1.0.0", + servers: getFlagValues(args, CLI_FLAGS.value.server).map((url) => ({ url })), + bearerAuthScheme: args.includes(CLI_FLAGS.value.bearerAuth) + ? (getFlagValue(args, CLI_FLAGS.value.bearerAuth) ?? "bearerAuth") : null, strictProblems, strictSchemas, - failOnDiagnostics: args.includes("--fail-on-diagnostics"), + failOnDiagnostics: args.includes(CLI_FLAGS.boolean.failOnDiagnostics), check, - manifestBundlePath: getFlagValue(args, "--manifest-bundle"), + manifestBundlePath: getFlagValue(args, CLI_FLAGS.value.manifestBundle), }, }; } function parseStrictProblems(args: readonly string[]): boolean | null { return parseContractGraphStrictModeFlag(args, { - strict: "--strict-problems", - compatibility: "--compatibility-problems", + strict: CLI_FLAGS.boolean.strictProblems, + compatibility: CLI_FLAGS.boolean.compatibilityProblems, }); } function parseStrictSchemas(args: readonly string[]): boolean | null { return parseContractGraphStrictModeFlag(args, { - strict: "--strict-schemas", - compatibility: "--compatibility-schemas", + strict: CLI_FLAGS.boolean.strictSchemas, + compatibility: CLI_FLAGS.boolean.compatibilitySchemas, }); } @@ -175,6 +184,60 @@ function getFlagValue(args: readonly string[], flag: string): string | null { return value && !value.startsWith("--") ? value : null; } +const CLI_FLAGS = { + value: { + bearerAuth: "--bearer-auth", + controllers: "--controllers", + manifestBundle: "--manifest-bundle", + out: "--out", + server: "--server", + title: "--title", + version: "--version", + }, + boolean: { + check: "--check", + compatibilityProblems: "--compatibility-problems", + compatibilitySchemas: "--compatibility-schemas", + failOnDiagnostics: "--fail-on-diagnostics", + help: "--help", + helpShort: "-h", + strictProblems: "--strict-problems", + strictSchemas: "--strict-schemas", + }, +} as const; + +const VALUE_FLAGS: ReadonlySet = new Set(Object.values(CLI_FLAGS.value)); +const BOOLEAN_FLAGS: ReadonlySet = new Set(Object.values(CLI_FLAGS.boolean)); + +function findUnsupportedArgument(args: readonly string[]): string | null { + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + + if (argument === undefined) { + break; + } + + if (VALUE_FLAGS.has(argument)) { + const value = args[index + 1]; + + if (value && !value.startsWith("--")) { + index += 1; + } + continue; + } + + if (BOOLEAN_FLAGS.has(argument)) { + continue; + } + + return argument.startsWith("-") + ? `[CROCO_CLI_UNKNOWN_OPTION] Unknown option "${argument}".` + : `[CROCO_CLI_UNEXPECTED_POSITIONAL] Unexpected positional argument "${argument}".`; + } + + return null; +} + function getFlagValues(args: readonly string[], flag: string): string[] { return args.flatMap((arg, index) => { if (arg !== flag) { diff --git a/packages/openapi-spec/src/tests/Cli.spec.ts b/packages/openapi-spec/src/tests/Cli.spec.ts index f8b89b202..54a3f6721 100644 --- a/packages/openapi-spec/src/tests/Cli.spec.ts +++ b/packages/openapi-spec/src/tests/Cli.spec.ts @@ -105,7 +105,7 @@ vi.mock("@croco/protocols-core", () => { }; }); -import { runCli } from "../libs/cli"; +import { parseArgs, runCli } from "../libs/cli"; describe("openapi-spec CLI", () => { let stdout: string[]; @@ -199,6 +199,61 @@ describe("openapi-spec CLI", () => { }); }); + it.each([ + [ + "a close output flag misspelling", + ["--otu"], + '[CROCO_CLI_UNKNOWN_OPTION] Unknown option "--otu".', + ], + [ + "a close check flag misspelling", + ["--chek"], + '[CROCO_CLI_UNKNOWN_OPTION] Unknown option "--chek".', + ], + [ + "an unexpected positional value", + ["openapi.json"], + '[CROCO_CLI_UNEXPECTED_POSITIONAL] Unexpected positional argument "openapi.json".', + ], + [ + "an unknown option combined with help", + ["--help", "--otu"], + '[CROCO_CLI_UNKNOWN_OPTION] Unknown option "--otu".', + ], + ])("rejects %s before loading generation modules", async (_name, arguments_, diagnostic) => { + const exitCode = await runCli( + ["--controllers", "src/controllers/**/*.ts", "--out", "generated.json", ...arguments_], + { + stdout: (message) => stdout.push(message), + }, + ); + + expect(exitCode).toBe(1); + expect(stdout[0]).toBe(diagnostic); + expect(generationModuleImports.loadControllers).toBe(0); + expect(generationModuleImports.emitOpenAPIFromContractGraph).toBe(0); + expect(fileSystemImports.writeFile).toBe(0); + }); + + it("preserves single-dash-prefixed option values", () => { + expect( + parseArgs([ + "--controllers", + "src/**/*.ts", + "--out", + "-generated.json", + "--title", + "-internal", + ]), + ).toMatchObject({ + kind: "run", + options: { + outFile: "-generated.json", + title: "-internal", + }, + }); + }); + it("validates the canonical contract graph without emitting OpenAPI", async () => { generationModuleImports.graph = { version: "croco.contract-graph.v1", diff --git a/packages/rpc-codegen/src/libs/cli.ts b/packages/rpc-codegen/src/libs/cli.ts index a2eea674d..f36bf9317 100644 --- a/packages/rpc-codegen/src/libs/cli.ts +++ b/packages/rpc-codegen/src/libs/cli.ts @@ -22,7 +22,7 @@ type CliOptions = { type CliParseResult = | { readonly kind: "help" } - | { readonly kind: "invalid" } + | { readonly kind: "invalid"; readonly diagnostic?: string } | { readonly kind: "run"; readonly options: CliOptions }; type CliIo = { @@ -42,6 +42,9 @@ export async function runCli(args: readonly string[], io: CliIo = defaultCliIo): } if (result.kind === "invalid") { + if (result.diagnostic) { + io.stdout(result.diagnostic); + } printHelp(io); return 1; } @@ -102,16 +105,22 @@ export async function runCli(args: readonly string[], io: CliIo = defaultCliIo): } export function parseArgs(args: readonly string[]): CliParseResult { - if (args.includes("--help") || args.includes("-h")) { + const unsupportedArgument = findUnsupportedArgument(args); + + if (unsupportedArgument) { + return { kind: "invalid", diagnostic: unsupportedArgument }; + } + + if (args.includes(CLI_FLAGS.boolean.help) || args.includes(CLI_FLAGS.boolean.helpShort)) { return { kind: "help" }; } - const controllers = getFlagValue(args, "--controllers"); - const frontendActionManifestPath = getFlagValue(args, "--frontend-action-manifest"); - const frontendActionManifestCheck = args.includes("--frontend-action-manifest-check"); - const manifestBundlePath = getFlagValue(args, "--manifest-bundle"); - const outDir = getFlagValue(args, "--out"); - const check = args.includes("--check"); + const controllers = getFlagValue(args, CLI_FLAGS.value.controllers); + const frontendActionManifestPath = getFlagValue(args, CLI_FLAGS.value.frontendActionManifest); + const frontendActionManifestCheck = args.includes(CLI_FLAGS.boolean.frontendActionManifestCheck); + const manifestBundlePath = getFlagValue(args, CLI_FLAGS.value.manifestBundle); + const outDir = getFlagValue(args, CLI_FLAGS.value.out); + const check = args.includes(CLI_FLAGS.boolean.check); const strictProblems = parseStrictProblems(args); const strictSchemas = parseStrictSchemas(args); const problemRuntime = parseProblemRuntime(args); @@ -136,10 +145,10 @@ export function parseArgs(args: readonly string[]): CliParseResult { manifestBundlePath, outDir, problemRuntime, - reactQuery: args.includes("--react-query"), + reactQuery: args.includes(CLI_FLAGS.boolean.reactQuery), strictProblems, strictSchemas, - failOnDiagnostics: args.includes("--fail-on-diagnostics"), + failOnDiagnostics: args.includes(CLI_FLAGS.boolean.failOnDiagnostics), check, }, }; @@ -147,15 +156,15 @@ export function parseArgs(args: readonly string[]): CliParseResult { function parseStrictProblems(args: readonly string[]): boolean | null { return parseContractGraphStrictModeFlag(args, { - strict: "--strict-problems", - compatibility: "--compatibility-problems", + strict: CLI_FLAGS.boolean.strictProblems, + compatibility: CLI_FLAGS.boolean.compatibilityProblems, }); } function parseStrictSchemas(args: readonly string[]): boolean | null { return parseContractGraphStrictModeFlag(args, { - strict: "--strict-schemas", - compatibility: "--compatibility-schemas", + strict: CLI_FLAGS.boolean.strictSchemas, + compatibility: CLI_FLAGS.boolean.compatibilitySchemas, }); } @@ -166,9 +175,63 @@ function getFlagValue(args: readonly string[], flag: string): string | null { return value && !value.startsWith("--") ? value : null; } +const CLI_FLAGS = { + value: { + controllers: "--controllers", + frontendActionManifest: "--frontend-action-manifest", + manifestBundle: "--manifest-bundle", + out: "--out", + problemRuntime: "--problem-runtime", + }, + boolean: { + check: "--check", + compatibilityProblems: "--compatibility-problems", + compatibilitySchemas: "--compatibility-schemas", + failOnDiagnostics: "--fail-on-diagnostics", + frontendActionManifestCheck: "--frontend-action-manifest-check", + help: "--help", + helpShort: "-h", + reactQuery: "--react-query", + strictProblems: "--strict-problems", + strictSchemas: "--strict-schemas", + }, +} as const; + +const VALUE_FLAGS: ReadonlySet = new Set(Object.values(CLI_FLAGS.value)); +const BOOLEAN_FLAGS: ReadonlySet = new Set(Object.values(CLI_FLAGS.boolean)); + +function findUnsupportedArgument(args: readonly string[]): string | null { + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + + if (argument === undefined) { + break; + } + + if (VALUE_FLAGS.has(argument)) { + const value = args[index + 1]; + + if (value && !value.startsWith("--")) { + index += 1; + } + continue; + } + + if (BOOLEAN_FLAGS.has(argument)) { + continue; + } + + return argument.startsWith("-") + ? `[CROCO_CLI_UNKNOWN_OPTION] Unknown option "${argument}".` + : `[CROCO_CLI_UNEXPECTED_POSITIONAL] Unexpected positional argument "${argument}".`; + } + + return null; +} + function parseProblemRuntime(args: readonly string[]): GenerateClientProblemRuntime | null { - const hasFlag = args.includes("--problem-runtime"); - const value = getFlagValue(args, "--problem-runtime"); + const hasFlag = args.includes(CLI_FLAGS.value.problemRuntime); + const value = getFlagValue(args, CLI_FLAGS.value.problemRuntime); if (!value) { return hasFlag ? null : "inline"; diff --git a/packages/rpc-codegen/src/tests/Cli.spec.ts b/packages/rpc-codegen/src/tests/Cli.spec.ts index 9c059d011..b6eedc064 100644 --- a/packages/rpc-codegen/src/tests/Cli.spec.ts +++ b/packages/rpc-codegen/src/tests/Cli.spec.ts @@ -89,7 +89,7 @@ vi.mock("../libs/loadRoutes", () => { }; }); -import { runCli } from "../libs/cli"; +import { parseArgs, runCli } from "../libs/cli"; describe("rpc-codegen CLI", () => { let stdout: string[]; @@ -211,6 +211,50 @@ describe("rpc-codegen CLI", () => { }); }); + it.each([ + [ + "a close output flag misspelling", + ["--otu"], + '[CROCO_CLI_UNKNOWN_OPTION] Unknown option "--otu".', + ], + [ + "a close check flag misspelling", + ["--chek"], + '[CROCO_CLI_UNKNOWN_OPTION] Unknown option "--chek".', + ], + [ + "an unexpected positional value", + ["client"], + '[CROCO_CLI_UNEXPECTED_POSITIONAL] Unexpected positional argument "client".', + ], + [ + "an unknown option combined with help", + ["--help", "--otu"], + '[CROCO_CLI_UNKNOWN_OPTION] Unknown option "--otu".', + ], + ])("rejects %s before loading generation modules", async (_name, arguments_, diagnostic) => { + const exitCode = await runCli( + ["--controllers", "src/controllers/**/*.ts", "--out", "generated", ...arguments_], + { + stdout: (message) => stdout.push(message), + }, + ); + + expect(exitCode).toBe(1); + expect(stdout[0]).toBe(diagnostic); + expect(generationModuleImports.loadContractGraph).toBe(0); + expect(generationModuleImports.generateClientFiles).toBe(0); + }); + + it("preserves single-dash-prefixed option values", () => { + expect(parseArgs(["--controllers", "src/**/*.ts", "--out", "-generated"])).toMatchObject({ + kind: "run", + options: { + outDir: "-generated", + }, + }); + }); + it("validates the canonical contract graph without generating clients", async () => { generationModuleImports.graph = { version: "croco.contract-graph.v1",