Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/codegen-cli-arguments.md
Original file line number Diff line number Diff line change
@@ -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.
95 changes: 79 additions & 16 deletions packages/openapi-spec/src/libs/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);

Expand All @@ -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,
});
}

Expand Down Expand Up @@ -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<string> = new Set(Object.values(CLI_FLAGS.value));
const BOOLEAN_FLAGS: ReadonlySet<string> = 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) {
Expand Down
57 changes: 56 additions & 1 deletion packages/openapi-spec/src/tests/Cli.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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",
Expand Down
95 changes: 79 additions & 16 deletions packages/rpc-codegen/src/libs/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
Expand All @@ -136,26 +145,26 @@ 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,
},
};
}

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,
});
}

Expand All @@ -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<string> = new Set(Object.values(CLI_FLAGS.value));
const BOOLEAN_FLAGS: ReadonlySet<string> = 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";
Expand Down
Loading
Loading