From 368082fce49a8d9f27a9c3c2c06c464065e6215f Mon Sep 17 00:00:00 2001 From: Dennis Chen Date: Wed, 21 Oct 2026 00:56:06 +0000 Subject: [PATCH 1/9] Generate API commands from the OpenAPI spec Add scripts/codegen/generate-commands.ts, which derives every API command in src/commands/ from api-spec.json using the conventions in AGENTS.md. Exceptions to the rules and help text overrides live in scripts/codegen/config.ts. - `pnpm generate:commands` regenerates src/commands/ - CI fails if the generated commands are out of date - Generated files are marked with a "Code generated" header; the generator removes generated files for groups that no longer exist The generated output matches the previous commit apart from the header. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 + AGENTS.md | 2 + package.json | 1 + scripts/codegen/config.ts | 269 +++++ scripts/codegen/generate-commands.ts | 1006 +++++++++++++++++ src/commands/business-units.ts | 1 + src/commands/contracts.ts | 1 + src/commands/controls.ts | 1 + src/commands/customer-trust.ts | 1 + src/commands/discovered-vendors.ts | 1 + src/commands/documents.ts | 1 + src/commands/event-logs.ts | 1 + src/commands/frameworks.ts | 1 + src/commands/groups.ts | 1 + src/commands/index.ts | 1 + src/commands/integrations.ts | 1 + src/commands/issues.ts | 1 + src/commands/knowledge-base.ts | 1 + src/commands/monitored-computers.ts | 1 + src/commands/people.ts | 1 + .../personnel-notification-settings.ts | 1 + src/commands/policies.ts | 1 + src/commands/program-scopes.ts | 1 + src/commands/risk-scenarios.ts | 1 + src/commands/tests.ts | 1 + src/commands/trust-centers.ts | 1 + src/commands/users.ts | 1 + src/commands/vendor-assessment-types.ts | 1 + src/commands/vendor-risk-attributes.ts | 1 + src/commands/vendors.ts | 1 + src/commands/vulnerabilities.ts | 1 + src/commands/vulnerability-remediations.ts | 1 + src/commands/vulnerable-assets.ts | 1 + 33 files changed, 1312 insertions(+) create mode 100644 scripts/codegen/config.ts create mode 100644 scripts/codegen/generate-commands.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcbdfad..883e5db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,12 @@ jobs: - name: Generate API client run: pnpm generate + - name: Check generated commands + run: | + pnpm generate:commands + git status --porcelain -- src/commands + test -z "$(git status --porcelain -- src/commands)" + - name: Check formatting run: pnpm format:check diff --git a/AGENTS.md b/AGENTS.md index 089ce40..ff45b65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ # Command structure +Commands in `src/commands/` are generated from `api-spec.json` by `scripts/codegen/`; put exceptions in `scripts/codegen/config.ts` instead of editing generated files. + We map from the REST api path to a command structure for CLI with the following rules: - A command is ` -`. The group is the path's first segment (`/vendors/...` → `vendors`). - The verb is a trailing action in the path (`/vendors/{vendorId}/set-status` → `set-status`), or comes from the method when there is no trailing action: GET → `list` or `get`, POST → `create` or `upload` (files), PATCH → `update`, PUT → `set`, DELETE → `delete`. A POST or DELETE that attaches or detaches an existing resource is `add` or `remove` (`groups add-person`), and a GET that returns a file is `download` (`trust-centers download-resource-media`). A multi-word action puts the noun after its first word: `trust-centers send-viewer-invite-reminder`. diff --git a/package.json b/package.json index 008da44..e669552 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "typecheck": "tsc --noEmit", "test": "node --import tsx --test 'src/**/*.test.ts'", "build:binaries": "tsx scripts/build-binaries.ts", + "generate:commands": "tsx scripts/codegen/generate-commands.ts", "format": "oxfmt", "format:check": "oxfmt --check" }, diff --git a/scripts/codegen/config.ts b/scripts/codegen/config.ts new file mode 100644 index 0000000..8eda025 --- /dev/null +++ b/scripts/codegen/config.ts @@ -0,0 +1,269 @@ +// Structural decisions the command generator cannot derive from the OpenAPI +// spec, plus help text that replaces generated descriptions. +// +// scripts/codegen/generate-commands.ts derives the command tree from +// api-spec.json using the conventions in AGENTS.md. Everything here is an +// explicit exception or naming input to those conventions. Prefer extending the +// conventions over adding per-operation overrides. + +export type Noun = { + // Display name for one item, e.g. "Trust Center FAQ". + singular?: string; + // Display name for the collection, e.g. "Trust Center FAQs". + plural?: string; + // Name used in verb-noun commands when it differs from the singular path + // segment, e.g. "file" for `uploads` (`documents upload-file`). + commandSingular?: string; +}; + +export type OptionOverride = { + flag?: string; + placeholder?: string; + description?: string; +}; + +export type OperationOverride = { + // Command name, replacing the derived verb or verb-noun. + name?: string; + // Keyed by API parameter or multipart field name. + options?: Record; +}; + +export type GroupOverride = { + // Overrides the group's own `--id ` flag. + id?: OptionOverride; +}; + +// Top-level command order in `vanta --help`. Groups not listed are appended +// alphabetically. +export const topLevelOrder = [ + "controls", + "policies", + "documents", + "tests", + "people", + "groups", + "frameworks", + "users", + "vulnerabilities", + "vulnerable-assets", + "vulnerability-remediations", + "contracts", + "risk-scenarios", + "monitored-computers", + "vendors", + "discovered-vendors", + "integrations", + "event-logs", + "business-units", + "issues", + "program-scopes", + "customer-trust", + "knowledge-base", + "trust-centers", +]; + +// Domain grouping: API resources mounted under another group instead of at the +// top level. Keyed by the API path prefix. +export const mounts: Record = { + "/personnel-notification-settings": "people notification-settings", + "/vendor-risk-attributes": "vendors risk-attributes", + "/vendor-assessment-types": "vendors assessment-types", +}; + +// Display names, keyed by " " or by path segment. Defaults humanize the segment, so only +// proper nouns, acronyms, and irregular forms belong here. +export const nouns: Record = { + "answer-library": { + singular: "Answer Library entry", + plural: "Answer Library entries", + }, + "customer-trust accounts": { + singular: "Customer Trust account", + plural: "Customer Trust accounts", + }, + "faq-categories": { singular: "FAQ category", plural: "FAQ categories" }, + faqs: { singular: "FAQ", plural: "FAQs" }, + "knowledge-base resources": { + singular: "Knowledge Base resource", + plural: "Knowledge Base resources", + }, + "trust-centers": { singular: "Trust Center", plural: "Trust Centers" }, + uploads: { commandSingular: "file" }, +}; + +// Keyed by CLI group path, e.g. "trust-centers". +export const groups: Record = { + "trust-centers": { + id: { + flag: "slug-id", + placeholder: "slug", + description: "Trust Center slug ID", + }, + }, +}; + +// Applied wherever the parameter appears, before per-operation overrides. +export const params: Record = { + slugId: { placeholder: "slug", description: "Trust Center slug ID" }, + resourceKind: { placeholder: "kind", description: "Integration resource kind" }, + // Product contexts are a fixed set of names, not IDs. + productContextId: { placeholder: "product" }, + productContextIdsMatchesAny: { placeholder: "product" }, +}; + +// A trailing path segment whose first word is one of these is an action, and +// names the command (`/vendors/{vendorId}/set-status` → `vendors set-status`). +export const actionVerbs = [ + "acknowledge", + "add", + "approve", + "cancel", + "clear", + "complete", + "deactivate", + "deny", + "import", + "mark", + "notify", + "offboard", + "reactivate", + "remove", + "send", + "set", + "submit", + "upload", + "verify", +]; + +// Command names that differ from the naming rules, keyed by operationId. +export const operations: Record = { + // Linking existing people, documents, tests, and controls: add/remove, not + // create/delete. + AddPersonToGroup: { name: "add-person" }, + RemovePersonFromGroup: { name: "remove-person" }, + DeleteDocumentForcontrol: { name: "remove-document" }, + DeleteTestForControl: { name: "remove-test" }, + CreateRiskScenarioControl: { name: "add-control" }, + AddDocumentToControl: { name: "add-document" }, + AddTestToControl: { name: "add-test" }, + BulkAddTagsToControls: { name: "add-control-tags" }, + BulkRemoveTagsFromControls: { name: "remove-control-tags" }, + // Don't repeat the group's noun. + ListLibraryControls: { name: "list-library" }, + ListDeactivatedControls: { name: "list-deactivated" }, + // A single settings object, not a collection. + GetSettings: { name: "get" }, + // The path names the media, not the action. + GetUploadedfileMedia: { name: "download-file" }, + // Replaces the file on an existing resource; `upload-resource-document` + // creates one. + ReplaceDocumentResourceFile: { name: "replace-resource-document-file" }, + CreateFileQuestionnaire: { + options: { includeUntaggedEntitiesForCategoryIds: { placeholder: "ids" } }, + }, + UploadFileForDocument: { + options: { effectiveAtDate: { placeholder: "date" } }, + }, +}; + +// Help text that replaces generated descriptions, keyed by command path. +// Descriptions come from templates ("List vendors", "Get a vendor by ID") and +// option descriptions from the API spec; add an entry when the default reads +// poorly. The generator fails on entries that match no command or that repeat +// the default. +export const commandHelp: Record = { + "controls create": "Create a custom control", + "controls list-library": "List controls from the library", + "controls set-owner": "Set or clear a control owner", + "customer-trust": "Manage Customer Trust accounts, questionnaires, and tags", + "customer-trust create-tag-category-product-context": "Enable a tag category for a product context", + "customer-trust delete-tag-category-product-context": "Disable a tag category for a product context", + "customer-trust get-questionnaire-export": "Get a questionnaire export's status by ID", + "customer-trust get-tag-category": "Get the tags in a tag category by ID", + "customer-trust list-questionnaire-assignable-users": "List users who can be assigned to questionnaires", + "customer-trust upload-questionnaire-file": "Upload a file to create a questionnaire", + "discovered-vendors add-to-managed": "Add a discovered vendor to managed vendors", + "documents set-owner": "Set or clear a document owner", + "documents submit": "Submit document collection", + "groups list-importable-idp-groups": "List importable identity provider groups", + "knowledge-base": "Manage Knowledge Base answers and resources", + "knowledge-base replace-resource-document-file": "Replace the file for a document resource", + "people mark-as-not-people": "Mark accounts as not people", + "people mark-as-people": "Mark accounts as people", + "trust-centers create-control": "Add a control to a Trust Center", + "trust-centers get": "Get a Trust Center by slug ID", + "trust-centers get-chatbot-conversation": "Get messages for a Trust Center chatbot conversation", + "trust-centers list-chatbot-conversations": "List Trust Center chatbot conversations", + "trust-centers update-control-category-controls": "Add or remove controls in a Trust Center control category", + "vulnerability-remediations acknowledge-sla-miss": "Acknowledge SLA miss for vulnerability remediations", +}; + +// Keyed by command path, then by flag (without the placeholder). +export const optionHelp: Record> = { + "controls list": { + "--framework-matches-any": "Framework IDs to filter by (repeatable)", + }, + "customer-trust delete-tag-category-product-context": { + "--product-context-id": "Product context: EXTERNAL_TRUST_CENTER, DOCUMENT_SHARING, CONTROL_SHARING", + }, + "customer-trust get-questionnaire-export": { + "--id": "Export job ID returned by `customer-trust create-questionnaire-export`", + }, + "customer-trust list-accounts": { + "--custom-fields-filter": "Filter by custom field values", + "--is-auto-approval-enabled": "Filter by whether access requests are auto-approved (true/false)", + }, + "discovered-vendors list": { + "--scope": "Discovered vendor scope (defaults to NEEDS_REVIEW in the API): NEEDS_REVIEW, IGNORED, REJECTED", + }, + "documents list": { + "--framework-matches-any": "Framework IDs to filter by (repeatable)", + "--status-matches-any": "Document statuses to filter by (repeatable)", + }, + "groups list-importable-idp-groups": { + "--search": "Search importable identity provider groups", + }, + "issues list": { + "--include-issues-without-due-date": "Include undated issues with --due-before-date or --due-after-date (true/false)", + "--include-only-issues-without-due-date": "Include only undated issues (true/false); cannot combine with --due-before-date or --due-after-date", + }, + "knowledge-base list-answer-library": { + "--matches-tags": "JSON-encoded array of `{categoryId, tagId}` pairs. Entries matching any of the given tags are returned (OR filter). Find category IDs with `vanta customer-trust list-tag-categories` and tag IDs with `vanta customer-trust get-tag-category --tag-category-id `.", + }, + "knowledge-base list-resources": { + "--matches-tags": "JSON-encoded array of `{categoryId, tagId}` pairs. Tags within the same category are OR'd together; tags across different categories are AND'd. For example, passing two tags from \"Framework\" and one tag from \"Region\" matches resources that have either of the two frameworks AND the given region. Find category IDs with `vanta customer-trust list-tag-categories` and tag IDs with `vanta customer-trust get-tag-category --tag-category-id `.", + }, + "knowledge-base replace-resource-document-file": { + "--id": "Knowledge Base resource ID", + }, + "knowledge-base update-resource-document": { + "--id": "Knowledge Base resource ID", + }, + "knowledge-base update-resource-webpage": { + "--id": "Knowledge Base resource ID", + }, + "knowledge-base upload-resource-document": { + "--category-id": "Trust Center category id to associate this resource with. Only applied when --customer-visibility is REQUEST_ACCESS or PUBLIC; otherwise ignored. Unknown IDs leave the resource uncategorized.", + }, + "people list": { + "--task-status-matches-any": "Task statuses to filter by (repeatable)", + "--task-type-matches-any": "Task types to filter by (repeatable)", + }, + "risk-scenarios list": { + "--include-ignored": "Include ignored risk scenarios (true/false)", + "--order-by": "Order by field (description or createdAt)", + }, + "trust-centers list-viewers": { + "--include-removed": "Include removed viewers (true/false; default: true)", + }, + "vendors list-assessments": { + "--type-id-matches-any": "Filter by assessment type ID from vendors assessment-types list (repeatable)", + }, + "vulnerabilities list": { + "--include-vulnerabilities-without-slas": "Include vulnerabilities without SLA dates (true/false)", + }, + "vulnerable-assets list": { + "--asset-external-account-id": "Filter vulnerable assets by external account ID", + }, +}; diff --git a/scripts/codegen/generate-commands.ts b/scripts/codegen/generate-commands.ts new file mode 100644 index 0000000..1f0c281 --- /dev/null +++ b/scripts/codegen/generate-commands.ts @@ -0,0 +1,1006 @@ +// Generates the API commands in src/commands/ from api-spec.json. +// +// tsx scripts/codegen/generate-commands.ts [--out ] +// +// AGENTS.md ("Command structure") describes the naming rules; ./config.ts holds the +// exceptions and help text overrides. The script runs in stages: +// +// 1. Spec read each API operation into an `Operation` +// 2. Naming place it in a group and name it (`vendors list-findings`) +// 3. Options turn its parameters and body into flags +// 4. Help text describe the command +// 5. Tree collect commands into groups, check names, apply overrides +// 6. Render write one TypeScript file per group, plus index.ts +import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import * as config from "./config.js"; + +const repoRoot = new URL("../../", import.meta.url).pathname; +const generatedHeader = "// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT."; + +// =========================================================================== +// 1. Spec +// =========================================================================== + +type Schema = { + $ref?: string; + type?: string; + format?: string; + enum?: string[]; + items?: Schema; + description?: string; + properties?: Record; + required?: string[]; +}; + +// One API input: a path or query parameter, or a multipart form field. +type Input = { + name: string; + required: boolean; + description: string; + schema: Schema; +}; + +// A literal path segment and the ID parameter that follows it, if any: +// `/vendors/{vendorId}` is `{ name: "vendors", param: "vendorId" }`. +type Segment = { name: string; param?: string }; + +type Operation = { + id: string; + method: "get" | "post" | "put" | "patch" | "delete"; + path: string; + summary: string; + segments: Segment[]; + pathParams: Input[]; + queryParams: Input[]; + body: "none" | "json" | "multipart"; + formFields: Input[]; + returnsFile: boolean; +}; + +const spec = JSON.parse(readFileSync(join(repoRoot, "api-spec.json"), "utf8")); + +function deref(value: T): T { + let v = value as { $ref?: string }; + while (v?.$ref) { + v = v.$ref + .split("/") + .slice(1) + .reduce((acc: any, key) => acc[key], spec); + } + return v as T; +} + +function toSegments(path: string): Segment[] { + const segments: Segment[] = []; + for (const part of path.split("/").filter(Boolean)) { + const param = /^\{(.+)\}$/.exec(part)?.[1]; + if (param) segments.at(-1)!.param = param; + else segments.push({ name: part }); + } + return segments; +} + +function loadOperations(): Operation[] { + const ops: Operation[] = []; + for (const [path, item] of Object.entries(spec.paths)) { + for (const method of ["get", "post", "put", "patch", "delete"] as const) { + const op = item[method]; + if (!op) continue; + const params = (op.parameters ?? []).map(deref); + const toInput = (p: any): Input => ({ + name: p.name, + required: Boolean(p.required), + description: p.description ?? "", + schema: deref(p.schema ?? {}), + }); + const body = op.requestBody ? deref(op.requestBody) : undefined; + const form = body?.content?.["multipart/form-data"]; + const formSchema: Schema = form ? deref(form.schema) : {}; + const ok = deref(op.responses?.["200"] ?? op.responses?.["201"] ?? {}); + ops.push({ + id: op.operationId, + method, + path, + summary: op.summary ?? "", + segments: toSegments(path), + pathParams: params.filter((p: any) => p.in === "path").map(toInput), + queryParams: params.filter((p: any) => p.in === "query").map(toInput), + body: form ? "multipart" : body ? "json" : "none", + formFields: Object.entries(formSchema.properties ?? {}).map(([name, s]) => { + const schema = deref(s); + return { + name, + required: (formSchema.required ?? []).includes(name), + description: schema.description ?? "", + schema, + }; + }), + returnsFile: Object.keys(ok.content ?? {}).some((t) => t !== "application/json"), + }); + } + } + return ops; +} + +const operations = loadOperations(); + +// =========================================================================== +// Words +// =========================================================================== + +const kebab = (s: string) => s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); +const camel = (s: string) => s.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()); +const pascal = (s: string) => camel(s).replace(/^./, (c) => c.toUpperCase()); +const lowerFirst = (s: string) => s.replace(/^./, (c) => c.toLowerCase()); +const upperFirst = (s: string) => s.replace(/^./, (c) => c.toUpperCase()); +const humanize = (s: string) => kebab(s).replace(/-/g, " "); +const article = (s: string) => (/^[aeiou]/i.test(s) && !/^(us|uni|eu)/i.test(s) ? "an" : "a"); +const a = (noun: string) => `${article(noun)} ${noun}`; + +function singularize(word: string): string { + if (word === "people") return "person"; + if (word.endsWith("ies")) return `${word.slice(0, -3)}y`; + if (word.endsWith("sses")) return word.slice(0, -2); + if (word.endsWith("s") && !/(ss|us)$/.test(word)) return word.slice(0, -1); + return word; +} + +// Singularizes the last word of a kebab-case segment: `security-reviews`. +function singular(segment: string): string { + const words = segment.split("-"); + words.push(singularize(words.pop()!)); + return words.join("-"); +} + +const isPlural = (segment: string) => singular(segment) !== segment; + +// Display names from `config.nouns`, keyed by " " or by +// segment, else from the segment itself. +const nounFor = (segment: string, group?: string) => + (group && config.nouns[`${group} ${segment}`]) || config.nouns[segment]; +const displaySingular = (segment: string, group?: string) => + nounFor(segment, group)?.singular ?? humanize(singular(segment)); +const displayPlural = (segment: string, group?: string) => + nounFor(segment, group)?.plural ?? humanize(segment); + +// =========================================================================== +// 2. Naming +// =========================================================================== + +// Where an operation sits in the CLI and what its path is made of. +type Placement = { + // CLI group path: ["vendors"], or ["people", "notification-settings"] for a mount. + group: string[]; + // The path segment the group stands for. + groupSegment: Segment; + // Segments after the group, minus a trailing action: they form the noun. + resources: Segment[]; + action?: string; +}; + +function findMount(path: string): string[] | undefined { + const prefix = Object.keys(config.mounts).find( + (p) => path === p || path.startsWith(`${p}/`), + ); + return prefix ? config.mounts[prefix]!.split(" ") : undefined; +} + +function place(op: Operation): Placement { + const [groupSegment, ...rest] = op.segments as [Segment, ...Segment[]]; + const last = rest.at(-1); + const isAction = + last && !last.param && config.actionVerbs.includes(last.name.split("-")[0]!); + return { + group: findMount(op.path) ?? [groupSegment.name], + groupSegment, + resources: isAction ? rest.slice(0, -1) : rest, + action: isAction ? last.name : undefined, + }; +} + +// `-`; see AGENTS.md "Command structure". +function commandName(op: Operation, p: Placement): string { + const atItem = op.segments.at(-1)!.param !== undefined; + let verb: string; + let rest: string[] = []; + // Operations on a whole collection keep the last noun plural. + let plural = !atItem; + if (p.action) { + [verb, ...rest] = p.action.split("-") as [string, ...string[]]; + plural = false; + } else if (op.method === "get") { + verb = atItem || op.returnsFile ? "get" : "list"; + plural = verb === "list"; + } else if (op.method === "post") { + verb = op.body === "multipart" ? "upload" : "create"; + plural = false; + } else { + verb = { patch: "update", put: "set", delete: "delete" }[op.method]; + } + const noun = p.resources.map((s, i) => + plural && i === p.resources.length - 1 + ? s.name + : (config.nouns[s.name]?.commandSingular ?? singular(s.name)), + ); + return [verb, ...noun, ...rest].join("-"); +} + +// =========================================================================== +// 3. Options +// =========================================================================== + +type Option = { + flag: string; + placeholder: string; + description: string; + required: boolean; + repeatable: boolean; + // Parse "true"/"false" into a boolean. + parseBool: boolean; + // Takes one of the spec's enum values, typed by the SDK. + isEnum: boolean; + // Where the value goes in the SDK call, under `key`. + target: "path" | "query" | "form" | "file"; + key: string; +}; + +const topLevelGroups = new Set( + operations.map((op) => findMount(op.path)?.[0] ?? op.segments[0]!.name), +); + +const searchParams = new Set(["q", "search", "searchString"]); +const placeholderWords = new Set([ + "category", + "direction", + "group", + "kind", + "name", + "owner", + "permission", + "reason", + "role", + "scope", + "severity", + "slug", + "source", + "status", + "template", + "title", + "type", + "url", + "visibility", +]); + +// The noun a parameter refers to: `statusMatchesAny` → `status`. +function paramNoun(name: string): string { + const base = name.replace(/(MatchesAny|Filter)$/, "").replace(/Filter$/, ""); + return singularize(kebab(base).split("-").at(-1)!); +} + +// See AGENTS.md "Help text" for the placeholder vocabulary. +function placeholderFor(input: Input): string { + const { name, schema, description } = input; + const item = schema.type === "array" ? deref(schema.items ?? {}) : schema; + if (item.type === "boolean" || /^(is|has)[A-Z]/.test(name)) return "bool"; + if (item.format === "date") return "date"; + if (item.format === "date-time" || (!item.enum && /(Date|After|Before)$/.test(name))) { + return /ISO 8601 date\b/.test(description) ? "date" : "timestamp"; + } + if (searchParams.has(name)) return "query"; + if (name === "orderBy") return "field"; + if (item.enum) return paramNoun(name); + if (/\bJSON\b/.test(description)) return "json"; + if ( + /(Id|Ids|Identifier)$/.test(name.replace(/MatchesAny$/, "")) || + name === "id" || + /\bIDs?\b/.test(description) + ) { + return "id"; + } + if (item.type === "integer" || item.type === "number") return "n"; + const noun = paramNoun(name); + if (topLevelGroups.has(`${noun}s`)) return "id"; + return placeholderWords.has(noun) ? noun : "text"; +} + +// First paragraph of a spec description, without "Possible values" lists. +function cleanDescription(text: string): string { + return text + .replace(/\s*Possible values:.*$/s, "") + .split(/\n\s*\n/)[0]! + .replace(/\s+/g, " ") + .trim() + .replace(/\.$/, ""); +} + +// The spec description, plus enum choices and format hints. +function inputDescription(input: Input, placeholder: string, listed: string): string { + const { name, schema } = input; + const item = schema.type === "array" ? deref(schema.items ?? {}) : schema; + let text = cleanDescription(input.description); + if (!text && placeholder === "query") text = `Search ${listed}`; + if (!text) { + const subject = humanize(name.replace(/MatchesAny$/, "")); + text = `Filter by ${subject.replace(/\bid(s?)$/, "ID$1")}`; + } + text = upperFirst(text) + // "Boolean determining whether X" → "Whether X". + .replace(/^Boolean (determining|indicating) w/, "W") + // `("true" / "false")` → the standard "(true/false)", added below. + .replace(/\s*\("true" ?\/ ?"false"\)/, ""); + if (placeholder === "timestamp") { + // "in ISO 8601" and "(ISO 8601 string)" → "ISO 8601 timestamp". + text = text.replace(/ISO 8601(?: string)?(?! (timestamp|date))/g, "ISO 8601 timestamp"); + } + if (item.enum && !item.enum.every((v) => text.includes(v))) { + text += `: ${item.enum.join(", ")}`; + } + if (placeholder === "bool" && !text.includes("true/false")) text += " (true/false)"; + if (placeholder === "timestamp" && !text.includes("ISO 8601")) { + text += " (ISO 8601 timestamp)"; + } + if (schema.type === "array") text += " (repeatable)"; + return text; +} + +// "FAQ ID" for `faqId` when the noun has a configured display name, else the +// humanized parameter name ("Security review ID"). +function pathParamDescription(name: string, p: Placement): string { + const segment = p.resources.find((s) => s.param === name); + const display = + segment && + (nounFor(segment.name, p.group[0])?.singular ?? + (name === "id" ? displaySingular(segment.name) : undefined)); + if (display) return `${upperFirst(display)} ID`; + return upperFirst(humanize(name).replace(/ id$/, " ID")); +} + +// Whether the SDK types the value as plain `string` (or `string[]`), rather +// than an enum or union. +function isPlainString(schema: Schema): boolean { + const item = schema.type === "array" ? deref(schema.items ?? {}) : schema; + return item.type !== "string" ? item.type === "boolean" || item.type === "integer" : !item.enum; +} + +function buildOptions(op: Operation, p: Placement, group: Group): Option[] { + const overrides = config.operations[op.id]?.options ?? {}; + const placeholderOverride = (key: string) => + overrides[key]?.placeholder ?? config.params[key]?.placeholder; + // What a search flag searches: the listed resource, else the group. + const last = p.resources.at(-1); + const listed = last ? displayPlural(last.name, p.group[0]) : group.plural; + const withOverrides = (option: Option, ...extra: (config.OptionOverride | undefined)[]) => { + for (const o of [...extra, config.params[option.key], overrides[option.key]]) { + Object.assign(option, Object.fromEntries(Object.entries(o ?? {}).filter(([, v]) => v))); + } + return option; + }; + const options: Option[] = []; + + for (const param of op.pathParams) { + // The group's own ID is `--id`; `resourceKind` stays `--resource-kind`. + const own = param.name === p.groupSegment.param && /(^id|Id)$/.test(param.name); + const option: Option = { + flag: own ? "id" : kebab(param.name), + placeholder: own || /Id$/.test(param.name) ? "id" : kebab(param.name), + description: own + ? `${upperFirst(group.singular)} ID` + : pathParamDescription(param.name, p), + required: true, + repeatable: false, + parseBool: false, + isEnum: !isPlainString(param.schema), + target: "path", + key: param.name, + }; + options.push(withOverrides(option, own ? config.groups[group.path.join(" ")]?.id : undefined)); + } + + for (const param of op.queryParams) { + if (param.name === "pageSize" || param.name === "pageCursor") continue; + const placeholder = placeholderOverride(param.name) ?? placeholderFor(param); + options.push( + withOverrides({ + flag: kebab(param.name), + placeholder, + description: inputDescription(param, placeholder, listed), + required: param.required, + repeatable: param.schema.type === "array", + parseBool: placeholder === "bool" && param.schema.type === "boolean", + isEnum: !isPlainString(param.schema), + target: "query", + key: param.name, + }), + ); + } + + for (const field of op.formFields) { + const isFile = field.schema.format === "binary"; + const placeholder = isFile ? "path" : (placeholderOverride(field.name) ?? placeholderFor(field)); + const fileDescription = cleanDescription(field.description); + options.push( + withOverrides({ + flag: kebab(field.name), + placeholder, + description: !isFile + ? inputDescription(field, placeholder, listed) + : fileDescription + ? `Path to file to upload: ${lowerFirst(fileDescription)}` + : field.name === "file" + ? "Path to file to upload" + : `Path to ${humanize(field.name)} file to upload`, + required: field.required, + repeatable: false, + parseBool: false, + isEnum: false, + target: isFile ? "file" : "form", + key: field.name, + }), + ); + } + + return options; +} + +// =========================================================================== +// 4. Help text +// =========================================================================== + +// Groups like `customer-trust` have no endpoints of their own, so they don't +// own the resources under them in descriptions. +const groupsWithEndpoints = new Set( + operations.filter((op) => op.segments.length === 1).map((op) => op.segments[0]!.name), +); + +const prepositions = new Set(["as", "for", "from", "in", "to", "with"]); + +// Describes a command from its verb, its subject (the last resource), and the +// subject's owner (the group and any resources in between). +function commandDescription(name: string, op: Operation, p: Placement, group: Group): string { + const atItem = op.segments.at(-1)!.param !== undefined; + const verb = upperFirst(name.split("-")[0]!); + const g = p.group[0]; + const subjectSegment = p.resources.at(-1); + const parents = p.resources.slice(0, -1); + const parentSegment = parents.at(-1) ?? p.groupSegment; + + const ownsResources = groupsWithEndpoints.has(p.groupSegment.name) || findMount(op.path); + const ownerWords = [ + ...(ownsResources ? [group.singular] : []), + ...parents.map((s) => displaySingular(s.name, g)), + ]; + const owner = ownerWords.join(" "); + const owners = parents.length + ? [...ownerWords.slice(0, -1), displayPlural(parentSegment.name, g)].join(" ") + : group.plural; + const subject = subjectSegment && { + one: + nounFor(subjectSegment.name, g)?.commandSingular ?? + displaySingular(subjectSegment.name, g), + many: displayPlural(subjectSegment.name, g), + }; + // With no owner, the subject stands alone ("List questionnaires"). + const one = subject && owner ? owner : (subject?.one ?? group.singular); + const many = subject && owner ? owners : (subject?.many ?? group.plural); + + if (p.action) { + // `add-document-to-control` under `controls` → "document". + const own = singular(p.groupSegment.name); + const rest = humanize(p.action.replace(new RegExp(`-(to|from|for)-${own}$`), "")) + .split(" ") + .slice(1); + const target = subject && owner ? `${owner} ${subject.one}` : one; + // Actions on the collection, e.g. `people offboard`. + if (!op.segments.some((s) => s.param)) { + return rest.length === 0 ? `${verb} ${many}` : op.summary; + } + if (rest.length === 0) return `${verb} ${a(target)}`; + if (prepositions.has(rest[0]!)) return `${verb} ${a(target)} ${rest.join(" ")}`; + // `add-people` → "Add people to a group". + const object = rest.join(" "); + const linked = isPlural(rest.at(-1)!) || rest.length > 1 ? object : a(object); + if (verb === "Add") return `Add ${linked} to ${a(target)}`; + if (verb === "Remove") return `Remove ${linked} from ${a(target)}`; + return `${verb} ${object} for ${a(target)}`; + } + + if (!subject || !owner) { + switch (verb) { + case "List": + return `List ${many}`; + case "Get": + return atItem ? `Get ${a(one)} by ID` : `Get ${many}`; + case "Create": + case "Upload": + return `${verb} ${a(one)}`; + case "Update": + return atItem ? `Update ${a(one)}` : `Update ${many}`; + case "Delete": + return `Delete ${a(one)}`; + case "Set": + return `Set ${many}`; + } + return op.summary; + } + + // Under a collection rather than an item: `controls list-deactivated`, + // `trust-centers add-control-tags`. + if (parentSegment.param === undefined) { + if (verb === "Add") return `Add ${subject.many} to ${many}`; + if (verb === "Remove") return `Remove ${subject.many} from ${many}`; + if (subjectSegment.name === "order") return `Reorder ${many}`; + if (verb === "List") { + // `deactivated-controls` under `controls` → "List deactivated controls". + const words = humanize(subjectSegment.name) + .split(" ") + .filter((w) => w !== p.groupSegment.name && w !== singular(p.groupSegment.name)); + return `List ${words.join(" ")} ${many}`; + } + // A resource of its own under the collection: "Create a questionnaire export". + if (isPlural(subjectSegment.name)) { + return `${verb} ${a(`${one} ${subject.one}`)}${atItem ? " by ID" : ""}`; + } + return `${verb} ${a(`${subject.one} ${one}`)}`; + } + + if (op.returnsFile) return `Download the ${subject.one} for ${a(one)}`; + const resource = a(`${one} ${subject.one}`); + switch (verb) { + case "List": + return `List ${subject.many} for ${a(one)}`; + case "Get": + return `Get ${resource}${atItem ? " by ID" : ""}`; + case "Update": + return !atItem && isPlural(subjectSegment.name) + ? `Update ${subject.many} for ${a(one)}` + : `Update ${resource}`; + case "Create": + case "Delete": + return `${verb} ${resource}`; + case "Add": + return `Add ${a(subject.one)} to ${a(one)}`; + case "Remove": + return `Remove ${a(subject.one)} from ${a(one)}`; + case "Upload": + return `Upload ${a(subject.one)} for ${a(one)}`; + case "Set": + return subjectSegment.name === "order" + ? `Reorder ${displayPlural(parentSegment.name, g)} for ${a(ownerWords.slice(0, -1).join(" "))}` + : `Set ${subject.many} for ${a(one)}`; + } + return op.summary; +} + +// =========================================================================== +// 5. Tree +// =========================================================================== + +type Command = { + name: string; + description: string; + op: Operation; + options: Option[]; + paginated: boolean; + download: boolean; +}; + +type Group = { + path: string[]; + name: string; + description: string; + singular: string; + plural: string; + children: Map; + commands: Command[]; + // Output file name without `.ts`. + file: string; +}; + +function newGroup(path: string[], apiSegment: string): Group { + const noun = config.nouns[path.join(" ")]; + const plural = noun?.plural ?? displayPlural(apiSegment); + return { + path, + name: path.at(-1)!, + description: `Manage ${plural}`, + singular: noun?.singular ?? displaySingular(apiSegment), + plural, + children: new Map(), + commands: [], + // Mounted groups are named after their API path: `vendor-assessment-types`. + file: path.length === 1 ? path[0]! : apiSegment, + }; +} + +// The group for a placement, created on first use. +function groupFor(root: Group, p: Placement): Group { + let group = root; + p.group.forEach((name, depth) => { + const path = p.group.slice(0, depth + 1); + const apiSegment = depth === p.group.length - 1 ? p.groupSegment.name : name; + if (!group.children.has(name)) group.children.set(name, newGroup(path, apiSegment)); + group = group.children.get(name)!; + }); + return group; +} + +function buildTree(): Group { + const root = newGroup([""], ""); + for (const op of operations) { + const p = place(op); + const group = groupFor(root, p); + const name = config.operations[op.id]?.name ?? commandName(op, p); + const queryNames = op.queryParams.map((q) => q.name); + group.commands.push({ + name, + description: commandDescription(name, op, p, group), + op, + options: buildOptions(op, p, group), + paginated: queryNames.includes("pageSize") && queryNames.includes("pageCursor"), + download: op.returnsFile, + }); + } + return root; +} + +function* allGroups(group: Group): Generator { + for (const child of group.children.values()) { + yield child; + yield* allGroups(child); + } +} + +function checkUniqueNames(root: Group): void { + for (const group of allGroups(root)) { + const seen = new Map(); + for (const { name, op } of group.commands) { + const path = [...group.path, name].join(" "); + if (seen.has(name)) { + throw new Error( + `duplicate command "${path}" for ${seen.get(name)} and ${op.id}; add a name override in scripts/codegen/config.ts`, + ); + } + if (group.children.has(name)) throw new Error(`command "${path}" collides with a group`); + seen.set(name, op.id); + } + } +} + +// Replaces generated descriptions with `config.commandHelp` and +// `config.optionHelp`, and fails on entries that match nothing or repeat the +// default, so the overrides stay a list of deliberate wording. +function applyHelpOverrides(root: Group): void { + const problems: string[] = []; + const unusedCommands = new Set(Object.keys(config.commandHelp)); + const unusedOptions = new Set( + Object.entries(config.optionHelp).flatMap(([cmd, flags]) => + Object.keys(flags).map((flag) => `${cmd} ${flag}`), + ), + ); + const override = (key: string, generated: string, text: string | undefined, unused: Set) => { + if (text === undefined) return generated; + unused.delete(key); + if (text === generated) problems.push(`help text for "${key}" repeats the default`); + return text; + }; + + for (const group of allGroups(root)) { + const groupKey = group.path.join(" "); + group.description = override(groupKey, group.description, config.commandHelp[groupKey], unusedCommands); + for (const command of group.commands) { + const key = `${groupKey} ${command.name}`; + command.description = override(key, command.description, config.commandHelp[key], unusedCommands); + for (const o of command.options) { + const flag = `--${o.flag}`; + o.description = override(`${key} ${flag}`, o.description, config.optionHelp[key]?.[flag], unusedOptions); + } + } + } + for (const key of unusedCommands) problems.push(`help text for unknown command "${key}"`); + for (const key of unusedOptions) problems.push(`help text for unknown option "${key}"`); + if (problems.length) throw new Error(`scripts/codegen/config.ts:\n ${problems.join("\n ")}`); +} + +// =========================================================================== +// 6. Render +// =========================================================================== + +const quote = (s: string) => JSON.stringify(s); +const pad = (n: number) => " ".repeat(n); +const indent = (text: string, n: number) => + text + .split("\n") + .map((line) => (line ? pad(n) + line : line)) + .join("\n"); +const sdkFunction = (op: Operation) => lowerFirst(op.id); +const registerFunction = (g: Group) => `register${pascal(g.file)}Command`; +const groupVariable = (g: Group) => { + const name = camel(g.name); + return ["delete", "export", "import", "new"].includes(name) ? `${name}Group` : name; +}; + +// A call on one line when it fits in `width`, else one argument per line. +function call(callee: string, args: string[], width: number): string { + const inline = `${callee}(${args.join(", ")})`; + if (inline.length <= width && !inline.includes("\n")) return inline; + return `${callee}(\n${args.map((arg) => `${indent(arg, 2)},`).join("\n")}\n)`; +} + +// An object literal on one line when it fits in `width`, else one entry per line. +function object(entries: string[], width: number): string { + const inline = `{ ${entries.join(", ")} }`; + if (inline.length <= width && !inline.includes("\n")) return inline; + return `{\n${entries.map((e) => `${indent(e, 2)},`).join("\n")}\n}`; +} + +// Names each generated file imports, collected while rendering. +type Imports = { + sdk: Set; + types: Set; + helpers: Set; + output: boolean; + registers: Map; +}; + +// The SDK call for a command, and statements that must run before it. +function renderSdkCall(command: Command, imports: Imports, width: number) { + const { op, options } = command; + const value = (o: Option) => (o.target === "file" ? camel(o.flag) : `opts.${camel(o.flag)}`); + const entry = (o: Option) => (o.key === value(o) ? o.key : `${o.key}: ${value(o)}`); + const of = (target: Option["target"][]) => options.filter((o) => target.includes(o.target)); + const before: string[] = []; + const args = ["client: api.client"]; + + const path = of(["path"]).map(entry); + if (path.length) args.push(`path: ${object(path, width - 8)}`); + + const query = of(["query"]).map(entry); + if (command.paginated) { + imports.helpers.add("paginationQuery"); + if (query.length === 0) args.push("query: paginationQuery(opts)"); + else query.unshift("...paginationQuery(opts)"); + } + if (query.length) args.push(`query: ${object(query, width - 9)}`); + + if (op.body === "json") { + imports.helpers.add("readJSONPayload"); + imports.types.add(`${op.id}Data`); + before.push( + `const body = (await readJSONPayload(\n opts.json,\n opts.file,\n)) as ${op.id}Data["body"];`, + ); + args.push("body"); + } else if (op.body === "multipart") { + imports.helpers.add("readBinaryFile"); + for (const o of of(["file"])) { + before.push(`const ${camel(o.flag)} = await readBinaryFile(${`opts.${camel(o.flag)}`});`); + } + args.push(`body: ${object(of(["form", "file"]).map(entry), width - 8)}`); + } + + imports.sdk.add(sdkFunction(op)); + return { before, call: `${sdkFunction(op)}(${object(args, width - sdkFunction(op).length - 2)})` }; +} + +function renderCommand(group: Group, command: Command, imports: Imports): string { + // Wrapped in addPaginationOptions/addJsonFileOptions, the chain sits two + // columns deeper and `.action()` attaches to the wrapper. + const wrapped = command.paginated || command.op.body === "json"; + const chainIndent = wrapped ? 6 : 4; + const width = 80 - chainIndent; + + const chain = [ + groupVariable(group), + `.command(${quote(command.name)})`, + `.description(${quote(command.description)})`, + ]; + for (const o of command.options) { + const args = [quote(`--${o.flag} <${o.placeholder}>`), quote(o.description)]; + if (o.repeatable) { + imports.helpers.add("collectString"); + args.push("collectString", "[] as string[]"); + } else if (o.parseBool) { + imports.helpers.add("parseOptionalBoolString"); + args.push(`(value) => parseOptionalBoolString(value, ${quote(o.flag)})`); + } + chain.push(call(`.${o.required ? "requiredOption" : "option"}`, args, width)); + } + if (command.download) { + const help = "Write downloaded bytes to file path (default stdout)"; + chain.push(call(".option", [quote("--output "), quote(help)], width)); + } + // Short chains stay on one line: `policies.command("list").description(...)`. + const inline = chain.join(""); + let head = + chain.length === 3 && chainIndent - 2 + inline.length < 80 + ? inline + : [chain[0], ...chain.slice(1).map((line) => indent(line, 2))].join("\n"); + for (const [use, helper] of [ + [command.paginated, "addPaginationOptions"], + [command.op.body === "json", "addJsonFileOptions"], + ] as const) { + if (!use) continue; + imports.helpers.add(helper); + head = `${helper}(\n${indent(head, 2)},\n)`; + } + + const bodyIndent = wrapped ? 4 : 6; + const sdk = renderSdkCall(command, imports, 80 - bodyIndent - 2); + const statements = [...sdk.before]; + if (command.download) { + imports.helpers.add("withClient"); + imports.helpers.add("writeDownloadedMedia"); + imports.output = true; + statements.push( + [ + "await withClient(getFlags, async (api, flags) => {", + indent(`const result = await ${sdk.call};`, 2), + " if (result.error) throw result.error;", + " const dest = opts.output?.trim();", + " await writeDownloadedMedia(result.data, dest);", + " if (dest) printResponse({ savedTo: dest }, flags);", + "});", + ].join("\n"), + ); + } else { + imports.helpers.add("runSdk"); + statements.push(`await runSdk(getFlags, (api) =>\n${indent(sdk.call, 2)},\n);`); + } + const body = statements.join("\n"); + const action = renderAction(command, body, wrapped ? 2 : 4, imports); + const text = wrapped ? `${head}${action};` : `${head}\n${indent(action, 2)};`; + return indent(text, 2); +} + +// `.action(async (opts: { id: string }) => { ... })`, with the options type +// on its own lines when it doesn't fit. +function renderAction( + command: Command, + body: string, + column: number, + imports: Imports, +): string { + if (!/\bopts\b/.test(body)) return `.action(async () => {\n${indent(body, 2)}\n})`; + const fields: string[] = []; + const add = (o: Option) => { + const optional = o.required || o.repeatable ? "" : "?"; + let type = o.repeatable ? "string[]" : o.parseBool ? "boolean" : "string"; + if (o.isEnum) { + imports.types.add(`${command.op.id}Data`); + type = + o.target === "path" + ? `${command.op.id}Data["path"]["${o.key}"]` + : `NonNullable<${command.op.id}Data["query"]>["${o.key}"]`; + if (o.repeatable) type = `NonNullable<${type}>`; + } + fields.push(`${camel(o.flag)}${optional}: ${type}`); + }; + command.options.filter((o) => o.target === "path").forEach(add); + if (command.paginated) fields.push("pageSize?: number", "pageCursor?: string"); + command.options.filter((o) => o.target !== "path").forEach(add); + if (command.op.body === "json") fields.push("json?: string", "file?: string"); + if (command.download) fields.push("output?: string"); + // Pagination alone doesn't need a type: `addPaginationOptions` provides it. + if (fields.every((f) => f.startsWith("page"))) { + return `.action(async (opts) => {\n${indent(body, 2)}\n})`; + } + + const signature = `async (opts: { ${fields.join("; ")} }) => {`; + if (column + ".action(".length + signature.length <= 80) { + return `.action(${signature}\n${indent(body, 2)}\n})`; + } + const type = fields.map((field) => ` ${field};`).join("\n"); + return `.action(\n async (opts: {\n${type}\n }) => {\n${indent(body, 4)}\n },\n)`; +} + +function renderGroup(group: Group, parent: string, files: Map, imports: Imports): string { + const v = groupVariable(group); + const declaration = ` const ${v} = ${parent}.command(${quote(group.name)}).description(${quote(group.description)});`; + const parts = [ + declaration.length <= 80 + ? declaration + : ` const ${v} = ${parent}\n .command(${quote(group.name)})\n .description(${quote(group.description)});`, + ]; + for (const child of group.children.values()) { + imports.registers.set(registerFunction(child), child.file); + parts.push(` ${registerFunction(child)}(${v}, getFlags);`); + renderFile(child, files); + } + // list, get, create, update, delete first; the rest in spec order. + const order = ["list", "get", "create", "update", "delete"]; + const rank = (c: Command) => (order.includes(c.name) ? order.indexOf(c.name) : order.length); + const commands = [...group.commands].sort((x, y) => rank(x) - rank(y)); + for (const command of commands) parts.push(renderCommand(group, command, imports)); + return parts.join("\n\n"); +} + +// Names in the order given, on one line when they fit. +function renderImport(kind: string, names: Iterable, from: string): string { + const list = [...names]; + const inline = `${kind} { ${list.join(", ")} } from "${from}";`; + if (inline.length <= 80) return inline; + return `${kind} {\n${list.map((n) => ` ${n},`).join("\n")}\n} from "${from}";`; +} + +function renderFile(group: Group, files: Map): void { + const imports: Imports = { + sdk: new Set(), + types: new Set(), + helpers: new Set(), + output: false, + registers: new Map(), + }; + const parent = group.path.length === 1 ? "program" : "parent"; + const body = renderGroup(group, parent, files, imports); + + const lines = [generatedHeader, `import type { Command } from "commander";`]; + for (const [fn, file] of imports.registers) { + lines.push(`import { ${fn} } from "./${file}.js";`); + } + if (imports.sdk.size) lines.push(renderImport("import", imports.sdk, "../generated/sdk.gen.js")); + if (imports.types.size) { + lines.push(renderImport("import type", imports.types, "../generated/types.gen.js")); + } + if (imports.output) lines.push(`import { printResponse } from "../output.js";`); + // Helpers alphabetically, then the GetFlags type. + lines.push(renderImport("import", [...[...imports.helpers].sort(), "type GetFlags"], "./helpers.js")); + + files.set( + `${group.file}.ts`, + `${lines.join("\n")}\n\nexport function ${registerFunction(group)}(\n ${parent}: Command,\n getFlags: GetFlags,\n): void {\n${body}\n}\n`, + ); +} + +function renderFiles(root: Group): Map { + const files = new Map(); + const order = (g: Group) => { + const i = config.topLevelOrder.indexOf(g.name); + return i === -1 ? config.topLevelOrder.length : i; + }; + const groups = [...root.children.values()].sort( + (x, y) => order(x) - order(y) || x.name.localeCompare(y.name), + ); + for (const group of groups) renderFile(group, files); + files.set( + "index.ts", + [ + generatedHeader, + `import type { Command } from "commander";`, + ...groups.map((g) => `import { ${registerFunction(g)} } from "./${g.file}.js";`), + `import type { GetFlags } from "./helpers.js";`, + "", + "export function registerApiCommands(program: Command, getFlags: GetFlags): void {", + ...groups.map((g) => ` ${registerFunction(g)}(program, getFlags);`), + "}", + "", + ].join("\n"), + ); + return files; +} + +// Writes the files, removing generated files for groups that no longer exist. +function writeFiles(files: Map, outDir: string): void { + mkdirSync(outDir, { recursive: true }); + for (const name of readdirSync(outDir)) { + const path = join(outDir, name); + if (!files.has(name) && name.endsWith(".ts") && readFileSync(path, "utf8").startsWith(generatedHeader)) { + rmSync(path); + } + } + for (const [name, content] of files) writeFileSync(join(outDir, name), content); + console.error(`wrote ${files.size} files to ${outDir}`); +} + +// =========================================================================== +// Main +// =========================================================================== + +const args = process.argv.slice(2); +const outDir = args.includes("--out") + ? args[args.indexOf("--out") + 1]! + : join(repoRoot, "src/commands"); + +const root = buildTree(); +checkUniqueNames(root); +applyHelpOverrides(root); +writeFiles(renderFiles(root), outDir); diff --git a/src/commands/business-units.ts b/src/commands/business-units.ts index 9c24b25..76d67c9 100644 --- a/src/commands/business-units.ts +++ b/src/commands/business-units.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listBusinessUnits, getBusinessUnit } from "../generated/sdk.gen.js"; import { diff --git a/src/commands/contracts.ts b/src/commands/contracts.ts index 1056db6..0fcb077 100644 --- a/src/commands/contracts.ts +++ b/src/commands/contracts.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listContracts, diff --git a/src/commands/controls.ts b/src/commands/controls.ts index c492d90..26e20a6 100644 --- a/src/commands/controls.ts +++ b/src/commands/controls.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listControls, diff --git a/src/commands/customer-trust.ts b/src/commands/customer-trust.ts index 1b36f6c..903d926 100644 --- a/src/commands/customer-trust.ts +++ b/src/commands/customer-trust.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listCustomerTrustAccounts, diff --git a/src/commands/discovered-vendors.ts b/src/commands/discovered-vendors.ts index 17819ac..74e2e0e 100644 --- a/src/commands/discovered-vendors.ts +++ b/src/commands/discovered-vendors.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listDiscoveredVendors, diff --git a/src/commands/documents.ts b/src/commands/documents.ts index 59fbf9c..10efa48 100644 --- a/src/commands/documents.ts +++ b/src/commands/documents.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listDocuments, diff --git a/src/commands/event-logs.ts b/src/commands/event-logs.ts index e79f7b3..bce49d6 100644 --- a/src/commands/event-logs.ts +++ b/src/commands/event-logs.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listEventLogs } from "../generated/sdk.gen.js"; import { diff --git a/src/commands/frameworks.ts b/src/commands/frameworks.ts index d8e9c97..b3917fc 100644 --- a/src/commands/frameworks.ts +++ b/src/commands/frameworks.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listFrameworks, diff --git a/src/commands/groups.ts b/src/commands/groups.ts index 42bfa51..6fe283f 100644 --- a/src/commands/groups.ts +++ b/src/commands/groups.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listPersonGroups, diff --git a/src/commands/index.ts b/src/commands/index.ts index cb1abce..68d144d 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { registerControlsCommand } from "./controls.js"; import { registerPoliciesCommand } from "./policies.js"; diff --git a/src/commands/integrations.ts b/src/commands/integrations.ts index a89c123..11c4c83 100644 --- a/src/commands/integrations.ts +++ b/src/commands/integrations.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listConnectedIntegrations, diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 437d6b7..09afd4e 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { list, getIssue } from "../generated/sdk.gen.js"; import type { ListData } from "../generated/types.gen.js"; diff --git a/src/commands/knowledge-base.ts b/src/commands/knowledge-base.ts index 735ede9..cd564ed 100644 --- a/src/commands/knowledge-base.ts +++ b/src/commands/knowledge-base.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listAnswerLibraryEntries, diff --git a/src/commands/monitored-computers.ts b/src/commands/monitored-computers.ts index c700576..b80ef61 100644 --- a/src/commands/monitored-computers.ts +++ b/src/commands/monitored-computers.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listMonitoredComputers, diff --git a/src/commands/people.ts b/src/commands/people.ts index 2228d93..99ce837 100644 --- a/src/commands/people.ts +++ b/src/commands/people.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listPeople, diff --git a/src/commands/personnel-notification-settings.ts b/src/commands/personnel-notification-settings.ts index 0f06188..34b446c 100644 --- a/src/commands/personnel-notification-settings.ts +++ b/src/commands/personnel-notification-settings.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { getSettings, updateSettings } from "../generated/sdk.gen.js"; import type { UpdateSettingsData } from "../generated/types.gen.js"; diff --git a/src/commands/policies.ts b/src/commands/policies.ts index d04d01c..48a55da 100644 --- a/src/commands/policies.ts +++ b/src/commands/policies.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listPolicies, getPolicy } from "../generated/sdk.gen.js"; import { diff --git a/src/commands/program-scopes.ts b/src/commands/program-scopes.ts index bfd5235..d199490 100644 --- a/src/commands/program-scopes.ts +++ b/src/commands/program-scopes.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listProgramScopes, getProgramScope } from "../generated/sdk.gen.js"; import { diff --git a/src/commands/risk-scenarios.ts b/src/commands/risk-scenarios.ts index b5dfaf3..9107533 100644 --- a/src/commands/risk-scenarios.ts +++ b/src/commands/risk-scenarios.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listRiskScenario, diff --git a/src/commands/tests.ts b/src/commands/tests.ts index b3c3d84..b684374 100644 --- a/src/commands/tests.ts +++ b/src/commands/tests.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listTests, diff --git a/src/commands/trust-centers.ts b/src/commands/trust-centers.ts index b14db7e..692dffe 100644 --- a/src/commands/trust-centers.ts +++ b/src/commands/trust-centers.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { getTrustCenter, diff --git a/src/commands/users.ts b/src/commands/users.ts index 75c06a4..bb8fd01 100644 --- a/src/commands/users.ts +++ b/src/commands/users.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listUsers, getUser } from "../generated/sdk.gen.js"; import { diff --git a/src/commands/vendor-assessment-types.ts b/src/commands/vendor-assessment-types.ts index 887cdcb..9802338 100644 --- a/src/commands/vendor-assessment-types.ts +++ b/src/commands/vendor-assessment-types.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listVendorAssessmentTypes, diff --git a/src/commands/vendor-risk-attributes.ts b/src/commands/vendor-risk-attributes.ts index 1266879..0003da5 100644 --- a/src/commands/vendor-risk-attributes.ts +++ b/src/commands/vendor-risk-attributes.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listVendorRiskAttributes } from "../generated/sdk.gen.js"; import { diff --git a/src/commands/vendors.ts b/src/commands/vendors.ts index 676da1c..d6ba14f 100644 --- a/src/commands/vendors.ts +++ b/src/commands/vendors.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listVendors, diff --git a/src/commands/vulnerabilities.ts b/src/commands/vulnerabilities.ts index ed15e1a..38e6690 100644 --- a/src/commands/vulnerabilities.ts +++ b/src/commands/vulnerabilities.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listVulnerabilities, diff --git a/src/commands/vulnerability-remediations.ts b/src/commands/vulnerability-remediations.ts index 971f114..e61707a 100644 --- a/src/commands/vulnerability-remediations.ts +++ b/src/commands/vulnerability-remediations.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listVulnerabilityRemediations, diff --git a/src/commands/vulnerable-assets.ts b/src/commands/vulnerable-assets.ts index 496b3e2..73dbd49 100644 --- a/src/commands/vulnerable-assets.ts +++ b/src/commands/vulnerable-assets.ts @@ -1,3 +1,4 @@ +// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. import type { Command } from "commander"; import { listVulnerableAssets, From b186f978272d4cb52ec1a3e0d524eb94873fcc90 Mon Sep 17 00:00:00 2001 From: Dennis Chen Date: Wed, 21 Oct 2026 01:52:23 +0000 Subject: [PATCH 2/9] Match action suffixes without a dynamic RegExp Semgrep flags `new RegExp` built from a path segment (detect-non-literal-regexp). Compare the suffix as a string instead; generated output is unchanged. Co-Authored-By: Claude Opus 5.5 --- scripts/codegen/generate-commands.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/codegen/generate-commands.ts b/scripts/codegen/generate-commands.ts index 1f0c281..e4b61a4 100644 --- a/scripts/codegen/generate-commands.ts +++ b/scripts/codegen/generate-commands.ts @@ -487,9 +487,11 @@ function commandDescription(name: string, op: Operation, p: Placement, group: Gr if (p.action) { // `add-document-to-control` under `controls` → "document". const own = singular(p.groupSegment.name); - const rest = humanize(p.action.replace(new RegExp(`-(to|from|for)-${own}$`), "")) - .split(" ") - .slice(1); + const suffix = ["to", "from", "for"] + .map((preposition) => `-${preposition}-${own}`) + .find((s) => p.action!.endsWith(s)); + const action = suffix ? p.action.slice(0, -suffix.length) : p.action; + const rest = humanize(action).split(" ").slice(1); const target = subject && owner ? `${owner} ${subject.one}` : one; // Actions on the collection, e.g. `people offboard`. if (!op.segments.some((s) => s.param)) { From 5f630eb91bdd358dc511514bfd672a30e6d72dfd Mon Sep 17 00:00:00 2001 From: Dennis Chen Date: Wed, 21 Oct 2026 01:59:19 +0000 Subject: [PATCH 3/9] Simplify the command generator without changing its output - Resolve every $ref once when the spec loads, instead of calling deref() wherever a schema is read - Type the parts of the OpenAPI spec the generator reads; no `any` - Keep groups in a map keyed by CLI path instead of a tree with a fake root node - Clearer names (`placement`, `topGroup`, `valueSchema`) Generated commands are byte-for-byte identical. Co-Authored-By: Claude Opus 5.5 --- scripts/codegen/generate-commands.ts | 265 +++++++++++++++------------ 1 file changed, 151 insertions(+), 114 deletions(-) diff --git a/scripts/codegen/generate-commands.ts b/scripts/codegen/generate-commands.ts index e4b61a4..e23f595 100644 --- a/scripts/codegen/generate-commands.ts +++ b/scripts/codegen/generate-commands.ts @@ -1,3 +1,4 @@ +/// // Generates the API commands in src/commands/ from api-spec.json. // // tsx scripts/codegen/generate-commands.ts [--out ] @@ -9,7 +10,7 @@ // 2. Naming place it in a group and name it (`vendors list-findings`) // 3. Options turn its parameters and body into flags // 4. Help text describe the command -// 5. Tree collect commands into groups, check names, apply overrides +// 5. Groups collect commands into groups, check names, apply overrides // 6. Render write one TypeScript file per group, plus index.ts import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -22,8 +23,30 @@ const generatedHeader = "// Code generated by scripts/codegen/generate-commands. // 1. Spec // =========================================================================== +// The parts of the OpenAPI spec the generator reads. +type Method = "get" | "post" | "put" | "patch" | "delete"; + +type Spec = { + paths: Record>>; +}; + +type SpecOperation = { + operationId: string; + summary?: string; + parameters?: SpecParameter[]; + requestBody?: { content?: Record }; + responses?: Record }>; +}; + +type SpecParameter = { + name: string; + in: "path" | "query" | "header" | "cookie"; + required?: boolean; + description?: string; + schema?: Schema; +}; + type Schema = { - $ref?: string; type?: string; format?: string; enum?: string[]; @@ -47,7 +70,7 @@ type Segment = { name: string; param?: string }; type Operation = { id: string; - method: "get" | "post" | "put" | "patch" | "delete"; + method: Method; path: string; summary: string; segments: Segment[]; @@ -58,19 +81,38 @@ type Operation = { returnsFile: boolean; }; -const spec = JSON.parse(readFileSync(join(repoRoot, "api-spec.json"), "utf8")); - -function deref(value: T): T { - let v = value as { $ref?: string }; - while (v?.$ref) { - v = v.$ref +// The spec with every `{ "$ref": "#/components/..." }` replaced by the object it +// points to, so the rest of the script never sees references. Shared and +// recursive schemas stay shared. +function resolveRefs(root: unknown): unknown { + // `#/components/schemas/Vendor` → root.components.schemas.Vendor + const lookup = (ref: string) => + ref .split("/") .slice(1) - .reduce((acc: any, key) => acc[key], spec); - } - return v as T; + .reduce((node, key) => (node as Record)[key], root); + const resolved = new Map(); + const resolve = (node: unknown): unknown => { + if (node === null || typeof node !== "object") return node; + const ref = (node as { $ref?: unknown }).$ref; + if (typeof ref === "string") return resolve(lookup(ref)); + if (resolved.has(node)) return resolved.get(node); + if (Array.isArray(node)) { + const out: unknown[] = []; + resolved.set(node, out); + for (const value of node) out.push(resolve(value)); + return out; + } + const out: Record = {}; + resolved.set(node, out); + for (const [key, value] of Object.entries(node)) out[key] = resolve(value); + return out; + }; + return resolve(root); } +const spec = resolveRefs(JSON.parse(readFileSync(join(repoRoot, "api-spec.json"), "utf8"))) as Spec; + function toSegments(path: string): Segment[] { const segments: Segment[] = []; for (const part of path.split("/").filter(Boolean)) { @@ -83,39 +125,36 @@ function toSegments(path: string): Segment[] { function loadOperations(): Operation[] { const ops: Operation[] = []; - for (const [path, item] of Object.entries(spec.paths)) { + for (const [path, item] of Object.entries(spec.paths)) { for (const method of ["get", "post", "put", "patch", "delete"] as const) { const op = item[method]; if (!op) continue; - const params = (op.parameters ?? []).map(deref); - const toInput = (p: any): Input => ({ - name: p.name, - required: Boolean(p.required), - description: p.description ?? "", - schema: deref(p.schema ?? {}), + const params = op.parameters ?? []; + const toInput = (param: SpecParameter): Input => ({ + name: param.name, + required: Boolean(param.required), + description: param.description ?? "", + schema: param.schema ?? {}, }); - const body = op.requestBody ? deref(op.requestBody) : undefined; + const body = op.requestBody; const form = body?.content?.["multipart/form-data"]; - const formSchema: Schema = form ? deref(form.schema) : {}; - const ok = deref(op.responses?.["200"] ?? op.responses?.["201"] ?? {}); + const formSchema: Schema = form?.schema ?? {}; + const ok = op.responses?.["200"] ?? op.responses?.["201"] ?? {}; ops.push({ id: op.operationId, method, path, summary: op.summary ?? "", segments: toSegments(path), - pathParams: params.filter((p: any) => p.in === "path").map(toInput), - queryParams: params.filter((p: any) => p.in === "query").map(toInput), + pathParams: params.filter((param) => param.in === "path").map(toInput), + queryParams: params.filter((param) => param.in === "query").map(toInput), body: form ? "multipart" : body ? "json" : "none", - formFields: Object.entries(formSchema.properties ?? {}).map(([name, s]) => { - const schema = deref(s); - return { - name, - required: (formSchema.required ?? []).includes(name), - description: schema.description ?? "", - schema, - }; - }), + formFields: Object.entries(formSchema.properties ?? {}).map(([name, schema]) => ({ + name, + required: (formSchema.required ?? []).includes(name), + description: schema.description ?? "", + schema, + })), returnsFile: Object.keys(ok.content ?? {}).some((t) => t !== "application/json"), }); } @@ -155,6 +194,9 @@ function singular(segment: string): string { const isPlural = (segment: string) => singular(segment) !== segment; +// The schema of one value: an array's items, else the schema itself. +const valueSchema = (schema: Schema): Schema => (schema.type === "array" ? (schema.items ?? {}) : schema); + // Display names from `config.nouns`, keyed by " " or by // segment, else from the segment itself. const nounFor = (segment: string, group?: string) => @@ -181,7 +223,7 @@ type Placement = { function findMount(path: string): string[] | undefined { const prefix = Object.keys(config.mounts).find( - (p) => path === p || path.startsWith(`${p}/`), + (mount) => path === mount || path.startsWith(`${mount}/`), ); return prefix ? config.mounts[prefix]!.split(" ") : undefined; } @@ -200,14 +242,14 @@ function place(op: Operation): Placement { } // `-`; see AGENTS.md "Command structure". -function commandName(op: Operation, p: Placement): string { +function commandName(op: Operation, placement: Placement): string { const atItem = op.segments.at(-1)!.param !== undefined; let verb: string; let rest: string[] = []; // Operations on a whole collection keep the last noun plural. let plural = !atItem; - if (p.action) { - [verb, ...rest] = p.action.split("-") as [string, ...string[]]; + if (placement.action) { + [verb, ...rest] = placement.action.split("-") as [string, ...string[]]; plural = false; } else if (op.method === "get") { verb = atItem || op.returnsFile ? "get" : "list"; @@ -218,8 +260,8 @@ function commandName(op: Operation, p: Placement): string { } else { verb = { patch: "update", put: "set", delete: "delete" }[op.method]; } - const noun = p.resources.map((s, i) => - plural && i === p.resources.length - 1 + const noun = placement.resources.map((s, i) => + plural && i === placement.resources.length - 1 ? s.name : (config.nouns[s.name]?.commandSingular ?? singular(s.name)), ); @@ -281,7 +323,7 @@ function paramNoun(name: string): string { // See AGENTS.md "Help text" for the placeholder vocabulary. function placeholderFor(input: Input): string { const { name, schema, description } = input; - const item = schema.type === "array" ? deref(schema.items ?? {}) : schema; + const item = valueSchema(schema); if (item.type === "boolean" || /^(is|has)[A-Z]/.test(name)) return "bool"; if (item.format === "date") return "date"; if (item.format === "date-time" || (!item.enum && /(Date|After|Before)$/.test(name))) { @@ -317,7 +359,7 @@ function cleanDescription(text: string): string { // The spec description, plus enum choices and format hints. function inputDescription(input: Input, placeholder: string, listed: string): string { const { name, schema } = input; - const item = schema.type === "array" ? deref(schema.items ?? {}) : schema; + const item = valueSchema(schema); let text = cleanDescription(input.description); if (!text && placeholder === "query") text = `Search ${listed}`; if (!text) { @@ -346,11 +388,11 @@ function inputDescription(input: Input, placeholder: string, listed: string): st // "FAQ ID" for `faqId` when the noun has a configured display name, else the // humanized parameter name ("Security review ID"). -function pathParamDescription(name: string, p: Placement): string { - const segment = p.resources.find((s) => s.param === name); +function pathParamDescription(name: string, placement: Placement): string { + const segment = placement.resources.find((s) => s.param === name); const display = segment && - (nounFor(segment.name, p.group[0])?.singular ?? + (nounFor(segment.name, placement.group[0])?.singular ?? (name === "id" ? displaySingular(segment.name) : undefined)); if (display) return `${upperFirst(display)} ID`; return upperFirst(humanize(name).replace(/ id$/, " ID")); @@ -359,17 +401,17 @@ function pathParamDescription(name: string, p: Placement): string { // Whether the SDK types the value as plain `string` (or `string[]`), rather // than an enum or union. function isPlainString(schema: Schema): boolean { - const item = schema.type === "array" ? deref(schema.items ?? {}) : schema; + const item = valueSchema(schema); return item.type !== "string" ? item.type === "boolean" || item.type === "integer" : !item.enum; } -function buildOptions(op: Operation, p: Placement, group: Group): Option[] { +function buildOptions(op: Operation, placement: Placement, group: Group): Option[] { const overrides = config.operations[op.id]?.options ?? {}; const placeholderOverride = (key: string) => overrides[key]?.placeholder ?? config.params[key]?.placeholder; // What a search flag searches: the listed resource, else the group. - const last = p.resources.at(-1); - const listed = last ? displayPlural(last.name, p.group[0]) : group.plural; + const last = placement.resources.at(-1); + const listed = last ? displayPlural(last.name, placement.group[0]) : group.plural; const withOverrides = (option: Option, ...extra: (config.OptionOverride | undefined)[]) => { for (const o of [...extra, config.params[option.key], overrides[option.key]]) { Object.assign(option, Object.fromEntries(Object.entries(o ?? {}).filter(([, v]) => v))); @@ -380,13 +422,13 @@ function buildOptions(op: Operation, p: Placement, group: Group): Option[] { for (const param of op.pathParams) { // The group's own ID is `--id`; `resourceKind` stays `--resource-kind`. - const own = param.name === p.groupSegment.param && /(^id|Id)$/.test(param.name); + const own = param.name === placement.groupSegment.param && /(^id|Id)$/.test(param.name); const option: Option = { flag: own ? "id" : kebab(param.name), placeholder: own || /Id$/.test(param.name) ? "id" : kebab(param.name), description: own ? `${upperFirst(group.singular)} ID` - : pathParamDescription(param.name, p), + : pathParamDescription(param.name, placement), required: true, repeatable: false, parseBool: false, @@ -457,40 +499,40 @@ const prepositions = new Set(["as", "for", "from", "in", "to", "with"]); // Describes a command from its verb, its subject (the last resource), and the // subject's owner (the group and any resources in between). -function commandDescription(name: string, op: Operation, p: Placement, group: Group): string { +function commandDescription(name: string, op: Operation, placement: Placement, group: Group): string { const atItem = op.segments.at(-1)!.param !== undefined; const verb = upperFirst(name.split("-")[0]!); - const g = p.group[0]; - const subjectSegment = p.resources.at(-1); - const parents = p.resources.slice(0, -1); - const parentSegment = parents.at(-1) ?? p.groupSegment; + const topGroup = placement.group[0]; + const subjectSegment = placement.resources.at(-1); + const parents = placement.resources.slice(0, -1); + const parentSegment = parents.at(-1) ?? placement.groupSegment; - const ownsResources = groupsWithEndpoints.has(p.groupSegment.name) || findMount(op.path); + const ownsResources = groupsWithEndpoints.has(placement.groupSegment.name) || findMount(op.path); const ownerWords = [ ...(ownsResources ? [group.singular] : []), - ...parents.map((s) => displaySingular(s.name, g)), + ...parents.map((s) => displaySingular(s.name, topGroup)), ]; const owner = ownerWords.join(" "); const owners = parents.length - ? [...ownerWords.slice(0, -1), displayPlural(parentSegment.name, g)].join(" ") + ? [...ownerWords.slice(0, -1), displayPlural(parentSegment.name, topGroup)].join(" ") : group.plural; const subject = subjectSegment && { one: - nounFor(subjectSegment.name, g)?.commandSingular ?? - displaySingular(subjectSegment.name, g), - many: displayPlural(subjectSegment.name, g), + nounFor(subjectSegment.name, topGroup)?.commandSingular ?? + displaySingular(subjectSegment.name, topGroup), + many: displayPlural(subjectSegment.name, topGroup), }; // With no owner, the subject stands alone ("List questionnaires"). const one = subject && owner ? owner : (subject?.one ?? group.singular); const many = subject && owner ? owners : (subject?.many ?? group.plural); - if (p.action) { + if (placement.action) { // `add-document-to-control` under `controls` → "document". - const own = singular(p.groupSegment.name); + const own = singular(placement.groupSegment.name); const suffix = ["to", "from", "for"] .map((preposition) => `-${preposition}-${own}`) - .find((s) => p.action!.endsWith(s)); - const action = suffix ? p.action.slice(0, -suffix.length) : p.action; + .find((s) => placement.action!.endsWith(s)); + const action = suffix ? placement.action.slice(0, -suffix.length) : placement.action; const rest = humanize(action).split(" ").slice(1); const target = subject && owner ? `${owner} ${subject.one}` : one; // Actions on the collection, e.g. `people offboard`. @@ -536,7 +578,7 @@ function commandDescription(name: string, op: Operation, p: Placement, group: Gr // `deactivated-controls` under `controls` → "List deactivated controls". const words = humanize(subjectSegment.name) .split(" ") - .filter((w) => w !== p.groupSegment.name && w !== singular(p.groupSegment.name)); + .filter((w) => w !== placement.groupSegment.name && w !== singular(placement.groupSegment.name)); return `List ${words.join(" ")} ${many}`; } // A resource of its own under the collection: "Create a questionnaire export". @@ -568,14 +610,14 @@ function commandDescription(name: string, op: Operation, p: Placement, group: Gr return `Upload ${a(subject.one)} for ${a(one)}`; case "Set": return subjectSegment.name === "order" - ? `Reorder ${displayPlural(parentSegment.name, g)} for ${a(ownerWords.slice(0, -1).join(" "))}` + ? `Reorder ${displayPlural(parentSegment.name, topGroup)} for ${a(ownerWords.slice(0, -1).join(" "))}` : `Set ${subject.many} for ${a(one)}`; } return op.summary; } // =========================================================================== -// 5. Tree +// 5. Groups // =========================================================================== type Command = { @@ -593,7 +635,8 @@ type Group = { description: string; singular: string; plural: string; - children: Map; + // Mounted subgroups (`vendors assessment-types`). + children: Group[]; commands: Command[]; // Output file name without `.ts`. file: string; @@ -608,53 +651,45 @@ function newGroup(path: string[], apiSegment: string): Group { description: `Manage ${plural}`, singular: noun?.singular ?? displaySingular(apiSegment), plural, - children: new Map(), + children: [], commands: [], // Mounted groups are named after their API path: `vendor-assessment-types`. file: path.length === 1 ? path[0]! : apiSegment, }; } -// The group for a placement, created on first use. -function groupFor(root: Group, p: Placement): Group { - let group = root; - p.group.forEach((name, depth) => { - const path = p.group.slice(0, depth + 1); - const apiSegment = depth === p.group.length - 1 ? p.groupSegment.name : name; - if (!group.children.has(name)) group.children.set(name, newGroup(path, apiSegment)); - group = group.children.get(name)!; - }); - return group; -} - -function buildTree(): Group { - const root = newGroup([""], ""); +// Every group, keyed by its CLI path ("vendors", "vendors assessment-types"). +function buildGroups(): Map { + const groups = new Map(); + // The group at `path`, created on first use along with its parent. + const groupAt = (path: string[], apiSegment: string): Group => { + const key = path.join(" "); + if (!groups.has(key)) { + const group = newGroup(path, apiSegment); + groups.set(key, group); + if (path.length > 1) groupAt(path.slice(0, -1), path.at(-2)!).children.push(group); + } + return groups.get(key)!; + }; for (const op of operations) { - const p = place(op); - const group = groupFor(root, p); - const name = config.operations[op.id]?.name ?? commandName(op, p); + const placement = place(op); + const group = groupAt(placement.group, placement.groupSegment.name); + const name = config.operations[op.id]?.name ?? commandName(op, placement); const queryNames = op.queryParams.map((q) => q.name); group.commands.push({ name, - description: commandDescription(name, op, p, group), + description: commandDescription(name, op, placement, group), op, - options: buildOptions(op, p, group), + options: buildOptions(op, placement, group), paginated: queryNames.includes("pageSize") && queryNames.includes("pageCursor"), download: op.returnsFile, }); } - return root; -} - -function* allGroups(group: Group): Generator { - for (const child of group.children.values()) { - yield child; - yield* allGroups(child); - } + return groups; } -function checkUniqueNames(root: Group): void { - for (const group of allGroups(root)) { +function checkUniqueNames(groups: Group[]): void { + for (const group of groups) { const seen = new Map(); for (const { name, op } of group.commands) { const path = [...group.path, name].join(" "); @@ -663,7 +698,9 @@ function checkUniqueNames(root: Group): void { `duplicate command "${path}" for ${seen.get(name)} and ${op.id}; add a name override in scripts/codegen/config.ts`, ); } - if (group.children.has(name)) throw new Error(`command "${path}" collides with a group`); + if (group.children.some((child) => child.name === name)) { + throw new Error(`command "${path}" collides with a group`); + } seen.set(name, op.id); } } @@ -672,7 +709,7 @@ function checkUniqueNames(root: Group): void { // Replaces generated descriptions with `config.commandHelp` and // `config.optionHelp`, and fails on entries that match nothing or repeat the // default, so the overrides stay a list of deliberate wording. -function applyHelpOverrides(root: Group): void { +function applyHelpOverrides(groups: Group[]): void { const problems: string[] = []; const unusedCommands = new Set(Object.keys(config.commandHelp)); const unusedOptions = new Set( @@ -687,7 +724,7 @@ function applyHelpOverrides(root: Group): void { return text; }; - for (const group of allGroups(root)) { + for (const group of groups) { const groupKey = group.path.join(" "); group.description = override(groupKey, group.description, config.commandHelp[groupKey], unusedCommands); for (const command of group.commands) { @@ -903,7 +940,7 @@ function renderGroup(group: Group, parent: string, files: Map, i ? declaration : ` const ${v} = ${parent}\n .command(${quote(group.name)})\n .description(${quote(group.description)});`, ]; - for (const child of group.children.values()) { + for (const child of group.children) { imports.registers.set(registerFunction(child), child.file); parts.push(` ${registerFunction(child)}(${v}, getFlags);`); renderFile(child, files); @@ -953,26 +990,26 @@ function renderFile(group: Group, files: Map): void { ); } -function renderFiles(root: Group): Map { +function renderFiles(groups: Group[]): Map { const files = new Map(); const order = (g: Group) => { const i = config.topLevelOrder.indexOf(g.name); return i === -1 ? config.topLevelOrder.length : i; }; - const groups = [...root.children.values()].sort( - (x, y) => order(x) - order(y) || x.name.localeCompare(y.name), - ); - for (const group of groups) renderFile(group, files); + const topLevel = groups + .filter((g) => g.path.length === 1) + .sort((x, y) => order(x) - order(y) || x.name.localeCompare(y.name)); + for (const group of topLevel) renderFile(group, files); files.set( "index.ts", [ generatedHeader, `import type { Command } from "commander";`, - ...groups.map((g) => `import { ${registerFunction(g)} } from "./${g.file}.js";`), + ...topLevel.map((g) => `import { ${registerFunction(g)} } from "./${g.file}.js";`), `import type { GetFlags } from "./helpers.js";`, "", "export function registerApiCommands(program: Command, getFlags: GetFlags): void {", - ...groups.map((g) => ` ${registerFunction(g)}(program, getFlags);`), + ...topLevel.map((g) => ` ${registerFunction(g)}(program, getFlags);`), "}", "", ].join("\n"), @@ -1002,7 +1039,7 @@ const outDir = args.includes("--out") ? args[args.indexOf("--out") + 1]! : join(repoRoot, "src/commands"); -const root = buildTree(); -checkUniqueNames(root); -applyHelpOverrides(root); -writeFiles(renderFiles(root), outDir); +const groups = [...buildGroups().values()]; +checkUniqueNames(groups); +applyHelpOverrides(groups); +writeFiles(renderFiles(groups), outDir); From ed017175e3b3b3b4aa0b252f17995ac2805555c5 Mon Sep 17 00:00:00 2001 From: Dennis Chen Date: Wed, 21 Oct 2026 17:45:22 +0000 Subject: [PATCH 4/9] Derive linking and download command names in the generator - `links` in config.ts lists operations that attach or detach an existing resource; the generator names them add/remove. The spec can't mark these itself, since its summaries also say "Add" for creates. - GETs that return a file use `download`. - `commandPlural` lets `uploads` read as `files` in list commands. - Drops the overrides that shortened names repeating the group (`list-library`, `list-deactivated`, `add-document`, `add-test`). - Removes mounts: every group is its path's first segment, so the generator no longer nests groups. Co-Authored-By: Claude Opus 5.5 --- scripts/codegen/config.ts | 64 +++++++-------- scripts/codegen/generate-commands.ts | 118 +++++++++------------------ 2 files changed, 71 insertions(+), 111 deletions(-) diff --git a/scripts/codegen/config.ts b/scripts/codegen/config.ts index 8eda025..0673d04 100644 --- a/scripts/codegen/config.ts +++ b/scripts/codegen/config.ts @@ -14,6 +14,9 @@ export type Noun = { // Name used in verb-noun commands when it differs from the singular path // segment, e.g. "file" for `uploads` (`documents upload-file`). commandSingular?: string; + // Name used in `list` and bulk commands when it differs from the path + // segment, e.g. "files" for `uploads` (`documents list-files`). + commandPlural?: string; }; export type OptionOverride = { @@ -42,6 +45,7 @@ export const topLevelOrder = [ "documents", "tests", "people", + "personnel-notification-settings", "groups", "frameworks", "users", @@ -52,6 +56,8 @@ export const topLevelOrder = [ "risk-scenarios", "monitored-computers", "vendors", + "vendor-assessment-types", + "vendor-risk-attributes", "discovered-vendors", "integrations", "event-logs", @@ -63,14 +69,6 @@ export const topLevelOrder = [ "trust-centers", ]; -// Domain grouping: API resources mounted under another group instead of at the -// top level. Keyed by the API path prefix. -export const mounts: Record = { - "/personnel-notification-settings": "people notification-settings", - "/vendor-risk-attributes": "vendors risk-attributes", - "/vendor-assessment-types": "vendors assessment-types", -}; - // Display names, keyed by " " or by path segment. Defaults humanize the segment, so only // proper nouns, acronyms, and irregular forms belong here. export const nouns: Record = { @@ -89,10 +87,10 @@ export const nouns: Record = { plural: "Knowledge Base resources", }, "trust-centers": { singular: "Trust Center", plural: "Trust Centers" }, - uploads: { commandSingular: "file" }, + uploads: { commandSingular: "file", commandPlural: "files" }, }; -// Keyed by CLI group path, e.g. "trust-centers". +// Keyed by group, e.g. "trust-centers". export const groups: Record = { "trust-centers": { id: { @@ -136,26 +134,29 @@ export const actionVerbs = [ "verify", ]; +// Operations that attach or detach an existing resource rather than create or +// delete one, keyed by operationId: POST is `add` and DELETE is `remove` +// (`groups add-person`). The spec can't tell these apart; its summaries say +// "Add" for creates too. +export const links = [ + "AddPersonToGroup", + "RemovePersonFromGroup", + "DeleteDocumentForcontrol", + "DeleteTestForControl", + "CreateRiskScenarioControl", + "DeleteRiskScenarioControl", + "AddControlToTrustCenter", + "DeleteTrustCenterControl", + "BulkAddTagsToControls", + "BulkRemoveTagsFromControls", + "AddTagCategoryProductContext", + "RemoveTagCategoryProductContext", +]; + // Command names that differ from the naming rules, keyed by operationId. export const operations: Record = { - // Linking existing people, documents, tests, and controls: add/remove, not - // create/delete. - AddPersonToGroup: { name: "add-person" }, - RemovePersonFromGroup: { name: "remove-person" }, - DeleteDocumentForcontrol: { name: "remove-document" }, - DeleteTestForControl: { name: "remove-test" }, - CreateRiskScenarioControl: { name: "add-control" }, - AddDocumentToControl: { name: "add-document" }, - AddTestToControl: { name: "add-test" }, - BulkAddTagsToControls: { name: "add-control-tags" }, - BulkRemoveTagsFromControls: { name: "remove-control-tags" }, - // Don't repeat the group's noun. - ListLibraryControls: { name: "list-library" }, - ListDeactivatedControls: { name: "list-deactivated" }, // A single settings object, not a collection. GetSettings: { name: "get" }, - // The path names the media, not the action. - GetUploadedfileMedia: { name: "download-file" }, // Replaces the file on an existing resource; `upload-resource-document` // creates one. ReplaceDocumentResourceFile: { name: "replace-resource-document-file" }, @@ -174,11 +175,11 @@ export const operations: Record = { // the default. export const commandHelp: Record = { "controls create": "Create a custom control", - "controls list-library": "List controls from the library", + "controls list-controls-library": "List controls from the library", "controls set-owner": "Set or clear a control owner", "customer-trust": "Manage Customer Trust accounts, questionnaires, and tags", - "customer-trust create-tag-category-product-context": "Enable a tag category for a product context", - "customer-trust delete-tag-category-product-context": "Disable a tag category for a product context", + "customer-trust add-tag-category-product-context": "Enable a tag category for a product context", + "customer-trust remove-tag-category-product-context": "Disable a tag category for a product context", "customer-trust get-questionnaire-export": "Get a questionnaire export's status by ID", "customer-trust get-tag-category": "Get the tags in a tag category by ID", "customer-trust list-questionnaire-assignable-users": "List users who can be assigned to questionnaires", @@ -191,7 +192,6 @@ export const commandHelp: Record = { "knowledge-base replace-resource-document-file": "Replace the file for a document resource", "people mark-as-not-people": "Mark accounts as not people", "people mark-as-people": "Mark accounts as people", - "trust-centers create-control": "Add a control to a Trust Center", "trust-centers get": "Get a Trust Center by slug ID", "trust-centers get-chatbot-conversation": "Get messages for a Trust Center chatbot conversation", "trust-centers list-chatbot-conversations": "List Trust Center chatbot conversations", @@ -204,7 +204,7 @@ export const optionHelp: Record> = { "controls list": { "--framework-matches-any": "Framework IDs to filter by (repeatable)", }, - "customer-trust delete-tag-category-product-context": { + "customer-trust remove-tag-category-product-context": { "--product-context-id": "Product context: EXTERNAL_TRUST_CENTER, DOCUMENT_SHARING, CONTROL_SHARING", }, "customer-trust get-questionnaire-export": { @@ -258,7 +258,7 @@ export const optionHelp: Record> = { "--include-removed": "Include removed viewers (true/false; default: true)", }, "vendors list-assessments": { - "--type-id-matches-any": "Filter by assessment type ID from vendors assessment-types list (repeatable)", + "--type-id-matches-any": "Filter by assessment type ID from vendor-assessment-types list (repeatable)", }, "vulnerabilities list": { "--include-vulnerabilities-without-slas": "Include vulnerabilities without SLA dates (true/false)", diff --git a/scripts/codegen/generate-commands.ts b/scripts/codegen/generate-commands.ts index e23f595..dc0f564 100644 --- a/scripts/codegen/generate-commands.ts +++ b/scripts/codegen/generate-commands.ts @@ -212,29 +212,19 @@ const displayPlural = (segment: string, group?: string) => // Where an operation sits in the CLI and what its path is made of. type Placement = { - // CLI group path: ["vendors"], or ["people", "notification-settings"] for a mount. - group: string[]; - // The path segment the group stands for. + // The path's first segment, which names the group. groupSegment: Segment; // Segments after the group, minus a trailing action: they form the noun. resources: Segment[]; action?: string; }; -function findMount(path: string): string[] | undefined { - const prefix = Object.keys(config.mounts).find( - (mount) => path === mount || path.startsWith(`${mount}/`), - ); - return prefix ? config.mounts[prefix]!.split(" ") : undefined; -} - function place(op: Operation): Placement { const [groupSegment, ...rest] = op.segments as [Segment, ...Segment[]]; const last = rest.at(-1); const isAction = last && !last.param && config.actionVerbs.includes(last.name.split("-")[0]!); return { - group: findMount(op.path) ?? [groupSegment.name], groupSegment, resources: isAction ? rest.slice(0, -1) : rest, action: isAction ? last.name : undefined, @@ -248,21 +238,25 @@ function commandName(op: Operation, placement: Placement): string { let rest: string[] = []; // Operations on a whole collection keep the last noun plural. let plural = !atItem; + const link = config.links.includes(op.id); if (placement.action) { [verb, ...rest] = placement.action.split("-") as [string, ...string[]]; plural = false; } else if (op.method === "get") { - verb = atItem || op.returnsFile ? "get" : "list"; + verb = op.returnsFile ? "download" : atItem ? "get" : "list"; plural = verb === "list"; } else if (op.method === "post") { - verb = op.body === "multipart" ? "upload" : "create"; - plural = false; + verb = link ? "add" : op.body === "multipart" ? "upload" : "create"; + // `create` and `add` make one item, unless the API adds many at once. + plural = op.id.startsWith("Bulk"); + } else if (op.method === "delete" && link) { + verb = "remove"; } else { verb = { patch: "update", put: "set", delete: "delete" }[op.method]; } const noun = placement.resources.map((s, i) => plural && i === placement.resources.length - 1 - ? s.name + ? (config.nouns[s.name]?.commandPlural ?? s.name) : (config.nouns[s.name]?.commandSingular ?? singular(s.name)), ); return [verb, ...noun, ...rest].join("-"); @@ -287,9 +281,7 @@ type Option = { key: string; }; -const topLevelGroups = new Set( - operations.map((op) => findMount(op.path)?.[0] ?? op.segments[0]!.name), -); +const topLevelGroups = new Set(operations.map((op) => op.segments[0]!.name)); const searchParams = new Set(["q", "search", "searchString"]); const placeholderWords = new Set([ @@ -392,7 +384,7 @@ function pathParamDescription(name: string, placement: Placement): string { const segment = placement.resources.find((s) => s.param === name); const display = segment && - (nounFor(segment.name, placement.group[0])?.singular ?? + (nounFor(segment.name, placement.groupSegment.name)?.singular ?? (name === "id" ? displaySingular(segment.name) : undefined)); if (display) return `${upperFirst(display)} ID`; return upperFirst(humanize(name).replace(/ id$/, " ID")); @@ -411,7 +403,7 @@ function buildOptions(op: Operation, placement: Placement, group: Group): Option overrides[key]?.placeholder ?? config.params[key]?.placeholder; // What a search flag searches: the listed resource, else the group. const last = placement.resources.at(-1); - const listed = last ? displayPlural(last.name, placement.group[0]) : group.plural; + const listed = last ? displayPlural(last.name, placement.groupSegment.name) : group.plural; const withOverrides = (option: Option, ...extra: (config.OptionOverride | undefined)[]) => { for (const o of [...extra, config.params[option.key], overrides[option.key]]) { Object.assign(option, Object.fromEntries(Object.entries(o ?? {}).filter(([, v]) => v))); @@ -436,7 +428,7 @@ function buildOptions(op: Operation, placement: Placement, group: Group): Option target: "path", key: param.name, }; - options.push(withOverrides(option, own ? config.groups[group.path.join(" ")]?.id : undefined)); + options.push(withOverrides(option, own ? config.groups[group.name]?.id : undefined)); } for (const param of op.queryParams) { @@ -502,12 +494,12 @@ const prepositions = new Set(["as", "for", "from", "in", "to", "with"]); function commandDescription(name: string, op: Operation, placement: Placement, group: Group): string { const atItem = op.segments.at(-1)!.param !== undefined; const verb = upperFirst(name.split("-")[0]!); - const topGroup = placement.group[0]; + const topGroup = placement.groupSegment.name; const subjectSegment = placement.resources.at(-1); const parents = placement.resources.slice(0, -1); const parentSegment = parents.at(-1) ?? placement.groupSegment; - const ownsResources = groupsWithEndpoints.has(placement.groupSegment.name) || findMount(op.path); + const ownsResources = groupsWithEndpoints.has(placement.groupSegment.name); const ownerWords = [ ...(ownsResources ? [group.singular] : []), ...parents.map((s) => displaySingular(s.name, topGroup)), @@ -629,56 +621,40 @@ type Command = { download: boolean; }; +// A group is named after its path segment, which also names its file. type Group = { - path: string[]; name: string; description: string; singular: string; plural: string; - // Mounted subgroups (`vendors assessment-types`). - children: Group[]; commands: Command[]; - // Output file name without `.ts`. - file: string; }; -function newGroup(path: string[], apiSegment: string): Group { - const noun = config.nouns[path.join(" ")]; - const plural = noun?.plural ?? displayPlural(apiSegment); +function newGroup(name: string): Group { + const noun = config.nouns[name]; + const plural = noun?.plural ?? displayPlural(name); return { - path, - name: path.at(-1)!, + name, description: `Manage ${plural}`, - singular: noun?.singular ?? displaySingular(apiSegment), + singular: noun?.singular ?? displaySingular(name), plural, - children: [], commands: [], - // Mounted groups are named after their API path: `vendor-assessment-types`. - file: path.length === 1 ? path[0]! : apiSegment, }; } -// Every group, keyed by its CLI path ("vendors", "vendors assessment-types"). +// Every group, keyed by name. function buildGroups(): Map { const groups = new Map(); - // The group at `path`, created on first use along with its parent. - const groupAt = (path: string[], apiSegment: string): Group => { - const key = path.join(" "); - if (!groups.has(key)) { - const group = newGroup(path, apiSegment); - groups.set(key, group); - if (path.length > 1) groupAt(path.slice(0, -1), path.at(-2)!).children.push(group); - } - return groups.get(key)!; - }; for (const op of operations) { const placement = place(op); - const group = groupAt(placement.group, placement.groupSegment.name); - const name = config.operations[op.id]?.name ?? commandName(op, placement); + const name = placement.groupSegment.name; + if (!groups.has(name)) groups.set(name, newGroup(name)); + const group = groups.get(name)!; + const command = config.operations[op.id]?.name ?? commandName(op, placement); const queryNames = op.queryParams.map((q) => q.name); group.commands.push({ - name, - description: commandDescription(name, op, placement, group), + name: command, + description: commandDescription(command, op, placement, group), op, options: buildOptions(op, placement, group), paginated: queryNames.includes("pageSize") && queryNames.includes("pageCursor"), @@ -692,15 +668,12 @@ function checkUniqueNames(groups: Group[]): void { for (const group of groups) { const seen = new Map(); for (const { name, op } of group.commands) { - const path = [...group.path, name].join(" "); + const path = `${group.name} ${name}`; if (seen.has(name)) { throw new Error( `duplicate command "${path}" for ${seen.get(name)} and ${op.id}; add a name override in scripts/codegen/config.ts`, ); } - if (group.children.some((child) => child.name === name)) { - throw new Error(`command "${path}" collides with a group`); - } seen.set(name, op.id); } } @@ -725,7 +698,7 @@ function applyHelpOverrides(groups: Group[]): void { }; for (const group of groups) { - const groupKey = group.path.join(" "); + const groupKey = group.name; group.description = override(groupKey, group.description, config.commandHelp[groupKey], unusedCommands); for (const command of group.commands) { const key = `${groupKey} ${command.name}`; @@ -753,7 +726,7 @@ const indent = (text: string, n: number) => .map((line) => (line ? pad(n) + line : line)) .join("\n"); const sdkFunction = (op: Operation) => lowerFirst(op.id); -const registerFunction = (g: Group) => `register${pascal(g.file)}Command`; +const registerFunction = (g: Group) => `register${pascal(g.name)}Command`; const groupVariable = (g: Group) => { const name = camel(g.name); return ["delete", "export", "import", "new"].includes(name) ? `${name}Group` : name; @@ -779,7 +752,6 @@ type Imports = { types: Set; helpers: Set; output: boolean; - registers: Map; }; // The SDK call for a command, and statements that must run before it. @@ -932,19 +904,14 @@ function renderAction( return `.action(\n async (opts: {\n${type}\n }) => {\n${indent(body, 4)}\n },\n)`; } -function renderGroup(group: Group, parent: string, files: Map, imports: Imports): string { +function renderGroup(group: Group, imports: Imports): string { const v = groupVariable(group); - const declaration = ` const ${v} = ${parent}.command(${quote(group.name)}).description(${quote(group.description)});`; + const declaration = ` const ${v} = program.command(${quote(group.name)}).description(${quote(group.description)});`; const parts = [ declaration.length <= 80 ? declaration - : ` const ${v} = ${parent}\n .command(${quote(group.name)})\n .description(${quote(group.description)});`, + : ` const ${v} = program\n .command(${quote(group.name)})\n .description(${quote(group.description)});`, ]; - for (const child of group.children) { - imports.registers.set(registerFunction(child), child.file); - parts.push(` ${registerFunction(child)}(${v}, getFlags);`); - renderFile(child, files); - } // list, get, create, update, delete first; the rest in spec order. const order = ["list", "get", "create", "update", "delete"]; const rank = (c: Command) => (order.includes(c.name) ? order.indexOf(c.name) : order.length); @@ -967,15 +934,10 @@ function renderFile(group: Group, files: Map): void { types: new Set(), helpers: new Set(), output: false, - registers: new Map(), }; - const parent = group.path.length === 1 ? "program" : "parent"; - const body = renderGroup(group, parent, files, imports); + const body = renderGroup(group, imports); const lines = [generatedHeader, `import type { Command } from "commander";`]; - for (const [fn, file] of imports.registers) { - lines.push(`import { ${fn} } from "./${file}.js";`); - } if (imports.sdk.size) lines.push(renderImport("import", imports.sdk, "../generated/sdk.gen.js")); if (imports.types.size) { lines.push(renderImport("import type", imports.types, "../generated/types.gen.js")); @@ -985,8 +947,8 @@ function renderFile(group: Group, files: Map): void { lines.push(renderImport("import", [...[...imports.helpers].sort(), "type GetFlags"], "./helpers.js")); files.set( - `${group.file}.ts`, - `${lines.join("\n")}\n\nexport function ${registerFunction(group)}(\n ${parent}: Command,\n getFlags: GetFlags,\n): void {\n${body}\n}\n`, + `${group.name}.ts`, + `${lines.join("\n")}\n\nexport function ${registerFunction(group)}(\n program: Command,\n getFlags: GetFlags,\n): void {\n${body}\n}\n`, ); } @@ -996,16 +958,14 @@ function renderFiles(groups: Group[]): Map { const i = config.topLevelOrder.indexOf(g.name); return i === -1 ? config.topLevelOrder.length : i; }; - const topLevel = groups - .filter((g) => g.path.length === 1) - .sort((x, y) => order(x) - order(y) || x.name.localeCompare(y.name)); + const topLevel = [...groups].sort((x, y) => order(x) - order(y) || x.name.localeCompare(y.name)); for (const group of topLevel) renderFile(group, files); files.set( "index.ts", [ generatedHeader, `import type { Command } from "commander";`, - ...topLevel.map((g) => `import { ${registerFunction(g)} } from "./${g.file}.js";`), + ...topLevel.map((g) => `import { ${registerFunction(g)} } from "./${g.name}.js";`), `import type { GetFlags } from "./helpers.js";`, "", "export function registerApiCommands(program: Command, getFlags: GetFlags): void {", From 7ba52b5cf9001fca49a40569c91f242a7676c53f Mon Sep 17 00:00:00 2001 From: Dennis Chen Date: Mon, 26 Oct 2026 21:40:59 +0000 Subject: [PATCH 5/9] Split the command generator into modules and format its output with oxfmt - One module per stage (spec, naming, options, help, groups, render), with main.ts running them in order. Values that were module-level globals (the group names, the groups with endpoints) are computed in buildGroups and passed in. - The renderer writes plain templates, and oxfmt lays them out using the repo's .oxfmtrc.json, replacing the hand-written line-width logic. - pluralize, indefinite, and change-case replace the hand-rolled singular/plural, a/an, and case helpers. Output is unchanged. - commandDescription is split by kind of command: actions, the group's own resources, resources under a collection, and resources under an item. - Downloads pass `parseAs: "blob"`. #30 added this to documents.ts by hand, so regenerating dropped it; `trust-centers download-resource-media` gets it too. - `pnpm generate:commands` runs scripts/codegen/main.ts. Apart from the header and parseAs, the generated commands match the previous generator's output after formatting. Co-Authored-By: Claude Opus 5.5 --- package.json | 6 +- pnpm-lock.yaml | 34 + scripts/codegen/config.ts | 79 +- scripts/codegen/generate-commands.ts | 1005 ----------------- scripts/codegen/groups.ts | 88 ++ scripts/codegen/help.ts | 267 +++++ scripts/codegen/indefinite.d.ts | 3 + scripts/codegen/main.ts | 64 ++ scripts/codegen/naming.ts | 59 + scripts/codegen/options.ts | 275 +++++ scripts/codegen/render.ts | 243 ++++ scripts/codegen/spec.ts | 154 +++ scripts/codegen/words.ts | 34 + src/commands/business-units.ts | 2 +- src/commands/contracts.ts | 2 +- src/commands/controls.ts | 2 +- src/commands/customer-trust.ts | 2 +- src/commands/discovered-vendors.ts | 2 +- src/commands/documents.ts | 2 +- src/commands/event-logs.ts | 2 +- src/commands/frameworks.ts | 2 +- src/commands/groups.ts | 2 +- src/commands/index.ts | 2 +- src/commands/integrations.ts | 2 +- src/commands/issues.ts | 2 +- src/commands/knowledge-base.ts | 2 +- src/commands/monitored-computers.ts | 2 +- src/commands/people.ts | 2 +- .../personnel-notification-settings.ts | 2 +- src/commands/policies.ts | 2 +- src/commands/program-scopes.ts | 2 +- src/commands/risk-scenarios.ts | 2 +- src/commands/tests.ts | 2 +- src/commands/trust-centers.ts | 3 +- src/commands/users.ts | 2 +- src/commands/vendor-assessment-types.ts | 2 +- src/commands/vendor-risk-attributes.ts | 2 +- src/commands/vendors.ts | 2 +- src/commands/vulnerabilities.ts | 2 +- src/commands/vulnerability-remediations.ts | 2 +- src/commands/vulnerable-assets.ts | 2 +- 41 files changed, 1308 insertions(+), 1060 deletions(-) delete mode 100644 scripts/codegen/generate-commands.ts create mode 100644 scripts/codegen/groups.ts create mode 100644 scripts/codegen/help.ts create mode 100644 scripts/codegen/indefinite.d.ts create mode 100644 scripts/codegen/main.ts create mode 100644 scripts/codegen/naming.ts create mode 100644 scripts/codegen/options.ts create mode 100644 scripts/codegen/render.ts create mode 100644 scripts/codegen/spec.ts create mode 100644 scripts/codegen/words.ts diff --git a/package.json b/package.json index e669552..83b6fc1 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "typecheck": "tsc --noEmit", "test": "node --import tsx --test 'src/**/*.test.ts'", "build:binaries": "tsx scripts/build-binaries.ts", - "generate:commands": "tsx scripts/codegen/generate-commands.ts", + "generate:commands": "tsx scripts/codegen/main.ts", "format": "oxfmt", "format:check": "oxfmt --check" }, @@ -29,8 +29,12 @@ "devDependencies": { "@hey-api/openapi-ts": "^0.85.0", "@types/node": "^22.15.0", + "@types/pluralize": "0.0.33", "bun": "^1.3.14", + "change-case": "5.4.4", + "indefinite": "2.5.1", "oxfmt": "0.72.0", + "pluralize": "8.0.0", "tsup": "^8.5.0", "tsx": "^4.20.0", "typescript": "^5.8.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3ded7e..28447e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,12 +182,24 @@ importers: '@types/node': specifier: ^22.15.0 version: 22.20.1 + '@types/pluralize': + specifier: 0.0.33 + version: 0.0.33 bun: specifier: ^1.3.14 version: 1.3.14 + change-case: + specifier: 5.4.4 + version: 5.4.4 + indefinite: + specifier: 2.5.1 + version: 2.5.1 oxfmt: specifier: 0.72.0 version: 0.72.0 + pluralize: + specifier: 8.0.0 + version: 8.0.0 tsup: specifier: ^8.5.0 version: 8.5.1(jiti@2.7.0)(tsx@4.23.12)(typescript@5.9.3) @@ -985,6 +997,9 @@ packages: '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + '@types/pluralize@0.0.33': + resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==} + acorn@8.18.0: resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} @@ -1028,6 +1043,9 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -1134,6 +1152,10 @@ packages: engines: {node: '>=0.4.7'} hasBin: true + indefinite@2.5.1: + resolution: {integrity: sha512-Ul0hCdnSjuFDEloYWeozTaEfljbz+0q+u4HsHns2dOk2DlhGlbRMGFtNcIL+Ve7sZYeIOTOAKA0usAXBGHpNDg==} + engines: {node: '>=6.0.0'} + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1247,6 +1269,10 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} @@ -1819,6 +1845,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pluralize@0.0.33': {} + acorn@8.18.0: {} ansi-colors@4.1.3: {} @@ -1872,6 +1900,8 @@ snapshots: cac@6.7.14: {} + change-case@5.4.4: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -2006,6 +2036,8 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 + indefinite@2.5.1: {} + is-docker@3.0.0: {} is-inside-container@1.0.0: @@ -2120,6 +2152,8 @@ snapshots: exsolve: 1.1.1 pathe: 2.0.3 + pluralize@8.0.0: {} + postcss-load-config@6.0.1(jiti@2.7.0)(tsx@4.23.12): dependencies: lilconfig: 3.1.3 diff --git a/scripts/codegen/config.ts b/scripts/codegen/config.ts index 0673d04..d8be249 100644 --- a/scripts/codegen/config.ts +++ b/scripts/codegen/config.ts @@ -1,7 +1,7 @@ // Structural decisions the command generator cannot derive from the OpenAPI // spec, plus help text that replaces generated descriptions. // -// scripts/codegen/generate-commands.ts derives the command tree from +// The generator (main.ts) derives the command tree from // api-spec.json using the conventions in AGENTS.md. Everything here is an // explicit exception or naming input to those conventions. Prefer extending the // conventions over adding per-operation overrides. @@ -104,7 +104,10 @@ export const groups: Record = { // Applied wherever the parameter appears, before per-operation overrides. export const params: Record = { slugId: { placeholder: "slug", description: "Trust Center slug ID" }, - resourceKind: { placeholder: "kind", description: "Integration resource kind" }, + resourceKind: { + placeholder: "kind", + description: "Integration resource kind", + }, // Product contexts are a fixed set of names, not IDs. productContextId: { placeholder: "product" }, productContextIdsMatchesAny: { placeholder: "product" }, @@ -178,25 +181,37 @@ export const commandHelp: Record = { "controls list-controls-library": "List controls from the library", "controls set-owner": "Set or clear a control owner", "customer-trust": "Manage Customer Trust accounts, questionnaires, and tags", - "customer-trust add-tag-category-product-context": "Enable a tag category for a product context", - "customer-trust remove-tag-category-product-context": "Disable a tag category for a product context", - "customer-trust get-questionnaire-export": "Get a questionnaire export's status by ID", + "customer-trust add-tag-category-product-context": + "Enable a tag category for a product context", + "customer-trust remove-tag-category-product-context": + "Disable a tag category for a product context", + "customer-trust get-questionnaire-export": + "Get a questionnaire export's status by ID", "customer-trust get-tag-category": "Get the tags in a tag category by ID", - "customer-trust list-questionnaire-assignable-users": "List users who can be assigned to questionnaires", - "customer-trust upload-questionnaire-file": "Upload a file to create a questionnaire", - "discovered-vendors add-to-managed": "Add a discovered vendor to managed vendors", + "customer-trust list-questionnaire-assignable-users": + "List users who can be assigned to questionnaires", + "customer-trust upload-questionnaire-file": + "Upload a file to create a questionnaire", + "discovered-vendors add-to-managed": + "Add a discovered vendor to managed vendors", "documents set-owner": "Set or clear a document owner", "documents submit": "Submit document collection", - "groups list-importable-idp-groups": "List importable identity provider groups", + "groups list-importable-idp-groups": + "List importable identity provider groups", "knowledge-base": "Manage Knowledge Base answers and resources", - "knowledge-base replace-resource-document-file": "Replace the file for a document resource", + "knowledge-base replace-resource-document-file": + "Replace the file for a document resource", "people mark-as-not-people": "Mark accounts as not people", "people mark-as-people": "Mark accounts as people", "trust-centers get": "Get a Trust Center by slug ID", - "trust-centers get-chatbot-conversation": "Get messages for a Trust Center chatbot conversation", - "trust-centers list-chatbot-conversations": "List Trust Center chatbot conversations", - "trust-centers update-control-category-controls": "Add or remove controls in a Trust Center control category", - "vulnerability-remediations acknowledge-sla-miss": "Acknowledge SLA miss for vulnerability remediations", + "trust-centers get-chatbot-conversation": + "Get messages for a Trust Center chatbot conversation", + "trust-centers list-chatbot-conversations": + "List Trust Center chatbot conversations", + "trust-centers update-control-category-controls": + "Add or remove controls in a Trust Center control category", + "vulnerability-remediations acknowledge-sla-miss": + "Acknowledge SLA miss for vulnerability remediations", }; // Keyed by command path, then by flag (without the placeholder). @@ -205,17 +220,21 @@ export const optionHelp: Record> = { "--framework-matches-any": "Framework IDs to filter by (repeatable)", }, "customer-trust remove-tag-category-product-context": { - "--product-context-id": "Product context: EXTERNAL_TRUST_CENTER, DOCUMENT_SHARING, CONTROL_SHARING", + "--product-context-id": + "Product context: EXTERNAL_TRUST_CENTER, DOCUMENT_SHARING, CONTROL_SHARING", }, "customer-trust get-questionnaire-export": { - "--id": "Export job ID returned by `customer-trust create-questionnaire-export`", + "--id": + "Export job ID returned by `customer-trust create-questionnaire-export`", }, "customer-trust list-accounts": { "--custom-fields-filter": "Filter by custom field values", - "--is-auto-approval-enabled": "Filter by whether access requests are auto-approved (true/false)", + "--is-auto-approval-enabled": + "Filter by whether access requests are auto-approved (true/false)", }, "discovered-vendors list": { - "--scope": "Discovered vendor scope (defaults to NEEDS_REVIEW in the API): NEEDS_REVIEW, IGNORED, REJECTED", + "--scope": + "Discovered vendor scope (defaults to NEEDS_REVIEW in the API): NEEDS_REVIEW, IGNORED, REJECTED", }, "documents list": { "--framework-matches-any": "Framework IDs to filter by (repeatable)", @@ -225,14 +244,18 @@ export const optionHelp: Record> = { "--search": "Search importable identity provider groups", }, "issues list": { - "--include-issues-without-due-date": "Include undated issues with --due-before-date or --due-after-date (true/false)", - "--include-only-issues-without-due-date": "Include only undated issues (true/false); cannot combine with --due-before-date or --due-after-date", + "--include-issues-without-due-date": + "Include undated issues with --due-before-date or --due-after-date (true/false)", + "--include-only-issues-without-due-date": + "Include only undated issues (true/false); cannot combine with --due-before-date or --due-after-date", }, "knowledge-base list-answer-library": { - "--matches-tags": "JSON-encoded array of `{categoryId, tagId}` pairs. Entries matching any of the given tags are returned (OR filter). Find category IDs with `vanta customer-trust list-tag-categories` and tag IDs with `vanta customer-trust get-tag-category --tag-category-id `.", + "--matches-tags": + "JSON-encoded array of `{categoryId, tagId}` pairs. Entries matching any of the given tags are returned (OR filter). Find category IDs with `vanta customer-trust list-tag-categories` and tag IDs with `vanta customer-trust get-tag-category --tag-category-id `.", }, "knowledge-base list-resources": { - "--matches-tags": "JSON-encoded array of `{categoryId, tagId}` pairs. Tags within the same category are OR'd together; tags across different categories are AND'd. For example, passing two tags from \"Framework\" and one tag from \"Region\" matches resources that have either of the two frameworks AND the given region. Find category IDs with `vanta customer-trust list-tag-categories` and tag IDs with `vanta customer-trust get-tag-category --tag-category-id `.", + "--matches-tags": + 'JSON-encoded array of `{categoryId, tagId}` pairs. Tags within the same category are OR\'d together; tags across different categories are AND\'d. For example, passing two tags from "Framework" and one tag from "Region" matches resources that have either of the two frameworks AND the given region. Find category IDs with `vanta customer-trust list-tag-categories` and tag IDs with `vanta customer-trust get-tag-category --tag-category-id `.', }, "knowledge-base replace-resource-document-file": { "--id": "Knowledge Base resource ID", @@ -244,7 +267,8 @@ export const optionHelp: Record> = { "--id": "Knowledge Base resource ID", }, "knowledge-base upload-resource-document": { - "--category-id": "Trust Center category id to associate this resource with. Only applied when --customer-visibility is REQUEST_ACCESS or PUBLIC; otherwise ignored. Unknown IDs leave the resource uncategorized.", + "--category-id": + "Trust Center category id to associate this resource with. Only applied when --customer-visibility is REQUEST_ACCESS or PUBLIC; otherwise ignored. Unknown IDs leave the resource uncategorized.", }, "people list": { "--task-status-matches-any": "Task statuses to filter by (repeatable)", @@ -258,12 +282,15 @@ export const optionHelp: Record> = { "--include-removed": "Include removed viewers (true/false; default: true)", }, "vendors list-assessments": { - "--type-id-matches-any": "Filter by assessment type ID from vendor-assessment-types list (repeatable)", + "--type-id-matches-any": + "Filter by assessment type ID from vendor-assessment-types list (repeatable)", }, "vulnerabilities list": { - "--include-vulnerabilities-without-slas": "Include vulnerabilities without SLA dates (true/false)", + "--include-vulnerabilities-without-slas": + "Include vulnerabilities without SLA dates (true/false)", }, "vulnerable-assets list": { - "--asset-external-account-id": "Filter vulnerable assets by external account ID", + "--asset-external-account-id": + "Filter vulnerable assets by external account ID", }, }; diff --git a/scripts/codegen/generate-commands.ts b/scripts/codegen/generate-commands.ts deleted file mode 100644 index dc0f564..0000000 --- a/scripts/codegen/generate-commands.ts +++ /dev/null @@ -1,1005 +0,0 @@ -/// -// Generates the API commands in src/commands/ from api-spec.json. -// -// tsx scripts/codegen/generate-commands.ts [--out ] -// -// AGENTS.md ("Command structure") describes the naming rules; ./config.ts holds the -// exceptions and help text overrides. The script runs in stages: -// -// 1. Spec read each API operation into an `Operation` -// 2. Naming place it in a group and name it (`vendors list-findings`) -// 3. Options turn its parameters and body into flags -// 4. Help text describe the command -// 5. Groups collect commands into groups, check names, apply overrides -// 6. Render write one TypeScript file per group, plus index.ts -import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import * as config from "./config.js"; - -const repoRoot = new URL("../../", import.meta.url).pathname; -const generatedHeader = "// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT."; - -// =========================================================================== -// 1. Spec -// =========================================================================== - -// The parts of the OpenAPI spec the generator reads. -type Method = "get" | "post" | "put" | "patch" | "delete"; - -type Spec = { - paths: Record>>; -}; - -type SpecOperation = { - operationId: string; - summary?: string; - parameters?: SpecParameter[]; - requestBody?: { content?: Record }; - responses?: Record }>; -}; - -type SpecParameter = { - name: string; - in: "path" | "query" | "header" | "cookie"; - required?: boolean; - description?: string; - schema?: Schema; -}; - -type Schema = { - type?: string; - format?: string; - enum?: string[]; - items?: Schema; - description?: string; - properties?: Record; - required?: string[]; -}; - -// One API input: a path or query parameter, or a multipart form field. -type Input = { - name: string; - required: boolean; - description: string; - schema: Schema; -}; - -// A literal path segment and the ID parameter that follows it, if any: -// `/vendors/{vendorId}` is `{ name: "vendors", param: "vendorId" }`. -type Segment = { name: string; param?: string }; - -type Operation = { - id: string; - method: Method; - path: string; - summary: string; - segments: Segment[]; - pathParams: Input[]; - queryParams: Input[]; - body: "none" | "json" | "multipart"; - formFields: Input[]; - returnsFile: boolean; -}; - -// The spec with every `{ "$ref": "#/components/..." }` replaced by the object it -// points to, so the rest of the script never sees references. Shared and -// recursive schemas stay shared. -function resolveRefs(root: unknown): unknown { - // `#/components/schemas/Vendor` → root.components.schemas.Vendor - const lookup = (ref: string) => - ref - .split("/") - .slice(1) - .reduce((node, key) => (node as Record)[key], root); - const resolved = new Map(); - const resolve = (node: unknown): unknown => { - if (node === null || typeof node !== "object") return node; - const ref = (node as { $ref?: unknown }).$ref; - if (typeof ref === "string") return resolve(lookup(ref)); - if (resolved.has(node)) return resolved.get(node); - if (Array.isArray(node)) { - const out: unknown[] = []; - resolved.set(node, out); - for (const value of node) out.push(resolve(value)); - return out; - } - const out: Record = {}; - resolved.set(node, out); - for (const [key, value] of Object.entries(node)) out[key] = resolve(value); - return out; - }; - return resolve(root); -} - -const spec = resolveRefs(JSON.parse(readFileSync(join(repoRoot, "api-spec.json"), "utf8"))) as Spec; - -function toSegments(path: string): Segment[] { - const segments: Segment[] = []; - for (const part of path.split("/").filter(Boolean)) { - const param = /^\{(.+)\}$/.exec(part)?.[1]; - if (param) segments.at(-1)!.param = param; - else segments.push({ name: part }); - } - return segments; -} - -function loadOperations(): Operation[] { - const ops: Operation[] = []; - for (const [path, item] of Object.entries(spec.paths)) { - for (const method of ["get", "post", "put", "patch", "delete"] as const) { - const op = item[method]; - if (!op) continue; - const params = op.parameters ?? []; - const toInput = (param: SpecParameter): Input => ({ - name: param.name, - required: Boolean(param.required), - description: param.description ?? "", - schema: param.schema ?? {}, - }); - const body = op.requestBody; - const form = body?.content?.["multipart/form-data"]; - const formSchema: Schema = form?.schema ?? {}; - const ok = op.responses?.["200"] ?? op.responses?.["201"] ?? {}; - ops.push({ - id: op.operationId, - method, - path, - summary: op.summary ?? "", - segments: toSegments(path), - pathParams: params.filter((param) => param.in === "path").map(toInput), - queryParams: params.filter((param) => param.in === "query").map(toInput), - body: form ? "multipart" : body ? "json" : "none", - formFields: Object.entries(formSchema.properties ?? {}).map(([name, schema]) => ({ - name, - required: (formSchema.required ?? []).includes(name), - description: schema.description ?? "", - schema, - })), - returnsFile: Object.keys(ok.content ?? {}).some((t) => t !== "application/json"), - }); - } - } - return ops; -} - -const operations = loadOperations(); - -// =========================================================================== -// Words -// =========================================================================== - -const kebab = (s: string) => s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); -const camel = (s: string) => s.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()); -const pascal = (s: string) => camel(s).replace(/^./, (c) => c.toUpperCase()); -const lowerFirst = (s: string) => s.replace(/^./, (c) => c.toLowerCase()); -const upperFirst = (s: string) => s.replace(/^./, (c) => c.toUpperCase()); -const humanize = (s: string) => kebab(s).replace(/-/g, " "); -const article = (s: string) => (/^[aeiou]/i.test(s) && !/^(us|uni|eu)/i.test(s) ? "an" : "a"); -const a = (noun: string) => `${article(noun)} ${noun}`; - -function singularize(word: string): string { - if (word === "people") return "person"; - if (word.endsWith("ies")) return `${word.slice(0, -3)}y`; - if (word.endsWith("sses")) return word.slice(0, -2); - if (word.endsWith("s") && !/(ss|us)$/.test(word)) return word.slice(0, -1); - return word; -} - -// Singularizes the last word of a kebab-case segment: `security-reviews`. -function singular(segment: string): string { - const words = segment.split("-"); - words.push(singularize(words.pop()!)); - return words.join("-"); -} - -const isPlural = (segment: string) => singular(segment) !== segment; - -// The schema of one value: an array's items, else the schema itself. -const valueSchema = (schema: Schema): Schema => (schema.type === "array" ? (schema.items ?? {}) : schema); - -// Display names from `config.nouns`, keyed by " " or by -// segment, else from the segment itself. -const nounFor = (segment: string, group?: string) => - (group && config.nouns[`${group} ${segment}`]) || config.nouns[segment]; -const displaySingular = (segment: string, group?: string) => - nounFor(segment, group)?.singular ?? humanize(singular(segment)); -const displayPlural = (segment: string, group?: string) => - nounFor(segment, group)?.plural ?? humanize(segment); - -// =========================================================================== -// 2. Naming -// =========================================================================== - -// Where an operation sits in the CLI and what its path is made of. -type Placement = { - // The path's first segment, which names the group. - groupSegment: Segment; - // Segments after the group, minus a trailing action: they form the noun. - resources: Segment[]; - action?: string; -}; - -function place(op: Operation): Placement { - const [groupSegment, ...rest] = op.segments as [Segment, ...Segment[]]; - const last = rest.at(-1); - const isAction = - last && !last.param && config.actionVerbs.includes(last.name.split("-")[0]!); - return { - groupSegment, - resources: isAction ? rest.slice(0, -1) : rest, - action: isAction ? last.name : undefined, - }; -} - -// `-`; see AGENTS.md "Command structure". -function commandName(op: Operation, placement: Placement): string { - const atItem = op.segments.at(-1)!.param !== undefined; - let verb: string; - let rest: string[] = []; - // Operations on a whole collection keep the last noun plural. - let plural = !atItem; - const link = config.links.includes(op.id); - if (placement.action) { - [verb, ...rest] = placement.action.split("-") as [string, ...string[]]; - plural = false; - } else if (op.method === "get") { - verb = op.returnsFile ? "download" : atItem ? "get" : "list"; - plural = verb === "list"; - } else if (op.method === "post") { - verb = link ? "add" : op.body === "multipart" ? "upload" : "create"; - // `create` and `add` make one item, unless the API adds many at once. - plural = op.id.startsWith("Bulk"); - } else if (op.method === "delete" && link) { - verb = "remove"; - } else { - verb = { patch: "update", put: "set", delete: "delete" }[op.method]; - } - const noun = placement.resources.map((s, i) => - plural && i === placement.resources.length - 1 - ? (config.nouns[s.name]?.commandPlural ?? s.name) - : (config.nouns[s.name]?.commandSingular ?? singular(s.name)), - ); - return [verb, ...noun, ...rest].join("-"); -} - -// =========================================================================== -// 3. Options -// =========================================================================== - -type Option = { - flag: string; - placeholder: string; - description: string; - required: boolean; - repeatable: boolean; - // Parse "true"/"false" into a boolean. - parseBool: boolean; - // Takes one of the spec's enum values, typed by the SDK. - isEnum: boolean; - // Where the value goes in the SDK call, under `key`. - target: "path" | "query" | "form" | "file"; - key: string; -}; - -const topLevelGroups = new Set(operations.map((op) => op.segments[0]!.name)); - -const searchParams = new Set(["q", "search", "searchString"]); -const placeholderWords = new Set([ - "category", - "direction", - "group", - "kind", - "name", - "owner", - "permission", - "reason", - "role", - "scope", - "severity", - "slug", - "source", - "status", - "template", - "title", - "type", - "url", - "visibility", -]); - -// The noun a parameter refers to: `statusMatchesAny` → `status`. -function paramNoun(name: string): string { - const base = name.replace(/(MatchesAny|Filter)$/, "").replace(/Filter$/, ""); - return singularize(kebab(base).split("-").at(-1)!); -} - -// See AGENTS.md "Help text" for the placeholder vocabulary. -function placeholderFor(input: Input): string { - const { name, schema, description } = input; - const item = valueSchema(schema); - if (item.type === "boolean" || /^(is|has)[A-Z]/.test(name)) return "bool"; - if (item.format === "date") return "date"; - if (item.format === "date-time" || (!item.enum && /(Date|After|Before)$/.test(name))) { - return /ISO 8601 date\b/.test(description) ? "date" : "timestamp"; - } - if (searchParams.has(name)) return "query"; - if (name === "orderBy") return "field"; - if (item.enum) return paramNoun(name); - if (/\bJSON\b/.test(description)) return "json"; - if ( - /(Id|Ids|Identifier)$/.test(name.replace(/MatchesAny$/, "")) || - name === "id" || - /\bIDs?\b/.test(description) - ) { - return "id"; - } - if (item.type === "integer" || item.type === "number") return "n"; - const noun = paramNoun(name); - if (topLevelGroups.has(`${noun}s`)) return "id"; - return placeholderWords.has(noun) ? noun : "text"; -} - -// First paragraph of a spec description, without "Possible values" lists. -function cleanDescription(text: string): string { - return text - .replace(/\s*Possible values:.*$/s, "") - .split(/\n\s*\n/)[0]! - .replace(/\s+/g, " ") - .trim() - .replace(/\.$/, ""); -} - -// The spec description, plus enum choices and format hints. -function inputDescription(input: Input, placeholder: string, listed: string): string { - const { name, schema } = input; - const item = valueSchema(schema); - let text = cleanDescription(input.description); - if (!text && placeholder === "query") text = `Search ${listed}`; - if (!text) { - const subject = humanize(name.replace(/MatchesAny$/, "")); - text = `Filter by ${subject.replace(/\bid(s?)$/, "ID$1")}`; - } - text = upperFirst(text) - // "Boolean determining whether X" → "Whether X". - .replace(/^Boolean (determining|indicating) w/, "W") - // `("true" / "false")` → the standard "(true/false)", added below. - .replace(/\s*\("true" ?\/ ?"false"\)/, ""); - if (placeholder === "timestamp") { - // "in ISO 8601" and "(ISO 8601 string)" → "ISO 8601 timestamp". - text = text.replace(/ISO 8601(?: string)?(?! (timestamp|date))/g, "ISO 8601 timestamp"); - } - if (item.enum && !item.enum.every((v) => text.includes(v))) { - text += `: ${item.enum.join(", ")}`; - } - if (placeholder === "bool" && !text.includes("true/false")) text += " (true/false)"; - if (placeholder === "timestamp" && !text.includes("ISO 8601")) { - text += " (ISO 8601 timestamp)"; - } - if (schema.type === "array") text += " (repeatable)"; - return text; -} - -// "FAQ ID" for `faqId` when the noun has a configured display name, else the -// humanized parameter name ("Security review ID"). -function pathParamDescription(name: string, placement: Placement): string { - const segment = placement.resources.find((s) => s.param === name); - const display = - segment && - (nounFor(segment.name, placement.groupSegment.name)?.singular ?? - (name === "id" ? displaySingular(segment.name) : undefined)); - if (display) return `${upperFirst(display)} ID`; - return upperFirst(humanize(name).replace(/ id$/, " ID")); -} - -// Whether the SDK types the value as plain `string` (or `string[]`), rather -// than an enum or union. -function isPlainString(schema: Schema): boolean { - const item = valueSchema(schema); - return item.type !== "string" ? item.type === "boolean" || item.type === "integer" : !item.enum; -} - -function buildOptions(op: Operation, placement: Placement, group: Group): Option[] { - const overrides = config.operations[op.id]?.options ?? {}; - const placeholderOverride = (key: string) => - overrides[key]?.placeholder ?? config.params[key]?.placeholder; - // What a search flag searches: the listed resource, else the group. - const last = placement.resources.at(-1); - const listed = last ? displayPlural(last.name, placement.groupSegment.name) : group.plural; - const withOverrides = (option: Option, ...extra: (config.OptionOverride | undefined)[]) => { - for (const o of [...extra, config.params[option.key], overrides[option.key]]) { - Object.assign(option, Object.fromEntries(Object.entries(o ?? {}).filter(([, v]) => v))); - } - return option; - }; - const options: Option[] = []; - - for (const param of op.pathParams) { - // The group's own ID is `--id`; `resourceKind` stays `--resource-kind`. - const own = param.name === placement.groupSegment.param && /(^id|Id)$/.test(param.name); - const option: Option = { - flag: own ? "id" : kebab(param.name), - placeholder: own || /Id$/.test(param.name) ? "id" : kebab(param.name), - description: own - ? `${upperFirst(group.singular)} ID` - : pathParamDescription(param.name, placement), - required: true, - repeatable: false, - parseBool: false, - isEnum: !isPlainString(param.schema), - target: "path", - key: param.name, - }; - options.push(withOverrides(option, own ? config.groups[group.name]?.id : undefined)); - } - - for (const param of op.queryParams) { - if (param.name === "pageSize" || param.name === "pageCursor") continue; - const placeholder = placeholderOverride(param.name) ?? placeholderFor(param); - options.push( - withOverrides({ - flag: kebab(param.name), - placeholder, - description: inputDescription(param, placeholder, listed), - required: param.required, - repeatable: param.schema.type === "array", - parseBool: placeholder === "bool" && param.schema.type === "boolean", - isEnum: !isPlainString(param.schema), - target: "query", - key: param.name, - }), - ); - } - - for (const field of op.formFields) { - const isFile = field.schema.format === "binary"; - const placeholder = isFile ? "path" : (placeholderOverride(field.name) ?? placeholderFor(field)); - const fileDescription = cleanDescription(field.description); - options.push( - withOverrides({ - flag: kebab(field.name), - placeholder, - description: !isFile - ? inputDescription(field, placeholder, listed) - : fileDescription - ? `Path to file to upload: ${lowerFirst(fileDescription)}` - : field.name === "file" - ? "Path to file to upload" - : `Path to ${humanize(field.name)} file to upload`, - required: field.required, - repeatable: false, - parseBool: false, - isEnum: false, - target: isFile ? "file" : "form", - key: field.name, - }), - ); - } - - return options; -} - -// =========================================================================== -// 4. Help text -// =========================================================================== - -// Groups like `customer-trust` have no endpoints of their own, so they don't -// own the resources under them in descriptions. -const groupsWithEndpoints = new Set( - operations.filter((op) => op.segments.length === 1).map((op) => op.segments[0]!.name), -); - -const prepositions = new Set(["as", "for", "from", "in", "to", "with"]); - -// Describes a command from its verb, its subject (the last resource), and the -// subject's owner (the group and any resources in between). -function commandDescription(name: string, op: Operation, placement: Placement, group: Group): string { - const atItem = op.segments.at(-1)!.param !== undefined; - const verb = upperFirst(name.split("-")[0]!); - const topGroup = placement.groupSegment.name; - const subjectSegment = placement.resources.at(-1); - const parents = placement.resources.slice(0, -1); - const parentSegment = parents.at(-1) ?? placement.groupSegment; - - const ownsResources = groupsWithEndpoints.has(placement.groupSegment.name); - const ownerWords = [ - ...(ownsResources ? [group.singular] : []), - ...parents.map((s) => displaySingular(s.name, topGroup)), - ]; - const owner = ownerWords.join(" "); - const owners = parents.length - ? [...ownerWords.slice(0, -1), displayPlural(parentSegment.name, topGroup)].join(" ") - : group.plural; - const subject = subjectSegment && { - one: - nounFor(subjectSegment.name, topGroup)?.commandSingular ?? - displaySingular(subjectSegment.name, topGroup), - many: displayPlural(subjectSegment.name, topGroup), - }; - // With no owner, the subject stands alone ("List questionnaires"). - const one = subject && owner ? owner : (subject?.one ?? group.singular); - const many = subject && owner ? owners : (subject?.many ?? group.plural); - - if (placement.action) { - // `add-document-to-control` under `controls` → "document". - const own = singular(placement.groupSegment.name); - const suffix = ["to", "from", "for"] - .map((preposition) => `-${preposition}-${own}`) - .find((s) => placement.action!.endsWith(s)); - const action = suffix ? placement.action.slice(0, -suffix.length) : placement.action; - const rest = humanize(action).split(" ").slice(1); - const target = subject && owner ? `${owner} ${subject.one}` : one; - // Actions on the collection, e.g. `people offboard`. - if (!op.segments.some((s) => s.param)) { - return rest.length === 0 ? `${verb} ${many}` : op.summary; - } - if (rest.length === 0) return `${verb} ${a(target)}`; - if (prepositions.has(rest[0]!)) return `${verb} ${a(target)} ${rest.join(" ")}`; - // `add-people` → "Add people to a group". - const object = rest.join(" "); - const linked = isPlural(rest.at(-1)!) || rest.length > 1 ? object : a(object); - if (verb === "Add") return `Add ${linked} to ${a(target)}`; - if (verb === "Remove") return `Remove ${linked} from ${a(target)}`; - return `${verb} ${object} for ${a(target)}`; - } - - if (!subject || !owner) { - switch (verb) { - case "List": - return `List ${many}`; - case "Get": - return atItem ? `Get ${a(one)} by ID` : `Get ${many}`; - case "Create": - case "Upload": - return `${verb} ${a(one)}`; - case "Update": - return atItem ? `Update ${a(one)}` : `Update ${many}`; - case "Delete": - return `Delete ${a(one)}`; - case "Set": - return `Set ${many}`; - } - return op.summary; - } - - // Under a collection rather than an item: `controls list-deactivated`, - // `trust-centers add-control-tags`. - if (parentSegment.param === undefined) { - if (verb === "Add") return `Add ${subject.many} to ${many}`; - if (verb === "Remove") return `Remove ${subject.many} from ${many}`; - if (subjectSegment.name === "order") return `Reorder ${many}`; - if (verb === "List") { - // `deactivated-controls` under `controls` → "List deactivated controls". - const words = humanize(subjectSegment.name) - .split(" ") - .filter((w) => w !== placement.groupSegment.name && w !== singular(placement.groupSegment.name)); - return `List ${words.join(" ")} ${many}`; - } - // A resource of its own under the collection: "Create a questionnaire export". - if (isPlural(subjectSegment.name)) { - return `${verb} ${a(`${one} ${subject.one}`)}${atItem ? " by ID" : ""}`; - } - return `${verb} ${a(`${subject.one} ${one}`)}`; - } - - if (op.returnsFile) return `Download the ${subject.one} for ${a(one)}`; - const resource = a(`${one} ${subject.one}`); - switch (verb) { - case "List": - return `List ${subject.many} for ${a(one)}`; - case "Get": - return `Get ${resource}${atItem ? " by ID" : ""}`; - case "Update": - return !atItem && isPlural(subjectSegment.name) - ? `Update ${subject.many} for ${a(one)}` - : `Update ${resource}`; - case "Create": - case "Delete": - return `${verb} ${resource}`; - case "Add": - return `Add ${a(subject.one)} to ${a(one)}`; - case "Remove": - return `Remove ${a(subject.one)} from ${a(one)}`; - case "Upload": - return `Upload ${a(subject.one)} for ${a(one)}`; - case "Set": - return subjectSegment.name === "order" - ? `Reorder ${displayPlural(parentSegment.name, topGroup)} for ${a(ownerWords.slice(0, -1).join(" "))}` - : `Set ${subject.many} for ${a(one)}`; - } - return op.summary; -} - -// =========================================================================== -// 5. Groups -// =========================================================================== - -type Command = { - name: string; - description: string; - op: Operation; - options: Option[]; - paginated: boolean; - download: boolean; -}; - -// A group is named after its path segment, which also names its file. -type Group = { - name: string; - description: string; - singular: string; - plural: string; - commands: Command[]; -}; - -function newGroup(name: string): Group { - const noun = config.nouns[name]; - const plural = noun?.plural ?? displayPlural(name); - return { - name, - description: `Manage ${plural}`, - singular: noun?.singular ?? displaySingular(name), - plural, - commands: [], - }; -} - -// Every group, keyed by name. -function buildGroups(): Map { - const groups = new Map(); - for (const op of operations) { - const placement = place(op); - const name = placement.groupSegment.name; - if (!groups.has(name)) groups.set(name, newGroup(name)); - const group = groups.get(name)!; - const command = config.operations[op.id]?.name ?? commandName(op, placement); - const queryNames = op.queryParams.map((q) => q.name); - group.commands.push({ - name: command, - description: commandDescription(command, op, placement, group), - op, - options: buildOptions(op, placement, group), - paginated: queryNames.includes("pageSize") && queryNames.includes("pageCursor"), - download: op.returnsFile, - }); - } - return groups; -} - -function checkUniqueNames(groups: Group[]): void { - for (const group of groups) { - const seen = new Map(); - for (const { name, op } of group.commands) { - const path = `${group.name} ${name}`; - if (seen.has(name)) { - throw new Error( - `duplicate command "${path}" for ${seen.get(name)} and ${op.id}; add a name override in scripts/codegen/config.ts`, - ); - } - seen.set(name, op.id); - } - } -} - -// Replaces generated descriptions with `config.commandHelp` and -// `config.optionHelp`, and fails on entries that match nothing or repeat the -// default, so the overrides stay a list of deliberate wording. -function applyHelpOverrides(groups: Group[]): void { - const problems: string[] = []; - const unusedCommands = new Set(Object.keys(config.commandHelp)); - const unusedOptions = new Set( - Object.entries(config.optionHelp).flatMap(([cmd, flags]) => - Object.keys(flags).map((flag) => `${cmd} ${flag}`), - ), - ); - const override = (key: string, generated: string, text: string | undefined, unused: Set) => { - if (text === undefined) return generated; - unused.delete(key); - if (text === generated) problems.push(`help text for "${key}" repeats the default`); - return text; - }; - - for (const group of groups) { - const groupKey = group.name; - group.description = override(groupKey, group.description, config.commandHelp[groupKey], unusedCommands); - for (const command of group.commands) { - const key = `${groupKey} ${command.name}`; - command.description = override(key, command.description, config.commandHelp[key], unusedCommands); - for (const o of command.options) { - const flag = `--${o.flag}`; - o.description = override(`${key} ${flag}`, o.description, config.optionHelp[key]?.[flag], unusedOptions); - } - } - } - for (const key of unusedCommands) problems.push(`help text for unknown command "${key}"`); - for (const key of unusedOptions) problems.push(`help text for unknown option "${key}"`); - if (problems.length) throw new Error(`scripts/codegen/config.ts:\n ${problems.join("\n ")}`); -} - -// =========================================================================== -// 6. Render -// =========================================================================== - -const quote = (s: string) => JSON.stringify(s); -const pad = (n: number) => " ".repeat(n); -const indent = (text: string, n: number) => - text - .split("\n") - .map((line) => (line ? pad(n) + line : line)) - .join("\n"); -const sdkFunction = (op: Operation) => lowerFirst(op.id); -const registerFunction = (g: Group) => `register${pascal(g.name)}Command`; -const groupVariable = (g: Group) => { - const name = camel(g.name); - return ["delete", "export", "import", "new"].includes(name) ? `${name}Group` : name; -}; - -// A call on one line when it fits in `width`, else one argument per line. -function call(callee: string, args: string[], width: number): string { - const inline = `${callee}(${args.join(", ")})`; - if (inline.length <= width && !inline.includes("\n")) return inline; - return `${callee}(\n${args.map((arg) => `${indent(arg, 2)},`).join("\n")}\n)`; -} - -// An object literal on one line when it fits in `width`, else one entry per line. -function object(entries: string[], width: number): string { - const inline = `{ ${entries.join(", ")} }`; - if (inline.length <= width && !inline.includes("\n")) return inline; - return `{\n${entries.map((e) => `${indent(e, 2)},`).join("\n")}\n}`; -} - -// Names each generated file imports, collected while rendering. -type Imports = { - sdk: Set; - types: Set; - helpers: Set; - output: boolean; -}; - -// The SDK call for a command, and statements that must run before it. -function renderSdkCall(command: Command, imports: Imports, width: number) { - const { op, options } = command; - const value = (o: Option) => (o.target === "file" ? camel(o.flag) : `opts.${camel(o.flag)}`); - const entry = (o: Option) => (o.key === value(o) ? o.key : `${o.key}: ${value(o)}`); - const of = (target: Option["target"][]) => options.filter((o) => target.includes(o.target)); - const before: string[] = []; - const args = ["client: api.client"]; - - const path = of(["path"]).map(entry); - if (path.length) args.push(`path: ${object(path, width - 8)}`); - - const query = of(["query"]).map(entry); - if (command.paginated) { - imports.helpers.add("paginationQuery"); - if (query.length === 0) args.push("query: paginationQuery(opts)"); - else query.unshift("...paginationQuery(opts)"); - } - if (query.length) args.push(`query: ${object(query, width - 9)}`); - - if (op.body === "json") { - imports.helpers.add("readJSONPayload"); - imports.types.add(`${op.id}Data`); - before.push( - `const body = (await readJSONPayload(\n opts.json,\n opts.file,\n)) as ${op.id}Data["body"];`, - ); - args.push("body"); - } else if (op.body === "multipart") { - imports.helpers.add("readBinaryFile"); - for (const o of of(["file"])) { - before.push(`const ${camel(o.flag)} = await readBinaryFile(${`opts.${camel(o.flag)}`});`); - } - args.push(`body: ${object(of(["form", "file"]).map(entry), width - 8)}`); - } - - imports.sdk.add(sdkFunction(op)); - return { before, call: `${sdkFunction(op)}(${object(args, width - sdkFunction(op).length - 2)})` }; -} - -function renderCommand(group: Group, command: Command, imports: Imports): string { - // Wrapped in addPaginationOptions/addJsonFileOptions, the chain sits two - // columns deeper and `.action()` attaches to the wrapper. - const wrapped = command.paginated || command.op.body === "json"; - const chainIndent = wrapped ? 6 : 4; - const width = 80 - chainIndent; - - const chain = [ - groupVariable(group), - `.command(${quote(command.name)})`, - `.description(${quote(command.description)})`, - ]; - for (const o of command.options) { - const args = [quote(`--${o.flag} <${o.placeholder}>`), quote(o.description)]; - if (o.repeatable) { - imports.helpers.add("collectString"); - args.push("collectString", "[] as string[]"); - } else if (o.parseBool) { - imports.helpers.add("parseOptionalBoolString"); - args.push(`(value) => parseOptionalBoolString(value, ${quote(o.flag)})`); - } - chain.push(call(`.${o.required ? "requiredOption" : "option"}`, args, width)); - } - if (command.download) { - const help = "Write downloaded bytes to file path (default stdout)"; - chain.push(call(".option", [quote("--output "), quote(help)], width)); - } - // Short chains stay on one line: `policies.command("list").description(...)`. - const inline = chain.join(""); - let head = - chain.length === 3 && chainIndent - 2 + inline.length < 80 - ? inline - : [chain[0], ...chain.slice(1).map((line) => indent(line, 2))].join("\n"); - for (const [use, helper] of [ - [command.paginated, "addPaginationOptions"], - [command.op.body === "json", "addJsonFileOptions"], - ] as const) { - if (!use) continue; - imports.helpers.add(helper); - head = `${helper}(\n${indent(head, 2)},\n)`; - } - - const bodyIndent = wrapped ? 4 : 6; - const sdk = renderSdkCall(command, imports, 80 - bodyIndent - 2); - const statements = [...sdk.before]; - if (command.download) { - imports.helpers.add("withClient"); - imports.helpers.add("writeDownloadedMedia"); - imports.output = true; - statements.push( - [ - "await withClient(getFlags, async (api, flags) => {", - indent(`const result = await ${sdk.call};`, 2), - " if (result.error) throw result.error;", - " const dest = opts.output?.trim();", - " await writeDownloadedMedia(result.data, dest);", - " if (dest) printResponse({ savedTo: dest }, flags);", - "});", - ].join("\n"), - ); - } else { - imports.helpers.add("runSdk"); - statements.push(`await runSdk(getFlags, (api) =>\n${indent(sdk.call, 2)},\n);`); - } - const body = statements.join("\n"); - const action = renderAction(command, body, wrapped ? 2 : 4, imports); - const text = wrapped ? `${head}${action};` : `${head}\n${indent(action, 2)};`; - return indent(text, 2); -} - -// `.action(async (opts: { id: string }) => { ... })`, with the options type -// on its own lines when it doesn't fit. -function renderAction( - command: Command, - body: string, - column: number, - imports: Imports, -): string { - if (!/\bopts\b/.test(body)) return `.action(async () => {\n${indent(body, 2)}\n})`; - const fields: string[] = []; - const add = (o: Option) => { - const optional = o.required || o.repeatable ? "" : "?"; - let type = o.repeatable ? "string[]" : o.parseBool ? "boolean" : "string"; - if (o.isEnum) { - imports.types.add(`${command.op.id}Data`); - type = - o.target === "path" - ? `${command.op.id}Data["path"]["${o.key}"]` - : `NonNullable<${command.op.id}Data["query"]>["${o.key}"]`; - if (o.repeatable) type = `NonNullable<${type}>`; - } - fields.push(`${camel(o.flag)}${optional}: ${type}`); - }; - command.options.filter((o) => o.target === "path").forEach(add); - if (command.paginated) fields.push("pageSize?: number", "pageCursor?: string"); - command.options.filter((o) => o.target !== "path").forEach(add); - if (command.op.body === "json") fields.push("json?: string", "file?: string"); - if (command.download) fields.push("output?: string"); - // Pagination alone doesn't need a type: `addPaginationOptions` provides it. - if (fields.every((f) => f.startsWith("page"))) { - return `.action(async (opts) => {\n${indent(body, 2)}\n})`; - } - - const signature = `async (opts: { ${fields.join("; ")} }) => {`; - if (column + ".action(".length + signature.length <= 80) { - return `.action(${signature}\n${indent(body, 2)}\n})`; - } - const type = fields.map((field) => ` ${field};`).join("\n"); - return `.action(\n async (opts: {\n${type}\n }) => {\n${indent(body, 4)}\n },\n)`; -} - -function renderGroup(group: Group, imports: Imports): string { - const v = groupVariable(group); - const declaration = ` const ${v} = program.command(${quote(group.name)}).description(${quote(group.description)});`; - const parts = [ - declaration.length <= 80 - ? declaration - : ` const ${v} = program\n .command(${quote(group.name)})\n .description(${quote(group.description)});`, - ]; - // list, get, create, update, delete first; the rest in spec order. - const order = ["list", "get", "create", "update", "delete"]; - const rank = (c: Command) => (order.includes(c.name) ? order.indexOf(c.name) : order.length); - const commands = [...group.commands].sort((x, y) => rank(x) - rank(y)); - for (const command of commands) parts.push(renderCommand(group, command, imports)); - return parts.join("\n\n"); -} - -// Names in the order given, on one line when they fit. -function renderImport(kind: string, names: Iterable, from: string): string { - const list = [...names]; - const inline = `${kind} { ${list.join(", ")} } from "${from}";`; - if (inline.length <= 80) return inline; - return `${kind} {\n${list.map((n) => ` ${n},`).join("\n")}\n} from "${from}";`; -} - -function renderFile(group: Group, files: Map): void { - const imports: Imports = { - sdk: new Set(), - types: new Set(), - helpers: new Set(), - output: false, - }; - const body = renderGroup(group, imports); - - const lines = [generatedHeader, `import type { Command } from "commander";`]; - if (imports.sdk.size) lines.push(renderImport("import", imports.sdk, "../generated/sdk.gen.js")); - if (imports.types.size) { - lines.push(renderImport("import type", imports.types, "../generated/types.gen.js")); - } - if (imports.output) lines.push(`import { printResponse } from "../output.js";`); - // Helpers alphabetically, then the GetFlags type. - lines.push(renderImport("import", [...[...imports.helpers].sort(), "type GetFlags"], "./helpers.js")); - - files.set( - `${group.name}.ts`, - `${lines.join("\n")}\n\nexport function ${registerFunction(group)}(\n program: Command,\n getFlags: GetFlags,\n): void {\n${body}\n}\n`, - ); -} - -function renderFiles(groups: Group[]): Map { - const files = new Map(); - const order = (g: Group) => { - const i = config.topLevelOrder.indexOf(g.name); - return i === -1 ? config.topLevelOrder.length : i; - }; - const topLevel = [...groups].sort((x, y) => order(x) - order(y) || x.name.localeCompare(y.name)); - for (const group of topLevel) renderFile(group, files); - files.set( - "index.ts", - [ - generatedHeader, - `import type { Command } from "commander";`, - ...topLevel.map((g) => `import { ${registerFunction(g)} } from "./${g.name}.js";`), - `import type { GetFlags } from "./helpers.js";`, - "", - "export function registerApiCommands(program: Command, getFlags: GetFlags): void {", - ...topLevel.map((g) => ` ${registerFunction(g)}(program, getFlags);`), - "}", - "", - ].join("\n"), - ); - return files; -} - -// Writes the files, removing generated files for groups that no longer exist. -function writeFiles(files: Map, outDir: string): void { - mkdirSync(outDir, { recursive: true }); - for (const name of readdirSync(outDir)) { - const path = join(outDir, name); - if (!files.has(name) && name.endsWith(".ts") && readFileSync(path, "utf8").startsWith(generatedHeader)) { - rmSync(path); - } - } - for (const [name, content] of files) writeFileSync(join(outDir, name), content); - console.error(`wrote ${files.size} files to ${outDir}`); -} - -// =========================================================================== -// Main -// =========================================================================== - -const args = process.argv.slice(2); -const outDir = args.includes("--out") - ? args[args.indexOf("--out") + 1]! - : join(repoRoot, "src/commands"); - -const groups = [...buildGroups().values()]; -checkUniqueNames(groups); -applyHelpOverrides(groups); -writeFiles(renderFiles(groups), outDir); diff --git a/scripts/codegen/groups.ts b/scripts/codegen/groups.ts new file mode 100644 index 0000000..e2b7247 --- /dev/null +++ b/scripts/codegen/groups.ts @@ -0,0 +1,88 @@ +// Collects operations into command groups, one per top-level path segment. +import * as config from "./config.js"; +import { commandDescription } from "./help.js"; +import { commandName, place } from "./naming.js"; +import { buildOptions, type Option } from "./options.js"; +import type { Operation } from "./spec.js"; +import { displayPlural, displaySingular } from "./words.js"; + +export type Command = { + name: string; + description: string; + op: Operation; + options: Option[]; + paginated: boolean; + download: boolean; +}; + +// A group is named after its path segment, which also names its file. +export type Group = { + name: string; + description: string; + singular: string; + plural: string; + commands: Command[]; +}; + +function newGroup(name: string): Group { + const noun = config.nouns[name]; + const plural = noun?.plural ?? displayPlural(name); + return { + name, + description: `Manage ${plural}`, + singular: noun?.singular ?? displaySingular(name), + plural, + commands: [], + }; +} + +export function buildGroups(operations: Operation[]): Group[] { + const groupNames = new Set(operations.map((op) => op.segments[0]!.name)); + // Groups like `customer-trust` have no endpoints of their own, so they don't + // own the resources under them in descriptions. + const groupsWithEndpoints = new Set( + operations + .filter((op) => op.segments.length === 1) + .map((op) => op.segments[0]!.name), + ); + const groups = new Map(); + for (const op of operations) { + const placement = place(op); + const name = placement.groupSegment.name; + if (!groups.has(name)) groups.set(name, newGroup(name)); + const group = groups.get(name)!; + const command = + config.operations[op.id]?.name ?? commandName(op, placement); + const queryNames = op.queryParams.map((q) => q.name); + group.commands.push({ + name: command, + description: commandDescription( + command, + op, + placement, + group, + groupsWithEndpoints.has(name), + ), + op, + options: buildOptions(op, placement, group, groupNames), + paginated: + queryNames.includes("pageSize") && queryNames.includes("pageCursor"), + download: op.returnsFile, + }); + } + return [...groups.values()]; +} + +export function checkUniqueNames(groups: Group[]): void { + for (const group of groups) { + const seen = new Map(); + for (const { name, op } of group.commands) { + if (seen.has(name)) { + throw new Error( + `duplicate command "${group.name} ${name}" for ${seen.get(name)} and ${op.id}; add a name override in scripts/codegen/config.ts`, + ); + } + seen.set(name, op.id); + } + } +} diff --git a/scripts/codegen/help.ts b/scripts/codegen/help.ts new file mode 100644 index 0000000..650292e --- /dev/null +++ b/scripts/codegen/help.ts @@ -0,0 +1,267 @@ +// Command descriptions: generated from the verb and the resources in the path +// ("List findings for a vendor"), then replaced by `config.commandHelp` and +// `config.optionHelp` where the default reads poorly. +import * as config from "./config.js"; +import type { Group } from "./groups.js"; +import type { Placement } from "./naming.js"; +import type { Operation, Segment } from "./spec.js"; +import { + a, + displayPlural, + displaySingular, + humanize, + isPlural, + nounFor, + singular, + upperFirst, +} from "./words.js"; + +const prepositions = new Set(["as", "for", "from", "in", "to", "with"]); + +// The words a description is built from. For `vendors get-finding` +// (`/vendors/{vendorId}/findings/{findingId}`): the subject is "finding" / +// "findings", and its owner is "vendor" / "vendors". +type Words = { + op: Operation; + placement: Placement; + verb: string; + // The path ends in an ID: `/vendors/{vendorId}`. + atItem: boolean; + topGroup: string; + // The last resource in the path, if any. + subject?: { segment: Segment; one: string; many: string }; + // The group (when it owns its resources) and the resources between it and + // the subject. + ownerWords: string[]; + owner: string; + owners: string; + // The resource just above the subject, else the group. + parent: Segment; + // The subject's owner if it has one, else the subject, else the group. + one: string; + many: string; +}; + +function words( + name: string, + op: Operation, + placement: Placement, + group: Group, + ownsResources: boolean, +): Words { + const topGroup = placement.groupSegment.name; + const subjectSegment = placement.resources.at(-1); + const parents = placement.resources.slice(0, -1); + const parent = parents.at(-1) ?? placement.groupSegment; + const ownerWords = [ + ...(ownsResources ? [group.singular] : []), + ...parents.map((s) => displaySingular(s.name, topGroup)), + ]; + const owner = ownerWords.join(" "); + const owners = parents.length + ? [...ownerWords.slice(0, -1), displayPlural(parent.name, topGroup)].join( + " ", + ) + : group.plural; + const subject = subjectSegment && { + segment: subjectSegment, + one: + nounFor(subjectSegment.name, topGroup)?.commandSingular ?? + displaySingular(subjectSegment.name, topGroup), + many: displayPlural(subjectSegment.name, topGroup), + }; + return { + op, + placement, + verb: upperFirst(name.split("-")[0]!), + atItem: op.segments.at(-1)!.param !== undefined, + topGroup, + subject, + ownerWords, + owner, + owners, + parent, + one: subject && owner ? owner : (subject?.one ?? group.singular), + many: subject && owner ? owners : (subject?.many ?? group.plural), + }; +} + +export function commandDescription( + name: string, + op: Operation, + placement: Placement, + group: Group, + ownsResources: boolean, +): string { + const w = words(name, op, placement, group, ownsResources); + if (placement.action) return describeAction(w, placement.action); + // With no owner, the subject stands alone ("List questionnaires"). + if (!w.subject || !w.owner) return describeStandalone(w); + if (w.parent.param === undefined) + return describeUnderCollection(w, w.subject); + return describeUnderItem(w, w.subject); +} + +// Commands named by a path action: `vendors set-status`, `groups add-people`. +function describeAction(w: Words, action: string): string { + const { verb, subject, owner, one, many, op } = w; + // `add-document-to-control` under `controls` → "document". + const own = singular(w.placement.groupSegment.name); + const suffix = ["to", "from", "for"] + .map((preposition) => `-${preposition}-${own}`) + .find((s) => action.endsWith(s)); + const rest = humanize(suffix ? action.slice(0, -suffix.length) : action) + .split(" ") + .slice(1); + const target = subject && owner ? `${owner} ${subject.one}` : one; + // Actions on the collection, e.g. `people offboard`. + if (!op.segments.some((s) => s.param)) { + return rest.length === 0 ? `${verb} ${many}` : op.summary; + } + if (rest.length === 0) return `${verb} ${a(target)}`; + if (prepositions.has(rest[0]!)) + return `${verb} ${a(target)} ${rest.join(" ")}`; + // `add-people` → "Add people to a group". + const object = rest.join(" "); + const linked = isPlural(rest.at(-1)!) || rest.length > 1 ? object : a(object); + if (verb === "Add") return `Add ${linked} to ${a(target)}`; + if (verb === "Remove") return `Remove ${linked} from ${a(target)}`; + return `${verb} ${object} for ${a(target)}`; +} + +// The group itself, or a resource with no owner: "Get a vendor by ID". +function describeStandalone({ verb, atItem, one, many, op }: Words): string { + switch (verb) { + case "List": + return `List ${many}`; + case "Get": + return atItem ? `Get ${a(one)} by ID` : `Get ${many}`; + case "Create": + case "Upload": + return `${verb} ${a(one)}`; + case "Update": + return atItem ? `Update ${a(one)}` : `Update ${many}`; + case "Delete": + return `Delete ${a(one)}`; + case "Set": + return `Set ${many}`; + } + return op.summary; +} + +// Under a collection rather than an item: `controls list-deactivated`, +// `trust-centers add-control-tags`. +function describeUnderCollection( + w: Words, + subject: NonNullable, +): string { + const { verb, atItem, one, many, placement } = w; + if (verb === "Add") return `Add ${subject.many} to ${many}`; + if (verb === "Remove") return `Remove ${subject.many} from ${many}`; + if (subject.segment.name === "order") return `Reorder ${many}`; + if (verb === "List") { + // `deactivated-controls` under `controls` → "List deactivated controls". + const group = placement.groupSegment.name; + const words = humanize(subject.segment.name) + .split(" ") + .filter((word) => word !== group && word !== singular(group)); + return `List ${words.join(" ")} ${many}`; + } + // A resource of its own under the collection: "Create a questionnaire export". + if (isPlural(subject.segment.name)) { + return `${verb} ${a(`${one} ${subject.one}`)}${atItem ? " by ID" : ""}`; + } + return `${verb} ${a(`${subject.one} ${one}`)}`; +} + +// Under an item: "List findings for a vendor". +function describeUnderItem( + w: Words, + subject: NonNullable, +): string { + const { verb, atItem, one, op } = w; + if (op.returnsFile) return `Download the ${subject.one} for ${a(one)}`; + const resource = a(`${one} ${subject.one}`); + switch (verb) { + case "List": + return `List ${subject.many} for ${a(one)}`; + case "Get": + return `Get ${resource}${atItem ? " by ID" : ""}`; + case "Update": + return !atItem && isPlural(subject.segment.name) + ? `Update ${subject.many} for ${a(one)}` + : `Update ${resource}`; + case "Create": + case "Delete": + return `${verb} ${resource}`; + case "Add": + return `Add ${a(subject.one)} to ${a(one)}`; + case "Remove": + return `Remove ${a(subject.one)} from ${a(one)}`; + case "Upload": + return `Upload ${a(subject.one)} for ${a(one)}`; + case "Set": + return subject.segment.name === "order" + ? `Reorder ${displayPlural(w.parent.name, w.topGroup)} for ${a(w.ownerWords.slice(0, -1).join(" "))}` + : `Set ${subject.many} for ${a(one)}`; + } + return op.summary; +} + +// Replaces generated descriptions with `config.commandHelp` and +// `config.optionHelp`, and fails on entries that match nothing or repeat the +// default, so the overrides stay a list of deliberate wording. +export function applyHelpOverrides(groups: Group[]): void { + const problems: string[] = []; + const unusedCommands = new Set(Object.keys(config.commandHelp)); + const unusedOptions = new Set( + Object.entries(config.optionHelp).flatMap(([cmd, flags]) => + Object.keys(flags).map((flag) => `${cmd} ${flag}`), + ), + ); + const override = ( + key: string, + generated: string, + text: string | undefined, + unused: Set, + ) => { + if (text === undefined) return generated; + unused.delete(key); + if (text === generated) + problems.push(`help text for "${key}" repeats the default`); + return text; + }; + + for (const group of groups) { + group.description = override( + group.name, + group.description, + config.commandHelp[group.name], + unusedCommands, + ); + for (const command of group.commands) { + const key = `${group.name} ${command.name}`; + command.description = override( + key, + command.description, + config.commandHelp[key], + unusedCommands, + ); + for (const o of command.options) { + const flag = `--${o.flag}`; + o.description = override( + `${key} ${flag}`, + o.description, + config.optionHelp[key]?.[flag], + unusedOptions, + ); + } + } + } + for (const key of unusedCommands) + problems.push(`help text for unknown command "${key}"`); + for (const key of unusedOptions) + problems.push(`help text for unknown option "${key}"`); + if (problems.length) + throw new Error(`scripts/codegen/config.ts:\n ${problems.join("\n ")}`); +} diff --git a/scripts/codegen/indefinite.d.ts b/scripts/codegen/indefinite.d.ts new file mode 100644 index 0000000..e6353ce --- /dev/null +++ b/scripts/codegen/indefinite.d.ts @@ -0,0 +1,3 @@ +declare module "indefinite" { + export default function indefinite(word: string): string; +} diff --git a/scripts/codegen/main.ts b/scripts/codegen/main.ts new file mode 100644 index 0000000..55c1673 --- /dev/null +++ b/scripts/codegen/main.ts @@ -0,0 +1,64 @@ +/// +// Generates the API commands in src/commands/ from api-spec.json. +// +// pnpm generate:commands [--out ] +// +// AGENTS.md ("Command structure") describes the naming rules; config.ts holds +// the exceptions and help text overrides. Each stage is its own module: +// +// spec.ts read each API operation into an `Operation` +// naming.ts place it in a group and name it (`vendors list-findings`) +// options.ts turn its parameters and body into flags +// help.ts describe the command, then apply help overrides +// groups.ts collect commands into groups +// render.ts write one TypeScript file per group, plus index.ts +// +// words.ts has the shared word helpers. The rendered files are formatted with +// oxfmt, using the repo's .oxfmtrc.json. +import { + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { parseArgs } from "node:util"; +import { buildGroups, checkUniqueNames } from "./groups.js"; +import { applyHelpOverrides } from "./help.js"; +import { generatedHeader, renderFiles } from "./render.js"; +import { loadOperations } from "./spec.js"; + +const repoRoot = new URL("../../", import.meta.url).pathname; +const commandsDir = join(repoRoot, "src/commands"); +const { values: args } = parseArgs({ options: { out: { type: "string" } } }); +const outDir = args.out ?? commandsDir; + +const groups = buildGroups(loadOperations(join(repoRoot, "api-spec.json"))); +checkUniqueNames(groups); +applyHelpOverrides(groups); + +const files = renderFiles(groups); + +// Write the files, removing generated files for groups that no longer exist. +mkdirSync(outDir, { recursive: true }); +for (const name of readdirSync(outDir)) { + const path = join(outDir, name); + if ( + !files.has(name) && + name.endsWith(".ts") && + readFileSync(path, "utf8").startsWith(generatedHeader) + ) { + rmSync(path); + } +} +const paths = [...files.keys()].map((name) => join(outDir, name)); +for (const [name, content] of files) writeFileSync(join(outDir, name), content); +// Pass the config explicitly so `--out` elsewhere gets the same output. +execFileSync( + join(repoRoot, "node_modules/.bin/oxfmt"), + ["-c", join(repoRoot, ".oxfmtrc.json"), ...paths], + { stdio: ["ignore", "ignore", "inherit"] }, +); +console.error(`wrote ${files.size} files to ${outDir}`); diff --git a/scripts/codegen/naming.ts b/scripts/codegen/naming.ts new file mode 100644 index 0000000..50f2400 --- /dev/null +++ b/scripts/codegen/naming.ts @@ -0,0 +1,59 @@ +// Places an operation in a group and names its command (`vendors +// list-findings`); see AGENTS.md "Command structure". +import * as config from "./config.js"; +import type { Operation, Segment } from "./spec.js"; +import { singular } from "./words.js"; + +// Where an operation sits in the CLI and what its path is made of. +export type Placement = { + // The path's first segment, which names the group. + groupSegment: Segment; + // Segments after the group, minus a trailing action: they form the noun. + resources: Segment[]; + action?: string; +}; + +export function place(op: Operation): Placement { + const [groupSegment, ...rest] = op.segments as [Segment, ...Segment[]]; + const last = rest.at(-1); + const isAction = + last && + !last.param && + config.actionVerbs.includes(last.name.split("-")[0]!); + return { + groupSegment, + resources: isAction ? rest.slice(0, -1) : rest, + action: isAction ? last.name : undefined, + }; +} + +// `-`. +export function commandName(op: Operation, placement: Placement): string { + const atItem = op.segments.at(-1)!.param !== undefined; + let verb: string; + let rest: string[] = []; + // Operations on a whole collection keep the last noun plural. + let plural = !atItem; + const link = config.links.includes(op.id); + if (placement.action) { + [verb, ...rest] = placement.action.split("-") as [string, ...string[]]; + plural = false; + } else if (op.method === "get") { + verb = op.returnsFile ? "download" : atItem ? "get" : "list"; + plural = verb === "list"; + } else if (op.method === "post") { + verb = link ? "add" : op.body === "multipart" ? "upload" : "create"; + // `create` and `add` make one item, unless the API adds many at once. + plural = op.id.startsWith("Bulk"); + } else if (op.method === "delete" && link) { + verb = "remove"; + } else { + verb = { patch: "update", put: "set", delete: "delete" }[op.method]; + } + const noun = placement.resources.map((s, i) => + plural && i === placement.resources.length - 1 + ? (config.nouns[s.name]?.commandPlural ?? s.name) + : (config.nouns[s.name]?.commandSingular ?? singular(s.name)), + ); + return [verb, ...noun, ...rest].join("-"); +} diff --git a/scripts/codegen/options.ts b/scripts/codegen/options.ts new file mode 100644 index 0000000..d522c2e --- /dev/null +++ b/scripts/codegen/options.ts @@ -0,0 +1,275 @@ +// Turns an operation's path parameters, query parameters, and multipart +// fields into flags; see AGENTS.md "Help text" for the placeholder vocabulary. +import * as config from "./config.js"; +import type { Group } from "./groups.js"; +import type { Placement } from "./naming.js"; +import { + type Input, + type Operation, + type Schema, + valueSchema, +} from "./spec.js"; +import { + displayPlural, + displaySingular, + humanize, + kebab, + lowerFirst, + nounFor, + singular, + upperFirst, +} from "./words.js"; + +export type Option = { + flag: string; + placeholder: string; + description: string; + required: boolean; + repeatable: boolean; + // Parse "true"/"false" into a boolean. + parseBool: boolean; + // The SDK types the value more narrowly than `string`, e.g. as an enum. + sdkTyped: boolean; + // Where the value goes in the SDK call, under `key`. + target: "path" | "query" | "form" | "file"; + key: string; +}; + +const searchParams = new Set(["q", "search", "searchString"]); +const placeholderWords = new Set([ + "category", + "direction", + "group", + "kind", + "name", + "owner", + "permission", + "reason", + "role", + "scope", + "severity", + "slug", + "source", + "status", + "template", + "title", + "type", + "url", + "visibility", +]); + +// The noun a parameter refers to: `statusMatchesAny` → `status`. +function paramNoun(name: string): string { + const base = name.replace(/MatchesAny$/, "").replace(/Filter$/, ""); + return singular(kebab(base).split("-").at(-1)!); +} + +function placeholderFor(input: Input, groupNames: Set): string { + const { name, schema, description } = input; + const item = valueSchema(schema); + if (item.type === "boolean" || /^(is|has)[A-Z]/.test(name)) return "bool"; + if (item.format === "date") return "date"; + if ( + item.format === "date-time" || + (!item.enum && /(Date|After|Before)$/.test(name)) + ) { + return /ISO 8601 date\b/.test(description) ? "date" : "timestamp"; + } + if (searchParams.has(name)) return "query"; + if (name === "orderBy") return "field"; + if (item.enum) return paramNoun(name); + if (/\bJSON\b/.test(description)) return "json"; + if ( + /(Id|Ids|Identifier)$/.test(name.replace(/MatchesAny$/, "")) || + name === "id" || + /\bIDs?\b/.test(description) + ) { + return "id"; + } + if (item.type === "integer" || item.type === "number") return "n"; + const noun = paramNoun(name); + if (groupNames.has(`${noun}s`)) return "id"; + return placeholderWords.has(noun) ? noun : "text"; +} + +// First paragraph of a spec description, without "Possible values" lists. +function cleanDescription(text: string): string { + return text + .replace(/\s*Possible values:.*$/s, "") + .split(/\n\s*\n/)[0]! + .replace(/\s+/g, " ") + .trim() + .replace(/\.$/, ""); +} + +// The spec description, plus enum choices and format hints. +function inputDescription( + input: Input, + placeholder: string, + listed: string, +): string { + const { name, schema } = input; + const item = valueSchema(schema); + let text = cleanDescription(input.description); + if (!text && placeholder === "query") text = `Search ${listed}`; + if (!text) { + const subject = humanize(name.replace(/MatchesAny$/, "")); + text = `Filter by ${subject.replace(/\bid(s?)$/, "ID$1")}`; + } + text = upperFirst(text) + // "Boolean determining whether X" → "Whether X". + .replace(/^Boolean (determining|indicating) w/, "W") + // `("true" / "false")` → the standard "(true/false)", added below. + .replace(/\s*\("true" ?\/ ?"false"\)/, ""); + if (placeholder === "timestamp") { + // "in ISO 8601" and "(ISO 8601 string)" → "ISO 8601 timestamp". + text = text.replace( + /ISO 8601(?: string)?(?! (timestamp|date))/g, + "ISO 8601 timestamp", + ); + } + if (item.enum && !item.enum.every((v) => text.includes(v))) { + text += `: ${item.enum.join(", ")}`; + } + if (placeholder === "bool" && !text.includes("true/false")) + text += " (true/false)"; + if (placeholder === "timestamp" && !text.includes("ISO 8601")) { + text += " (ISO 8601 timestamp)"; + } + if (schema.type === "array") text += " (repeatable)"; + return text; +} + +function fileDescription(field: Input): string { + const text = cleanDescription(field.description); + if (text) return `Path to file to upload: ${lowerFirst(text)}`; + if (field.name === "file") return "Path to file to upload"; + return `Path to ${humanize(field.name)} file to upload`; +} + +// "FAQ ID" for `faqId` when the noun has a configured display name, else the +// humanized parameter name ("Security review ID"). +function pathParamDescription(name: string, placement: Placement): string { + const segment = placement.resources.find((s) => s.param === name); + const display = + segment && + (nounFor(segment.name, placement.groupSegment.name)?.singular ?? + (name === "id" ? displaySingular(segment.name) : undefined)); + if (display) return `${upperFirst(display)} ID`; + return upperFirst(humanize(name).replace(/ id$/, " ID")); +} + +// Whether the SDK types the value as something other than a plain `string` +// (or `string[]`): an enum, or a non-string type other than boolean/integer. +function isSdkTyped(schema: Schema): boolean { + const item = valueSchema(schema); + if (item.type === "string") return Boolean(item.enum); + return item.type !== "boolean" && item.type !== "integer"; +} + +// Overrides from `config.params` and `config.operations`, ignoring empty +// values; `extra` applies first. +function withOverrides( + option: Option, + opId: string, + extra?: config.OptionOverride, +): Option { + const overrides = [ + extra, + config.params[option.key], + config.operations[opId]?.options?.[option.key], + ]; + for (const o of overrides) { + Object.assign( + option, + Object.fromEntries(Object.entries(o ?? {}).filter(([, v]) => v)), + ); + } + return option; +} + +export function buildOptions( + op: Operation, + placement: Placement, + group: Group, + groupNames: Set, +): Option[] { + const placeholderOverride = (key: string) => + config.operations[op.id]?.options?.[key]?.placeholder ?? + config.params[key]?.placeholder; + const placeholder = (input: Input) => + placeholderOverride(input.name) ?? placeholderFor(input, groupNames); + // What a search flag searches: the listed resource, else the group. + const last = placement.resources.at(-1); + const listed = last + ? displayPlural(last.name, placement.groupSegment.name) + : group.plural; + + const path = op.pathParams.map((param) => { + // The group's own ID is `--id`; `resourceKind` stays `--resource-kind`. + const own = + param.name === placement.groupSegment.param && + /(^id|Id)$/.test(param.name); + const option: Option = { + flag: own ? "id" : kebab(param.name), + placeholder: own || /Id$/.test(param.name) ? "id" : kebab(param.name), + description: own + ? `${upperFirst(group.singular)} ID` + : pathParamDescription(param.name, placement), + required: true, + repeatable: false, + parseBool: false, + sdkTyped: isSdkTyped(param.schema), + target: "path", + key: param.name, + }; + return withOverrides( + option, + op.id, + own ? config.groups[group.name]?.id : undefined, + ); + }); + + const query = op.queryParams + .filter((param) => param.name !== "pageSize" && param.name !== "pageCursor") + .map((param) => { + const p = placeholder(param); + return withOverrides( + { + flag: kebab(param.name), + placeholder: p, + description: inputDescription(param, p, listed), + required: param.required, + repeatable: param.schema.type === "array", + parseBool: p === "bool" && param.schema.type === "boolean", + sdkTyped: isSdkTyped(param.schema), + target: "query", + key: param.name, + }, + op.id, + ); + }); + + const form = op.formFields.map((field) => { + const isFile = field.schema.format === "binary"; + const p = isFile ? "path" : placeholder(field); + return withOverrides( + { + flag: kebab(field.name), + placeholder: p, + description: isFile + ? fileDescription(field) + : inputDescription(field, p, listed), + required: field.required, + repeatable: false, + parseBool: false, + sdkTyped: false, + target: isFile ? "file" : "form", + key: field.name, + }, + op.id, + ); + }); + + return [...path, ...query, ...form]; +} diff --git a/scripts/codegen/render.ts b/scripts/codegen/render.ts new file mode 100644 index 0000000..4b89917 --- /dev/null +++ b/scripts/codegen/render.ts @@ -0,0 +1,243 @@ +// Renders one TypeScript file per group, plus index.ts. The output is +// unformatted; main.ts formats it with oxfmt. +import * as config from "./config.js"; +import type { Command, Group } from "./groups.js"; +import type { Option } from "./options.js"; +import { camel, lowerFirst, pascal } from "./words.js"; + +export const generatedHeader = + "// Code generated by `pnpm generate:commands`. DO NOT EDIT."; + +const quote = (s: string) => JSON.stringify(s); +const registerFunction = (g: Group) => `register${pascal(g.name)}Command`; +const groupVariable = (g: Group) => { + const name = camel(g.name); + return ["delete", "export", "import", "new"].includes(name) + ? `${name}Group` + : name; +}; + +// Names each generated file imports, collected while rendering. +type Imports = { + sdk: Set; + types: Set; + helpers: Set; + output: boolean; +}; + +// `vendors.command("get").description(...).requiredOption(...)`, wrapped in +// `addPaginationOptions` or `addJsonFileOptions` when it takes those flags. +function renderDefinition( + group: Group, + command: Command, + imports: Imports, +): string { + const chain = [ + `${groupVariable(group)}.command(${quote(command.name)})`, + `.description(${quote(command.description)})`, + ]; + for (const o of command.options) { + const args = [ + quote(`--${o.flag} <${o.placeholder}>`), + quote(o.description), + ]; + if (o.repeatable) { + imports.helpers.add("collectString"); + args.push("collectString", "[] as string[]"); + } else if (o.parseBool) { + imports.helpers.add("parseOptionalBoolString"); + args.push(`(value) => parseOptionalBoolString(value, ${quote(o.flag)})`); + } + chain.push( + `.${o.required ? "requiredOption" : "option"}(${args.join(", ")})`, + ); + } + if (command.download) { + chain.push( + `.option("--output ", "Write downloaded bytes to file path (default stdout)")`, + ); + } + let definition = chain.join(""); + if (command.paginated) { + imports.helpers.add("addPaginationOptions"); + definition = `addPaginationOptions(${definition})`; + } + if (command.op.body === "json") { + imports.helpers.add("addJsonFileOptions"); + definition = `addJsonFileOptions(${definition})`; + } + return definition; +} + +// The SDK call for a command, and statements that must run before it. +function renderSdkCall( + command: Command, + imports: Imports, +): { before: string[]; call: string } { + const { op, options } = command; + const value = (o: Option) => + o.target === "file" ? camel(o.flag) : `opts.${camel(o.flag)}`; + const entry = (o: Option) => + o.key === value(o) ? o.key : `${o.key}: ${value(o)}`; + const entries = (...targets: Option["target"][]) => + options.filter((o) => targets.includes(o.target)).map(entry); + const before: string[] = []; + const args = ["client: api.client"]; + + // Downloads can be any file type; without this the SDK parses text and JSON. + if (command.download) args.push(`parseAs: "blob"`); + + const path = entries("path"); + if (path.length) args.push(`path: { ${path.join(", ")} }`); + + const query = entries("query"); + if (command.paginated) { + imports.helpers.add("paginationQuery"); + query.unshift("...paginationQuery(opts)"); + } + if (query.length === 1 && command.paginated) + args.push("query: paginationQuery(opts)"); + else if (query.length) args.push(`query: { ${query.join(", ")} }`); + + if (op.body === "json") { + imports.helpers.add("readJSONPayload"); + imports.types.add(`${op.id}Data`); + before.push( + `const body = (await readJSONPayload(opts.json, opts.file)) as ${op.id}Data["body"];`, + ); + args.push("body"); + } else if (op.body === "multipart") { + imports.helpers.add("readBinaryFile"); + for (const o of options.filter((o) => o.target === "file")) { + before.push( + `const ${camel(o.flag)} = await readBinaryFile(opts.${camel(o.flag)});`, + ); + } + args.push(`body: { ${entries("form", "file").join(", ")} }`); + } + + const fn = lowerFirst(op.id); + imports.sdk.add(fn); + return { before, call: `${fn}({ ${args.join(", ")} })` }; +} + +// The options type for `.action(async (opts: { ... }) => ...)`, or undefined +// when there are no options or `addPaginationOptions` already types them. +function optionsType(command: Command, imports: Imports): string | undefined { + const { op } = command; + const field = (o: Option) => { + const optional = o.required || o.repeatable ? "" : "?"; + let type = o.repeatable ? "string[]" : o.parseBool ? "boolean" : "string"; + if (o.sdkTyped) { + imports.types.add(`${op.id}Data`); + type = + o.target === "path" + ? `${op.id}Data["path"]["${o.key}"]` + : `NonNullable<${op.id}Data["query"]>["${o.key}"]`; + if (o.repeatable) type = `NonNullable<${type}>`; + } + return `${camel(o.flag)}${optional}: ${type}`; + }; + const fields = [ + ...command.options.filter((o) => o.target === "path").map(field), + ...(command.paginated ? ["pageSize?: number", "pageCursor?: string"] : []), + ...command.options.filter((o) => o.target !== "path").map(field), + ...(op.body === "json" ? ["json?: string", "file?: string"] : []), + ...(command.download ? ["output?: string"] : []), + ]; + if (fields.every((f) => f.startsWith("page"))) return undefined; + return `{ ${fields.join("; ")} }`; +} + +function renderCommand( + group: Group, + command: Command, + imports: Imports, +): string { + const sdk = renderSdkCall(command, imports); + const statements = [...sdk.before]; + if (command.download) { + imports.helpers.add("withClient"); + imports.helpers.add("writeDownloadedMedia"); + imports.output = true; + statements.push(`await withClient(getFlags, async (api, flags) => { + const result = await ${sdk.call}; + if (result.error) throw result.error; + const dest = opts.output?.trim(); + await writeDownloadedMedia(result.data, dest); + if (dest) printResponse({ savedTo: dest }, flags); + });`); + } else { + imports.helpers.add("runSdk"); + statements.push(`await runSdk(getFlags, (api) => ${sdk.call});`); + } + const body = statements.join("\n"); + const type = optionsType(command, imports); + const params = !/\bopts\b/.test(body) ? "" : type ? `opts: ${type}` : "opts"; + const definition = renderDefinition(group, command, imports); + return `${definition}.action(async (${params}) => {\n${body}\n});`; +} + +// list, get, create, update, delete first; the rest in spec order. +const commandOrder = ["list", "get", "create", "update", "delete"]; +const rank = (c: Command) => { + const i = commandOrder.indexOf(c.name); + return i === -1 ? commandOrder.length : i; +}; + +function renderFile(group: Group): string { + const imports: Imports = { + sdk: new Set(), + types: new Set(), + helpers: new Set(), + output: false, + }; + const commands = [...group.commands] + .sort((x, y) => rank(x) - rank(y)) + .map((command) => renderCommand(group, command, imports)); + const list = (names: Iterable) => [...names].join(", "); + const importLines = [ + `import type { Command } from "commander";`, + imports.sdk.size && + `import { ${list(imports.sdk)} } from "../generated/sdk.gen.js";`, + imports.types.size && + `import type { ${list(imports.types)} } from "../generated/types.gen.js";`, + imports.output && `import { printResponse } from "../output.js";`, + `import { ${list([...imports.helpers].sort())}, type GetFlags } from "./helpers.js";`, + ]; + return `${generatedHeader} +${importLines.filter(Boolean).join("\n")} + +export function ${registerFunction(group)}(program: Command, getFlags: GetFlags): void { + const ${groupVariable(group)} = program.command(${quote(group.name)}).description(${quote(group.description)}); + + ${commands.join("\n\n")} +} +`; +} + +function renderIndex(groups: Group[]): string { + return `${generatedHeader} +import type { Command } from "commander"; +${groups.map((g) => `import { ${registerFunction(g)} } from "./${g.name}.js";`).join("\n")} +import type { GetFlags } from "./helpers.js"; + +export function registerApiCommands(program: Command, getFlags: GetFlags): void { + ${groups.map((g) => `${registerFunction(g)}(program, getFlags);`).join("\n")} +} +`; +} + +// File contents keyed by file name, with groups in `config.topLevelOrder`. +export function renderFiles(groups: Group[]): Map { + const order = (g: Group) => { + const i = config.topLevelOrder.indexOf(g.name); + return i === -1 ? config.topLevelOrder.length : i; + }; + const sorted = [...groups].sort( + (x, y) => order(x) - order(y) || x.name.localeCompare(y.name), + ); + const files = new Map(sorted.map((g) => [`${g.name}.ts`, renderFile(g)])); + files.set("index.ts", renderIndex(sorted)); + return files; +} diff --git a/scripts/codegen/spec.ts b/scripts/codegen/spec.ts new file mode 100644 index 0000000..c845958 --- /dev/null +++ b/scripts/codegen/spec.ts @@ -0,0 +1,154 @@ +// Reads api-spec.json into one `Operation` per API endpoint and method. +import { readFileSync } from "node:fs"; + +// The parts of the OpenAPI spec the generator reads. +type Method = "get" | "post" | "put" | "patch" | "delete"; + +type Spec = { + paths: Record>>; +}; + +type SpecOperation = { + operationId: string; + summary?: string; + parameters?: SpecParameter[]; + requestBody?: { content?: Record }; + responses?: Record }>; +}; + +type SpecParameter = { + name: string; + in: "path" | "query" | "header" | "cookie"; + required?: boolean; + description?: string; + schema?: Schema; +}; + +export type Schema = { + type?: string; + format?: string; + enum?: string[]; + items?: Schema; + description?: string; + properties?: Record; + required?: string[]; +}; + +// One API input: a path or query parameter, or a multipart form field. +export type Input = { + name: string; + required: boolean; + description: string; + schema: Schema; +}; + +// A literal path segment and the ID parameter that follows it, if any: +// `/vendors/{vendorId}` is `{ name: "vendors", param: "vendorId" }`. +export type Segment = { name: string; param?: string }; + +export type Operation = { + id: string; + method: Method; + path: string; + summary: string; + segments: Segment[]; + pathParams: Input[]; + queryParams: Input[]; + body: "none" | "json" | "multipart"; + formFields: Input[]; + returnsFile: boolean; +}; + +// The schema of one value: an array's items, else the schema itself. +export const valueSchema = (schema: Schema): Schema => + schema.type === "array" ? (schema.items ?? {}) : schema; + +// The spec with every `{ "$ref": "#/components/..." }` replaced by the object it +// points to, so the rest of the generator never sees references. Shared and +// recursive schemas stay shared. +function resolveRefs(root: unknown): unknown { + // `#/components/schemas/Vendor` → root.components.schemas.Vendor + const lookup = (ref: string) => + ref + .split("/") + .slice(1) + .reduce( + (node, key) => (node as Record)[key], + root, + ); + const resolved = new Map(); + const resolve = (node: unknown): unknown => { + if (node === null || typeof node !== "object") return node; + const ref = (node as { $ref?: unknown }).$ref; + if (typeof ref === "string") return resolve(lookup(ref)); + if (resolved.has(node)) return resolved.get(node); + if (Array.isArray(node)) { + const out: unknown[] = []; + resolved.set(node, out); + for (const value of node) out.push(resolve(value)); + return out; + } + const out: Record = {}; + resolved.set(node, out); + for (const [key, value] of Object.entries(node)) out[key] = resolve(value); + return out; + }; + return resolve(root); +} + +function toSegments(path: string): Segment[] { + const segments: Segment[] = []; + for (const part of path.split("/").filter(Boolean)) { + const param = /^\{(.+)\}$/.exec(part)?.[1]; + if (param) segments.at(-1)!.param = param; + else segments.push({ name: part }); + } + return segments; +} + +const toInput = (param: SpecParameter): Input => ({ + name: param.name, + required: Boolean(param.required), + description: param.description ?? "", + schema: param.schema ?? {}, +}); + +export function loadOperations(specPath: string): Operation[] { + const spec = resolveRefs(JSON.parse(readFileSync(specPath, "utf8"))) as Spec; + const ops: Operation[] = []; + for (const [path, item] of Object.entries(spec.paths)) { + for (const method of ["get", "post", "put", "patch", "delete"] as const) { + const op = item[method]; + if (!op) continue; + const params = op.parameters ?? []; + const body = op.requestBody; + const form = body?.content?.["multipart/form-data"]; + const formSchema: Schema = form?.schema ?? {}; + const ok = op.responses?.["200"] ?? op.responses?.["201"] ?? {}; + ops.push({ + id: op.operationId, + method, + path, + summary: op.summary ?? "", + segments: toSegments(path), + pathParams: params.filter((param) => param.in === "path").map(toInput), + queryParams: params + .filter((param) => param.in === "query") + .map(toInput), + body: form ? "multipart" : body ? "json" : "none", + formFields: Object.entries(formSchema.properties ?? {}).map( + ([name, schema]) => ({ + name, + required: (formSchema.required ?? []).includes(name), + description: schema.description ?? "", + schema, + }), + ), + returnsFile: Object.keys(ok.content ?? {}).some( + (t) => t !== "application/json", + ), + }); + } + } + return ops; +} diff --git a/scripts/codegen/words.ts b/scripts/codegen/words.ts new file mode 100644 index 0000000..d6c2c0a --- /dev/null +++ b/scripts/codegen/words.ts @@ -0,0 +1,34 @@ +// Word helpers: case conversion, singular and plural, articles, and the +// display names used in help text. +import { camelCase, kebabCase, noCase, pascalCase } from "change-case"; +import indefinite from "indefinite"; +import pluralize from "pluralize"; +import * as config from "./config.js"; + +// `/trust-centers/{slugId}/data-collected` is about data, not one datum. +pluralize.addUncountableRule("data"); + +export { camelCase as camel, kebabCase as kebab, pascalCase as pascal }; + +// `faqCategories` and `faq-categories` → "faq categories". +export const humanize = noCase; +export const lowerFirst = (s: string) => + s.replace(/^./, (c) => c.toLowerCase()); +export const upperFirst = (s: string) => + s.replace(/^./, (c) => c.toUpperCase()); + +// "a vendor", "an integration". +export const a = (noun: string) => indefinite(noun); + +// Singularizes the last word of a kebab-case segment: `security-reviews`. +export const singular = (segment: string) => pluralize.singular(segment); +export const isPlural = (segment: string) => singular(segment) !== segment; + +// Display names from `config.nouns`, keyed by " " or by +// segment, else from the segment itself. +export const nounFor = (segment: string, group?: string) => + (group && config.nouns[`${group} ${segment}`]) || config.nouns[segment]; +export const displaySingular = (segment: string, group?: string) => + nounFor(segment, group)?.singular ?? humanize(singular(segment)); +export const displayPlural = (segment: string, group?: string) => + nounFor(segment, group)?.plural ?? humanize(segment); diff --git a/src/commands/business-units.ts b/src/commands/business-units.ts index 76d67c9..5f40b89 100644 --- a/src/commands/business-units.ts +++ b/src/commands/business-units.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listBusinessUnits, getBusinessUnit } from "../generated/sdk.gen.js"; import { diff --git a/src/commands/contracts.ts b/src/commands/contracts.ts index 0fcb077..f893006 100644 --- a/src/commands/contracts.ts +++ b/src/commands/contracts.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listContracts, diff --git a/src/commands/controls.ts b/src/commands/controls.ts index 26e20a6..a01d27a 100644 --- a/src/commands/controls.ts +++ b/src/commands/controls.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listControls, diff --git a/src/commands/customer-trust.ts b/src/commands/customer-trust.ts index 903d926..920d213 100644 --- a/src/commands/customer-trust.ts +++ b/src/commands/customer-trust.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listCustomerTrustAccounts, diff --git a/src/commands/discovered-vendors.ts b/src/commands/discovered-vendors.ts index 74e2e0e..8a149a1 100644 --- a/src/commands/discovered-vendors.ts +++ b/src/commands/discovered-vendors.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listDiscoveredVendors, diff --git a/src/commands/documents.ts b/src/commands/documents.ts index 10efa48..fdd41a7 100644 --- a/src/commands/documents.ts +++ b/src/commands/documents.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listDocuments, diff --git a/src/commands/event-logs.ts b/src/commands/event-logs.ts index bce49d6..29b7a98 100644 --- a/src/commands/event-logs.ts +++ b/src/commands/event-logs.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listEventLogs } from "../generated/sdk.gen.js"; import { diff --git a/src/commands/frameworks.ts b/src/commands/frameworks.ts index b3917fc..6d9feb3 100644 --- a/src/commands/frameworks.ts +++ b/src/commands/frameworks.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listFrameworks, diff --git a/src/commands/groups.ts b/src/commands/groups.ts index 6fe283f..2cfefbd 100644 --- a/src/commands/groups.ts +++ b/src/commands/groups.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listPersonGroups, diff --git a/src/commands/index.ts b/src/commands/index.ts index 68d144d..47eedbd 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { registerControlsCommand } from "./controls.js"; import { registerPoliciesCommand } from "./policies.js"; diff --git a/src/commands/integrations.ts b/src/commands/integrations.ts index 11c4c83..4c092ba 100644 --- a/src/commands/integrations.ts +++ b/src/commands/integrations.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listConnectedIntegrations, diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 09afd4e..731b707 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { list, getIssue } from "../generated/sdk.gen.js"; import type { ListData } from "../generated/types.gen.js"; diff --git a/src/commands/knowledge-base.ts b/src/commands/knowledge-base.ts index cd564ed..8bde5d3 100644 --- a/src/commands/knowledge-base.ts +++ b/src/commands/knowledge-base.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listAnswerLibraryEntries, diff --git a/src/commands/monitored-computers.ts b/src/commands/monitored-computers.ts index b80ef61..15bdb41 100644 --- a/src/commands/monitored-computers.ts +++ b/src/commands/monitored-computers.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listMonitoredComputers, diff --git a/src/commands/people.ts b/src/commands/people.ts index 99ce837..2b2b918 100644 --- a/src/commands/people.ts +++ b/src/commands/people.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listPeople, diff --git a/src/commands/personnel-notification-settings.ts b/src/commands/personnel-notification-settings.ts index 34b446c..f6cae5f 100644 --- a/src/commands/personnel-notification-settings.ts +++ b/src/commands/personnel-notification-settings.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { getSettings, updateSettings } from "../generated/sdk.gen.js"; import type { UpdateSettingsData } from "../generated/types.gen.js"; diff --git a/src/commands/policies.ts b/src/commands/policies.ts index 48a55da..c777a67 100644 --- a/src/commands/policies.ts +++ b/src/commands/policies.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listPolicies, getPolicy } from "../generated/sdk.gen.js"; import { diff --git a/src/commands/program-scopes.ts b/src/commands/program-scopes.ts index d199490..70fed93 100644 --- a/src/commands/program-scopes.ts +++ b/src/commands/program-scopes.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listProgramScopes, getProgramScope } from "../generated/sdk.gen.js"; import { diff --git a/src/commands/risk-scenarios.ts b/src/commands/risk-scenarios.ts index 9107533..5135594 100644 --- a/src/commands/risk-scenarios.ts +++ b/src/commands/risk-scenarios.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listRiskScenario, diff --git a/src/commands/tests.ts b/src/commands/tests.ts index b684374..72a6f7d 100644 --- a/src/commands/tests.ts +++ b/src/commands/tests.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listTests, diff --git a/src/commands/trust-centers.ts b/src/commands/trust-centers.ts index 692dffe..c97c60b 100644 --- a/src/commands/trust-centers.ts +++ b/src/commands/trust-centers.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { getTrustCenter, @@ -1144,6 +1144,7 @@ export function registerTrustCentersCommand( await withClient(getFlags, async (api, flags) => { const result = await getTrustCenterResourceMedia({ client: api.client, + parseAs: "blob", path: { slugId: opts.slugId, resourceId: opts.resourceId }, }); if (result.error) throw result.error; diff --git a/src/commands/users.ts b/src/commands/users.ts index bb8fd01..2b020de 100644 --- a/src/commands/users.ts +++ b/src/commands/users.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listUsers, getUser } from "../generated/sdk.gen.js"; import { diff --git a/src/commands/vendor-assessment-types.ts b/src/commands/vendor-assessment-types.ts index 9802338..5c48eae 100644 --- a/src/commands/vendor-assessment-types.ts +++ b/src/commands/vendor-assessment-types.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listVendorAssessmentTypes, diff --git a/src/commands/vendor-risk-attributes.ts b/src/commands/vendor-risk-attributes.ts index 0003da5..3c758fc 100644 --- a/src/commands/vendor-risk-attributes.ts +++ b/src/commands/vendor-risk-attributes.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listVendorRiskAttributes } from "../generated/sdk.gen.js"; import { diff --git a/src/commands/vendors.ts b/src/commands/vendors.ts index d6ba14f..eb14006 100644 --- a/src/commands/vendors.ts +++ b/src/commands/vendors.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listVendors, diff --git a/src/commands/vulnerabilities.ts b/src/commands/vulnerabilities.ts index 38e6690..0a370c6 100644 --- a/src/commands/vulnerabilities.ts +++ b/src/commands/vulnerabilities.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listVulnerabilities, diff --git a/src/commands/vulnerability-remediations.ts b/src/commands/vulnerability-remediations.ts index e61707a..11fff4a 100644 --- a/src/commands/vulnerability-remediations.ts +++ b/src/commands/vulnerability-remediations.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listVulnerabilityRemediations, diff --git a/src/commands/vulnerable-assets.ts b/src/commands/vulnerable-assets.ts index 73dbd49..5685164 100644 --- a/src/commands/vulnerable-assets.ts +++ b/src/commands/vulnerable-assets.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT. +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listVulnerableAssets, From 5e0328331a7d5b22f31ca046e0f82467b500d309 Mon Sep 17 00:00:00 2001 From: Dennis Chen Date: Mon, 26 Oct 2026 22:03:43 +0000 Subject: [PATCH 6/9] Organize the generator into five files Each file follows one part of the rules: - spec.ts: what the API has - commands.ts: which commands exist and what they're called (AGENTS.md "Command structure") - help.ts: what each command's flags and help say (AGENTS.md "Help text") - config.ts: the exceptions to those rules - generate.ts: builds the groups, renders the files, formats them with oxfmt Drops the `indefinite` dependency for the one-line a/an rule it replaced, which gives the same output for every word in the spec. Generated commands are unchanged. Co-Authored-By: Claude Opus 5.5 --- package.json | 3 +- pnpm-lock.yaml | 9 - scripts/codegen/commands.ts | 145 +++++++++++ scripts/codegen/config.ts | 2 +- scripts/codegen/{render.ts => generate.ts} | 132 +++++++++- scripts/codegen/groups.ts | 88 ------- scripts/codegen/help.ts | 287 ++++++++++++++++++++- scripts/codegen/indefinite.d.ts | 3 - scripts/codegen/main.ts | 64 ----- scripts/codegen/naming.ts | 59 ----- scripts/codegen/options.ts | 275 -------------------- scripts/codegen/words.ts | 34 --- 12 files changed, 551 insertions(+), 550 deletions(-) create mode 100644 scripts/codegen/commands.ts rename scripts/codegen/{render.ts => generate.ts} (66%) delete mode 100644 scripts/codegen/groups.ts delete mode 100644 scripts/codegen/indefinite.d.ts delete mode 100644 scripts/codegen/main.ts delete mode 100644 scripts/codegen/naming.ts delete mode 100644 scripts/codegen/options.ts delete mode 100644 scripts/codegen/words.ts diff --git a/package.json b/package.json index 83b6fc1..b401ba7 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "typecheck": "tsc --noEmit", "test": "node --import tsx --test 'src/**/*.test.ts'", "build:binaries": "tsx scripts/build-binaries.ts", - "generate:commands": "tsx scripts/codegen/main.ts", + "generate:commands": "tsx scripts/codegen/generate.ts", "format": "oxfmt", "format:check": "oxfmt --check" }, @@ -32,7 +32,6 @@ "@types/pluralize": "0.0.33", "bun": "^1.3.14", "change-case": "5.4.4", - "indefinite": "2.5.1", "oxfmt": "0.72.0", "pluralize": "8.0.0", "tsup": "^8.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28447e5..7f453f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,9 +191,6 @@ importers: change-case: specifier: 5.4.4 version: 5.4.4 - indefinite: - specifier: 2.5.1 - version: 2.5.1 oxfmt: specifier: 0.72.0 version: 0.72.0 @@ -1152,10 +1149,6 @@ packages: engines: {node: '>=0.4.7'} hasBin: true - indefinite@2.5.1: - resolution: {integrity: sha512-Ul0hCdnSjuFDEloYWeozTaEfljbz+0q+u4HsHns2dOk2DlhGlbRMGFtNcIL+Ve7sZYeIOTOAKA0usAXBGHpNDg==} - engines: {node: '>=6.0.0'} - is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2036,8 +2029,6 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 - indefinite@2.5.1: {} - is-docker@3.0.0: {} is-inside-container@1.0.0: diff --git a/scripts/codegen/commands.ts b/scripts/codegen/commands.ts new file mode 100644 index 0000000..11c4bb9 --- /dev/null +++ b/scripts/codegen/commands.ts @@ -0,0 +1,145 @@ +// Which commands exist and what they're called: each operation is placed in +// a group and named `-` (`vendors list-findings`). See AGENTS.md +// "Command structure". +import { camelCase, kebabCase, noCase, pascalCase } from "change-case"; +import pluralize from "pluralize"; +import * as config from "./config.js"; +import type { Option } from "./help.js"; +import type { Operation, Segment } from "./spec.js"; + +// =========================================================================== +// Words +// =========================================================================== + +// `/trust-centers/{slugId}/data-collected` is about data, not one datum. +pluralize.addUncountableRule("data"); + +export { camelCase as camel, kebabCase as kebab, pascalCase as pascal }; + +// `faqCategories` and `faq-categories` → "faq categories". +export const humanize = noCase; +export const lowerFirst = (s: string) => + s.replace(/^./, (c) => c.toLowerCase()); +export const upperFirst = (s: string) => + s.replace(/^./, (c) => c.toUpperCase()); + +// "a vendor", "an integration", "a user". +export const a = (noun: string) => + `${/^[aeiou]/i.test(noun) && !/^(us|uni|eu)/i.test(noun) ? "an" : "a"} ${noun}`; + +// Singularizes the last word of a kebab-case segment: `security-reviews`. +export const singular = (segment: string) => pluralize.singular(segment); +export const isPlural = (segment: string) => singular(segment) !== segment; + +// Display names from `config.nouns`, keyed by " " or by +// segment, else from the segment itself. +export const nounFor = (segment: string, group?: string) => + (group && config.nouns[`${group} ${segment}`]) || config.nouns[segment]; +export const displaySingular = (segment: string, group?: string) => + nounFor(segment, group)?.singular ?? humanize(singular(segment)); +export const displayPlural = (segment: string, group?: string) => + nounFor(segment, group)?.plural ?? humanize(segment); + +// =========================================================================== +// Groups and commands +// =========================================================================== + +export type Command = { + name: string; + description: string; + op: Operation; + options: Option[]; + paginated: boolean; + download: boolean; +}; + +// A group is named after its path segment, which also names its file. +export type Group = { + name: string; + description: string; + singular: string; + plural: string; + commands: Command[]; +}; + +export function newGroup(name: string): Group { + const noun = config.nouns[name]; + const plural = noun?.plural ?? displayPlural(name); + return { + name, + description: `Manage ${plural}`, + singular: noun?.singular ?? displaySingular(name), + plural, + commands: [], + }; +} + +// Where an operation sits in the CLI and what its path is made of. +export type Placement = { + // The path's first segment, which names the group. + groupSegment: Segment; + // Segments after the group, minus a trailing action: they form the noun. + resources: Segment[]; + action?: string; +}; + +export function place(op: Operation): Placement { + const [groupSegment, ...rest] = op.segments as [Segment, ...Segment[]]; + const last = rest.at(-1); + const isAction = + last && + !last.param && + config.actionVerbs.includes(last.name.split("-")[0]!); + return { + groupSegment, + resources: isAction ? rest.slice(0, -1) : rest, + action: isAction ? last.name : undefined, + }; +} + +// `-`, unless `config.operations` names the command. +export function commandName(op: Operation, placement: Placement): string { + const override = config.operations[op.id]?.name; + if (override) return override; + const atItem = op.segments.at(-1)!.param !== undefined; + let verb: string; + let rest: string[] = []; + // Operations on a whole collection keep the last noun plural. + let plural = !atItem; + const link = config.links.includes(op.id); + if (placement.action) { + [verb, ...rest] = placement.action.split("-") as [string, ...string[]]; + plural = false; + } else if (op.method === "get") { + verb = op.returnsFile ? "download" : atItem ? "get" : "list"; + plural = verb === "list"; + } else if (op.method === "post") { + verb = link ? "add" : op.body === "multipart" ? "upload" : "create"; + // `create` and `add` make one item, unless the API adds many at once. + plural = op.id.startsWith("Bulk"); + } else if (op.method === "delete" && link) { + verb = "remove"; + } else { + verb = { patch: "update", put: "set", delete: "delete" }[op.method]; + } + const noun = placement.resources.map((s, i) => + plural && i === placement.resources.length - 1 + ? (config.nouns[s.name]?.commandPlural ?? s.name) + : (config.nouns[s.name]?.commandSingular ?? singular(s.name)), + ); + return [verb, ...noun, ...rest].join("-"); +} + +export function checkUniqueNames(groups: Group[]): void { + for (const group of groups) { + const seen = new Map(); + for (const { name, op } of group.commands) { + if (seen.has(name)) { + throw new Error( + `duplicate command "${group.name} ${name}" for ${seen.get(name)} and ${op.id}; add a name override in scripts/codegen/config.ts`, + ); + } + seen.set(name, op.id); + } + } +} diff --git a/scripts/codegen/config.ts b/scripts/codegen/config.ts index d8be249..954c2fd 100644 --- a/scripts/codegen/config.ts +++ b/scripts/codegen/config.ts @@ -1,7 +1,7 @@ // Structural decisions the command generator cannot derive from the OpenAPI // spec, plus help text that replaces generated descriptions. // -// The generator (main.ts) derives the command tree from +// The generator (generate.ts) derives the command tree from // api-spec.json using the conventions in AGENTS.md. Everything here is an // explicit exception or naming input to those conventions. Prefer extending the // conventions over adding per-operation overrides. diff --git a/scripts/codegen/render.ts b/scripts/codegen/generate.ts similarity index 66% rename from scripts/codegen/render.ts rename to scripts/codegen/generate.ts index 4b89917..7b871fb 100644 --- a/scripts/codegen/render.ts +++ b/scripts/codegen/generate.ts @@ -1,11 +1,92 @@ -// Renders one TypeScript file per group, plus index.ts. The output is -// unformatted; main.ts formats it with oxfmt. +/// +// Generates the API commands in src/commands/ from api-spec.json. +// +// pnpm generate:commands [--out ] +// +// The rules are in AGENTS.md, and each file here follows one part: +// +// spec.ts what the API has: one `Operation` per endpoint and method +// commands.ts which commands exist and what they're called ("Command structure") +// help.ts what each command's flags and help say ("Help text") +// config.ts the exceptions to those rules +// generate.ts this file: builds the groups, renders one TypeScript file +// per group plus index.ts, and formats them with oxfmt +import { execFileSync } from "node:child_process"; +import { + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { parseArgs } from "node:util"; +import { + camel, + checkUniqueNames, + type Command, + commandName, + type Group, + lowerFirst, + newGroup, + pascal, + place, +} from "./commands.js"; import * as config from "./config.js"; -import type { Command, Group } from "./groups.js"; -import type { Option } from "./options.js"; -import { camel, lowerFirst, pascal } from "./words.js"; +import { + applyHelpOverrides, + buildOptions, + commandDescription, + type Option, +} from "./help.js"; +import { loadOperations, type Operation } from "./spec.js"; -export const generatedHeader = +// =========================================================================== +// Groups +// =========================================================================== + +function buildGroups(operations: Operation[]): Group[] { + const groupNames = new Set(operations.map((op) => op.segments[0]!.name)); + // Groups like `customer-trust` have no endpoints of their own, so they don't + // own the resources under them in descriptions. + const groupsWithEndpoints = new Set( + operations + .filter((op) => op.segments.length === 1) + .map((op) => op.segments[0]!.name), + ); + const groups = new Map(); + for (const op of operations) { + const placement = place(op); + const name = placement.groupSegment.name; + if (!groups.has(name)) groups.set(name, newGroup(name)); + const group = groups.get(name)!; + const command = commandName(op, placement); + const queryNames = op.queryParams.map((q) => q.name); + group.commands.push({ + name: command, + description: commandDescription( + command, + op, + placement, + group, + groupsWithEndpoints.has(name), + ), + op, + options: buildOptions(op, placement, group, groupNames), + paginated: + queryNames.includes("pageSize") && queryNames.includes("pageCursor"), + download: op.returnsFile, + }); + } + return [...groups.values()]; +} + +// =========================================================================== +// Render +// =========================================================================== + +// The output is unformatted; oxfmt formats it after it's written. +const generatedHeader = "// Code generated by `pnpm generate:commands`. DO NOT EDIT."; const quote = (s: string) => JSON.stringify(s); @@ -229,7 +310,7 @@ export function registerApiCommands(program: Command, getFlags: GetFlags): void } // File contents keyed by file name, with groups in `config.topLevelOrder`. -export function renderFiles(groups: Group[]): Map { +function renderFiles(groups: Group[]): Map { const order = (g: Group) => { const i = config.topLevelOrder.indexOf(g.name); return i === -1 ? config.topLevelOrder.length : i; @@ -241,3 +322,40 @@ export function renderFiles(groups: Group[]): Map { files.set("index.ts", renderIndex(sorted)); return files; } + +// =========================================================================== +// Main +// =========================================================================== + +const repoRoot = new URL("../../", import.meta.url).pathname; +const commandsDir = join(repoRoot, "src/commands"); +const { values: args } = parseArgs({ options: { out: { type: "string" } } }); +const outDir = args.out ?? commandsDir; + +const groups = buildGroups(loadOperations(join(repoRoot, "api-spec.json"))); +checkUniqueNames(groups); +applyHelpOverrides(groups); + +const files = renderFiles(groups); + +// Write the files, removing generated files for groups that no longer exist. +mkdirSync(outDir, { recursive: true }); +for (const name of readdirSync(outDir)) { + const path = join(outDir, name); + if ( + !files.has(name) && + name.endsWith(".ts") && + readFileSync(path, "utf8").startsWith(generatedHeader) + ) { + rmSync(path); + } +} +const paths = [...files.keys()].map((name) => join(outDir, name)); +for (const [name, content] of files) writeFileSync(join(outDir, name), content); +// Pass the config explicitly so `--out` elsewhere gets the same output. +execFileSync( + join(repoRoot, "node_modules/.bin/oxfmt"), + ["-c", join(repoRoot, ".oxfmtrc.json"), ...paths], + { stdio: ["ignore", "ignore", "inherit"] }, +); +console.error(`wrote ${files.size} files to ${outDir}`); diff --git a/scripts/codegen/groups.ts b/scripts/codegen/groups.ts deleted file mode 100644 index e2b7247..0000000 --- a/scripts/codegen/groups.ts +++ /dev/null @@ -1,88 +0,0 @@ -// Collects operations into command groups, one per top-level path segment. -import * as config from "./config.js"; -import { commandDescription } from "./help.js"; -import { commandName, place } from "./naming.js"; -import { buildOptions, type Option } from "./options.js"; -import type { Operation } from "./spec.js"; -import { displayPlural, displaySingular } from "./words.js"; - -export type Command = { - name: string; - description: string; - op: Operation; - options: Option[]; - paginated: boolean; - download: boolean; -}; - -// A group is named after its path segment, which also names its file. -export type Group = { - name: string; - description: string; - singular: string; - plural: string; - commands: Command[]; -}; - -function newGroup(name: string): Group { - const noun = config.nouns[name]; - const plural = noun?.plural ?? displayPlural(name); - return { - name, - description: `Manage ${plural}`, - singular: noun?.singular ?? displaySingular(name), - plural, - commands: [], - }; -} - -export function buildGroups(operations: Operation[]): Group[] { - const groupNames = new Set(operations.map((op) => op.segments[0]!.name)); - // Groups like `customer-trust` have no endpoints of their own, so they don't - // own the resources under them in descriptions. - const groupsWithEndpoints = new Set( - operations - .filter((op) => op.segments.length === 1) - .map((op) => op.segments[0]!.name), - ); - const groups = new Map(); - for (const op of operations) { - const placement = place(op); - const name = placement.groupSegment.name; - if (!groups.has(name)) groups.set(name, newGroup(name)); - const group = groups.get(name)!; - const command = - config.operations[op.id]?.name ?? commandName(op, placement); - const queryNames = op.queryParams.map((q) => q.name); - group.commands.push({ - name: command, - description: commandDescription( - command, - op, - placement, - group, - groupsWithEndpoints.has(name), - ), - op, - options: buildOptions(op, placement, group, groupNames), - paginated: - queryNames.includes("pageSize") && queryNames.includes("pageCursor"), - download: op.returnsFile, - }); - } - return [...groups.values()]; -} - -export function checkUniqueNames(groups: Group[]): void { - for (const group of groups) { - const seen = new Map(); - for (const { name, op } of group.commands) { - if (seen.has(name)) { - throw new Error( - `duplicate command "${group.name} ${name}" for ${seen.get(name)} and ${op.id}; add a name override in scripts/codegen/config.ts`, - ); - } - seen.set(name, op.id); - } - } -} diff --git a/scripts/codegen/help.ts b/scripts/codegen/help.ts index 650292e..4de686d 100644 --- a/scripts/codegen/help.ts +++ b/scripts/codegen/help.ts @@ -1,20 +1,291 @@ -// Command descriptions: generated from the verb and the resources in the path -// ("List findings for a vendor"), then replaced by `config.commandHelp` and -// `config.optionHelp` where the default reads poorly. -import * as config from "./config.js"; -import type { Group } from "./groups.js"; -import type { Placement } from "./naming.js"; -import type { Operation, Segment } from "./spec.js"; +// What each command's flags and help say: flags from the operation's inputs, +// placeholders, and descriptions from the spec and the path, then the +// overrides in `config.commandHelp` and `config.optionHelp`. See AGENTS.md +// "Help text". import { a, displayPlural, displaySingular, + type Group, humanize, isPlural, + kebab, + lowerFirst, nounFor, + type Placement, singular, upperFirst, -} from "./words.js"; +} from "./commands.js"; +import * as config from "./config.js"; +import { + type Input, + type Operation, + type Schema, + type Segment, + valueSchema, +} from "./spec.js"; + +// =========================================================================== +// Flags +// =========================================================================== + +export type Option = { + flag: string; + placeholder: string; + description: string; + required: boolean; + repeatable: boolean; + // Parse "true"/"false" into a boolean. + parseBool: boolean; + // The SDK types the value more narrowly than `string`, e.g. as an enum. + sdkTyped: boolean; + // Where the value goes in the SDK call, under `key`. + target: "path" | "query" | "form" | "file"; + key: string; +}; + +const searchParams = new Set(["q", "search", "searchString"]); +const placeholderWords = new Set([ + "category", + "direction", + "group", + "kind", + "name", + "owner", + "permission", + "reason", + "role", + "scope", + "severity", + "slug", + "source", + "status", + "template", + "title", + "type", + "url", + "visibility", +]); + +// The noun a parameter refers to: `statusMatchesAny` → `status`. +function paramNoun(name: string): string { + const base = name.replace(/MatchesAny$/, "").replace(/Filter$/, ""); + return singular(kebab(base).split("-").at(-1)!); +} + +function placeholderFor(input: Input, groupNames: Set): string { + const { name, schema, description } = input; + const item = valueSchema(schema); + if (item.type === "boolean" || /^(is|has)[A-Z]/.test(name)) return "bool"; + if (item.format === "date") return "date"; + if ( + item.format === "date-time" || + (!item.enum && /(Date|After|Before)$/.test(name)) + ) { + return /ISO 8601 date\b/.test(description) ? "date" : "timestamp"; + } + if (searchParams.has(name)) return "query"; + if (name === "orderBy") return "field"; + if (item.enum) return paramNoun(name); + if (/\bJSON\b/.test(description)) return "json"; + if ( + /(Id|Ids|Identifier)$/.test(name.replace(/MatchesAny$/, "")) || + name === "id" || + /\bIDs?\b/.test(description) + ) { + return "id"; + } + if (item.type === "integer" || item.type === "number") return "n"; + const noun = paramNoun(name); + if (groupNames.has(`${noun}s`)) return "id"; + return placeholderWords.has(noun) ? noun : "text"; +} + +// First paragraph of a spec description, without "Possible values" lists. +function cleanDescription(text: string): string { + return text + .replace(/\s*Possible values:.*$/s, "") + .split(/\n\s*\n/)[0]! + .replace(/\s+/g, " ") + .trim() + .replace(/\.$/, ""); +} + +// The spec description, plus enum choices and format hints. +function inputDescription( + input: Input, + placeholder: string, + listed: string, +): string { + const { name, schema } = input; + const item = valueSchema(schema); + let text = cleanDescription(input.description); + if (!text && placeholder === "query") text = `Search ${listed}`; + if (!text) { + const subject = humanize(name.replace(/MatchesAny$/, "")); + text = `Filter by ${subject.replace(/\bid(s?)$/, "ID$1")}`; + } + text = upperFirst(text) + // "Boolean determining whether X" → "Whether X". + .replace(/^Boolean (determining|indicating) w/, "W") + // `("true" / "false")` → the standard "(true/false)", added below. + .replace(/\s*\("true" ?\/ ?"false"\)/, ""); + if (placeholder === "timestamp") { + // "in ISO 8601" and "(ISO 8601 string)" → "ISO 8601 timestamp". + text = text.replace( + /ISO 8601(?: string)?(?! (timestamp|date))/g, + "ISO 8601 timestamp", + ); + } + if (item.enum && !item.enum.every((v) => text.includes(v))) { + text += `: ${item.enum.join(", ")}`; + } + if (placeholder === "bool" && !text.includes("true/false")) + text += " (true/false)"; + if (placeholder === "timestamp" && !text.includes("ISO 8601")) { + text += " (ISO 8601 timestamp)"; + } + if (schema.type === "array") text += " (repeatable)"; + return text; +} + +function fileDescription(field: Input): string { + const text = cleanDescription(field.description); + if (text) return `Path to file to upload: ${lowerFirst(text)}`; + if (field.name === "file") return "Path to file to upload"; + return `Path to ${humanize(field.name)} file to upload`; +} + +// "FAQ ID" for `faqId` when the noun has a configured display name, else the +// humanized parameter name ("Security review ID"). +function pathParamDescription(name: string, placement: Placement): string { + const segment = placement.resources.find((s) => s.param === name); + const display = + segment && + (nounFor(segment.name, placement.groupSegment.name)?.singular ?? + (name === "id" ? displaySingular(segment.name) : undefined)); + if (display) return `${upperFirst(display)} ID`; + return upperFirst(humanize(name).replace(/ id$/, " ID")); +} + +// Whether the SDK types the value as something other than a plain `string` +// (or `string[]`): an enum, or a non-string type other than boolean/integer. +function isSdkTyped(schema: Schema): boolean { + const item = valueSchema(schema); + if (item.type === "string") return Boolean(item.enum); + return item.type !== "boolean" && item.type !== "integer"; +} + +// Overrides from `config.params` and `config.operations`, ignoring empty +// values; `extra` applies first. +function withOverrides( + option: Option, + opId: string, + extra?: config.OptionOverride, +): Option { + const overrides = [ + extra, + config.params[option.key], + config.operations[opId]?.options?.[option.key], + ]; + for (const o of overrides) { + Object.assign( + option, + Object.fromEntries(Object.entries(o ?? {}).filter(([, v]) => v)), + ); + } + return option; +} + +export function buildOptions( + op: Operation, + placement: Placement, + group: Group, + groupNames: Set, +): Option[] { + const placeholderOverride = (key: string) => + config.operations[op.id]?.options?.[key]?.placeholder ?? + config.params[key]?.placeholder; + const placeholder = (input: Input) => + placeholderOverride(input.name) ?? placeholderFor(input, groupNames); + // What a search flag searches: the listed resource, else the group. + const last = placement.resources.at(-1); + const listed = last + ? displayPlural(last.name, placement.groupSegment.name) + : group.plural; + + const path = op.pathParams.map((param) => { + // The group's own ID is `--id`; `resourceKind` stays `--resource-kind`. + const own = + param.name === placement.groupSegment.param && + /(^id|Id)$/.test(param.name); + const option: Option = { + flag: own ? "id" : kebab(param.name), + placeholder: own || /Id$/.test(param.name) ? "id" : kebab(param.name), + description: own + ? `${upperFirst(group.singular)} ID` + : pathParamDescription(param.name, placement), + required: true, + repeatable: false, + parseBool: false, + sdkTyped: isSdkTyped(param.schema), + target: "path", + key: param.name, + }; + return withOverrides( + option, + op.id, + own ? config.groups[group.name]?.id : undefined, + ); + }); + + const query = op.queryParams + .filter((param) => param.name !== "pageSize" && param.name !== "pageCursor") + .map((param) => { + const p = placeholder(param); + return withOverrides( + { + flag: kebab(param.name), + placeholder: p, + description: inputDescription(param, p, listed), + required: param.required, + repeatable: param.schema.type === "array", + parseBool: p === "bool" && param.schema.type === "boolean", + sdkTyped: isSdkTyped(param.schema), + target: "query", + key: param.name, + }, + op.id, + ); + }); + + const form = op.formFields.map((field) => { + const isFile = field.schema.format === "binary"; + const p = isFile ? "path" : placeholder(field); + return withOverrides( + { + flag: kebab(field.name), + placeholder: p, + description: isFile + ? fileDescription(field) + : inputDescription(field, p, listed), + required: field.required, + repeatable: false, + parseBool: false, + sdkTyped: false, + target: isFile ? "file" : "form", + key: field.name, + }, + op.id, + ); + }); + + return [...path, ...query, ...form]; +} + +// =========================================================================== +// Command descriptions +// =========================================================================== const prepositions = new Set(["as", "for", "from", "in", "to", "with"]); diff --git a/scripts/codegen/indefinite.d.ts b/scripts/codegen/indefinite.d.ts deleted file mode 100644 index e6353ce..0000000 --- a/scripts/codegen/indefinite.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module "indefinite" { - export default function indefinite(word: string): string; -} diff --git a/scripts/codegen/main.ts b/scripts/codegen/main.ts deleted file mode 100644 index 55c1673..0000000 --- a/scripts/codegen/main.ts +++ /dev/null @@ -1,64 +0,0 @@ -/// -// Generates the API commands in src/commands/ from api-spec.json. -// -// pnpm generate:commands [--out ] -// -// AGENTS.md ("Command structure") describes the naming rules; config.ts holds -// the exceptions and help text overrides. Each stage is its own module: -// -// spec.ts read each API operation into an `Operation` -// naming.ts place it in a group and name it (`vendors list-findings`) -// options.ts turn its parameters and body into flags -// help.ts describe the command, then apply help overrides -// groups.ts collect commands into groups -// render.ts write one TypeScript file per group, plus index.ts -// -// words.ts has the shared word helpers. The rendered files are formatted with -// oxfmt, using the repo's .oxfmtrc.json. -import { - mkdirSync, - readdirSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { execFileSync } from "node:child_process"; -import { join } from "node:path"; -import { parseArgs } from "node:util"; -import { buildGroups, checkUniqueNames } from "./groups.js"; -import { applyHelpOverrides } from "./help.js"; -import { generatedHeader, renderFiles } from "./render.js"; -import { loadOperations } from "./spec.js"; - -const repoRoot = new URL("../../", import.meta.url).pathname; -const commandsDir = join(repoRoot, "src/commands"); -const { values: args } = parseArgs({ options: { out: { type: "string" } } }); -const outDir = args.out ?? commandsDir; - -const groups = buildGroups(loadOperations(join(repoRoot, "api-spec.json"))); -checkUniqueNames(groups); -applyHelpOverrides(groups); - -const files = renderFiles(groups); - -// Write the files, removing generated files for groups that no longer exist. -mkdirSync(outDir, { recursive: true }); -for (const name of readdirSync(outDir)) { - const path = join(outDir, name); - if ( - !files.has(name) && - name.endsWith(".ts") && - readFileSync(path, "utf8").startsWith(generatedHeader) - ) { - rmSync(path); - } -} -const paths = [...files.keys()].map((name) => join(outDir, name)); -for (const [name, content] of files) writeFileSync(join(outDir, name), content); -// Pass the config explicitly so `--out` elsewhere gets the same output. -execFileSync( - join(repoRoot, "node_modules/.bin/oxfmt"), - ["-c", join(repoRoot, ".oxfmtrc.json"), ...paths], - { stdio: ["ignore", "ignore", "inherit"] }, -); -console.error(`wrote ${files.size} files to ${outDir}`); diff --git a/scripts/codegen/naming.ts b/scripts/codegen/naming.ts deleted file mode 100644 index 50f2400..0000000 --- a/scripts/codegen/naming.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Places an operation in a group and names its command (`vendors -// list-findings`); see AGENTS.md "Command structure". -import * as config from "./config.js"; -import type { Operation, Segment } from "./spec.js"; -import { singular } from "./words.js"; - -// Where an operation sits in the CLI and what its path is made of. -export type Placement = { - // The path's first segment, which names the group. - groupSegment: Segment; - // Segments after the group, minus a trailing action: they form the noun. - resources: Segment[]; - action?: string; -}; - -export function place(op: Operation): Placement { - const [groupSegment, ...rest] = op.segments as [Segment, ...Segment[]]; - const last = rest.at(-1); - const isAction = - last && - !last.param && - config.actionVerbs.includes(last.name.split("-")[0]!); - return { - groupSegment, - resources: isAction ? rest.slice(0, -1) : rest, - action: isAction ? last.name : undefined, - }; -} - -// `-`. -export function commandName(op: Operation, placement: Placement): string { - const atItem = op.segments.at(-1)!.param !== undefined; - let verb: string; - let rest: string[] = []; - // Operations on a whole collection keep the last noun plural. - let plural = !atItem; - const link = config.links.includes(op.id); - if (placement.action) { - [verb, ...rest] = placement.action.split("-") as [string, ...string[]]; - plural = false; - } else if (op.method === "get") { - verb = op.returnsFile ? "download" : atItem ? "get" : "list"; - plural = verb === "list"; - } else if (op.method === "post") { - verb = link ? "add" : op.body === "multipart" ? "upload" : "create"; - // `create` and `add` make one item, unless the API adds many at once. - plural = op.id.startsWith("Bulk"); - } else if (op.method === "delete" && link) { - verb = "remove"; - } else { - verb = { patch: "update", put: "set", delete: "delete" }[op.method]; - } - const noun = placement.resources.map((s, i) => - plural && i === placement.resources.length - 1 - ? (config.nouns[s.name]?.commandPlural ?? s.name) - : (config.nouns[s.name]?.commandSingular ?? singular(s.name)), - ); - return [verb, ...noun, ...rest].join("-"); -} diff --git a/scripts/codegen/options.ts b/scripts/codegen/options.ts deleted file mode 100644 index d522c2e..0000000 --- a/scripts/codegen/options.ts +++ /dev/null @@ -1,275 +0,0 @@ -// Turns an operation's path parameters, query parameters, and multipart -// fields into flags; see AGENTS.md "Help text" for the placeholder vocabulary. -import * as config from "./config.js"; -import type { Group } from "./groups.js"; -import type { Placement } from "./naming.js"; -import { - type Input, - type Operation, - type Schema, - valueSchema, -} from "./spec.js"; -import { - displayPlural, - displaySingular, - humanize, - kebab, - lowerFirst, - nounFor, - singular, - upperFirst, -} from "./words.js"; - -export type Option = { - flag: string; - placeholder: string; - description: string; - required: boolean; - repeatable: boolean; - // Parse "true"/"false" into a boolean. - parseBool: boolean; - // The SDK types the value more narrowly than `string`, e.g. as an enum. - sdkTyped: boolean; - // Where the value goes in the SDK call, under `key`. - target: "path" | "query" | "form" | "file"; - key: string; -}; - -const searchParams = new Set(["q", "search", "searchString"]); -const placeholderWords = new Set([ - "category", - "direction", - "group", - "kind", - "name", - "owner", - "permission", - "reason", - "role", - "scope", - "severity", - "slug", - "source", - "status", - "template", - "title", - "type", - "url", - "visibility", -]); - -// The noun a parameter refers to: `statusMatchesAny` → `status`. -function paramNoun(name: string): string { - const base = name.replace(/MatchesAny$/, "").replace(/Filter$/, ""); - return singular(kebab(base).split("-").at(-1)!); -} - -function placeholderFor(input: Input, groupNames: Set): string { - const { name, schema, description } = input; - const item = valueSchema(schema); - if (item.type === "boolean" || /^(is|has)[A-Z]/.test(name)) return "bool"; - if (item.format === "date") return "date"; - if ( - item.format === "date-time" || - (!item.enum && /(Date|After|Before)$/.test(name)) - ) { - return /ISO 8601 date\b/.test(description) ? "date" : "timestamp"; - } - if (searchParams.has(name)) return "query"; - if (name === "orderBy") return "field"; - if (item.enum) return paramNoun(name); - if (/\bJSON\b/.test(description)) return "json"; - if ( - /(Id|Ids|Identifier)$/.test(name.replace(/MatchesAny$/, "")) || - name === "id" || - /\bIDs?\b/.test(description) - ) { - return "id"; - } - if (item.type === "integer" || item.type === "number") return "n"; - const noun = paramNoun(name); - if (groupNames.has(`${noun}s`)) return "id"; - return placeholderWords.has(noun) ? noun : "text"; -} - -// First paragraph of a spec description, without "Possible values" lists. -function cleanDescription(text: string): string { - return text - .replace(/\s*Possible values:.*$/s, "") - .split(/\n\s*\n/)[0]! - .replace(/\s+/g, " ") - .trim() - .replace(/\.$/, ""); -} - -// The spec description, plus enum choices and format hints. -function inputDescription( - input: Input, - placeholder: string, - listed: string, -): string { - const { name, schema } = input; - const item = valueSchema(schema); - let text = cleanDescription(input.description); - if (!text && placeholder === "query") text = `Search ${listed}`; - if (!text) { - const subject = humanize(name.replace(/MatchesAny$/, "")); - text = `Filter by ${subject.replace(/\bid(s?)$/, "ID$1")}`; - } - text = upperFirst(text) - // "Boolean determining whether X" → "Whether X". - .replace(/^Boolean (determining|indicating) w/, "W") - // `("true" / "false")` → the standard "(true/false)", added below. - .replace(/\s*\("true" ?\/ ?"false"\)/, ""); - if (placeholder === "timestamp") { - // "in ISO 8601" and "(ISO 8601 string)" → "ISO 8601 timestamp". - text = text.replace( - /ISO 8601(?: string)?(?! (timestamp|date))/g, - "ISO 8601 timestamp", - ); - } - if (item.enum && !item.enum.every((v) => text.includes(v))) { - text += `: ${item.enum.join(", ")}`; - } - if (placeholder === "bool" && !text.includes("true/false")) - text += " (true/false)"; - if (placeholder === "timestamp" && !text.includes("ISO 8601")) { - text += " (ISO 8601 timestamp)"; - } - if (schema.type === "array") text += " (repeatable)"; - return text; -} - -function fileDescription(field: Input): string { - const text = cleanDescription(field.description); - if (text) return `Path to file to upload: ${lowerFirst(text)}`; - if (field.name === "file") return "Path to file to upload"; - return `Path to ${humanize(field.name)} file to upload`; -} - -// "FAQ ID" for `faqId` when the noun has a configured display name, else the -// humanized parameter name ("Security review ID"). -function pathParamDescription(name: string, placement: Placement): string { - const segment = placement.resources.find((s) => s.param === name); - const display = - segment && - (nounFor(segment.name, placement.groupSegment.name)?.singular ?? - (name === "id" ? displaySingular(segment.name) : undefined)); - if (display) return `${upperFirst(display)} ID`; - return upperFirst(humanize(name).replace(/ id$/, " ID")); -} - -// Whether the SDK types the value as something other than a plain `string` -// (or `string[]`): an enum, or a non-string type other than boolean/integer. -function isSdkTyped(schema: Schema): boolean { - const item = valueSchema(schema); - if (item.type === "string") return Boolean(item.enum); - return item.type !== "boolean" && item.type !== "integer"; -} - -// Overrides from `config.params` and `config.operations`, ignoring empty -// values; `extra` applies first. -function withOverrides( - option: Option, - opId: string, - extra?: config.OptionOverride, -): Option { - const overrides = [ - extra, - config.params[option.key], - config.operations[opId]?.options?.[option.key], - ]; - for (const o of overrides) { - Object.assign( - option, - Object.fromEntries(Object.entries(o ?? {}).filter(([, v]) => v)), - ); - } - return option; -} - -export function buildOptions( - op: Operation, - placement: Placement, - group: Group, - groupNames: Set, -): Option[] { - const placeholderOverride = (key: string) => - config.operations[op.id]?.options?.[key]?.placeholder ?? - config.params[key]?.placeholder; - const placeholder = (input: Input) => - placeholderOverride(input.name) ?? placeholderFor(input, groupNames); - // What a search flag searches: the listed resource, else the group. - const last = placement.resources.at(-1); - const listed = last - ? displayPlural(last.name, placement.groupSegment.name) - : group.plural; - - const path = op.pathParams.map((param) => { - // The group's own ID is `--id`; `resourceKind` stays `--resource-kind`. - const own = - param.name === placement.groupSegment.param && - /(^id|Id)$/.test(param.name); - const option: Option = { - flag: own ? "id" : kebab(param.name), - placeholder: own || /Id$/.test(param.name) ? "id" : kebab(param.name), - description: own - ? `${upperFirst(group.singular)} ID` - : pathParamDescription(param.name, placement), - required: true, - repeatable: false, - parseBool: false, - sdkTyped: isSdkTyped(param.schema), - target: "path", - key: param.name, - }; - return withOverrides( - option, - op.id, - own ? config.groups[group.name]?.id : undefined, - ); - }); - - const query = op.queryParams - .filter((param) => param.name !== "pageSize" && param.name !== "pageCursor") - .map((param) => { - const p = placeholder(param); - return withOverrides( - { - flag: kebab(param.name), - placeholder: p, - description: inputDescription(param, p, listed), - required: param.required, - repeatable: param.schema.type === "array", - parseBool: p === "bool" && param.schema.type === "boolean", - sdkTyped: isSdkTyped(param.schema), - target: "query", - key: param.name, - }, - op.id, - ); - }); - - const form = op.formFields.map((field) => { - const isFile = field.schema.format === "binary"; - const p = isFile ? "path" : placeholder(field); - return withOverrides( - { - flag: kebab(field.name), - placeholder: p, - description: isFile - ? fileDescription(field) - : inputDescription(field, p, listed), - required: field.required, - repeatable: false, - parseBool: false, - sdkTyped: false, - target: isFile ? "file" : "form", - key: field.name, - }, - op.id, - ); - }); - - return [...path, ...query, ...form]; -} diff --git a/scripts/codegen/words.ts b/scripts/codegen/words.ts deleted file mode 100644 index d6c2c0a..0000000 --- a/scripts/codegen/words.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Word helpers: case conversion, singular and plural, articles, and the -// display names used in help text. -import { camelCase, kebabCase, noCase, pascalCase } from "change-case"; -import indefinite from "indefinite"; -import pluralize from "pluralize"; -import * as config from "./config.js"; - -// `/trust-centers/{slugId}/data-collected` is about data, not one datum. -pluralize.addUncountableRule("data"); - -export { camelCase as camel, kebabCase as kebab, pascalCase as pascal }; - -// `faqCategories` and `faq-categories` → "faq categories". -export const humanize = noCase; -export const lowerFirst = (s: string) => - s.replace(/^./, (c) => c.toLowerCase()); -export const upperFirst = (s: string) => - s.replace(/^./, (c) => c.toUpperCase()); - -// "a vendor", "an integration". -export const a = (noun: string) => indefinite(noun); - -// Singularizes the last word of a kebab-case segment: `security-reviews`. -export const singular = (segment: string) => pluralize.singular(segment); -export const isPlural = (segment: string) => singular(segment) !== segment; - -// Display names from `config.nouns`, keyed by " " or by -// segment, else from the segment itself. -export const nounFor = (segment: string, group?: string) => - (group && config.nouns[`${group} ${segment}`]) || config.nouns[segment]; -export const displaySingular = (segment: string, group?: string) => - nounFor(segment, group)?.singular ?? humanize(singular(segment)); -export const displayPlural = (segment: string, group?: string) => - nounFor(segment, group)?.plural ?? humanize(segment); From 048de1e77aec52f1b134890ce70610bab4c8d886 Mon Sep 17 00:00:00 2001 From: Dennis Chen Date: Mon, 26 Oct 2026 22:12:22 +0000 Subject: [PATCH 7/9] Move placeholder vocabulary and word exceptions to config.ts Lists an agent would edit for new commands now live with the other exceptions: - placeholderNouns: parameter nouns that are their own placeholder - uncountableNouns: nouns that stay as written when singularized ("data") - params: the search parameters (``) and orderBy (``), which were special cases in the placeholder code Generated commands are unchanged. Co-Authored-By: Claude Opus 5.5 --- scripts/codegen/commands.ts | 3 +-- scripts/codegen/config.ts | 33 +++++++++++++++++++++++++++++++++ scripts/codegen/help.ts | 27 +-------------------------- 3 files changed, 35 insertions(+), 28 deletions(-) diff --git a/scripts/codegen/commands.ts b/scripts/codegen/commands.ts index 11c4bb9..0623094 100644 --- a/scripts/codegen/commands.ts +++ b/scripts/codegen/commands.ts @@ -11,8 +11,7 @@ import type { Operation, Segment } from "./spec.js"; // Words // =========================================================================== -// `/trust-centers/{slugId}/data-collected` is about data, not one datum. -pluralize.addUncountableRule("data"); +for (const noun of config.uncountableNouns) pluralize.addUncountableRule(noun); export { camelCase as camel, kebabCase as kebab, pascalCase as pascal }; diff --git a/scripts/codegen/config.ts b/scripts/codegen/config.ts index 954c2fd..31ae739 100644 --- a/scripts/codegen/config.ts +++ b/scripts/codegen/config.ts @@ -111,8 +111,41 @@ export const params: Record = { // Product contexts are a fixed set of names, not IDs. productContextId: { placeholder: "product" }, productContextIdsMatchesAny: { placeholder: "product" }, + // Search terms. + q: { placeholder: "query" }, + search: { placeholder: "query" }, + searchString: { placeholder: "query" }, + orderBy: { placeholder: "field" }, }; +// Parameter nouns that are their own placeholder (`--status `). Other +// free-text parameters use ``; see AGENTS.md "Help text". +export const placeholderNouns = [ + "category", + "direction", + "group", + "kind", + "name", + "owner", + "permission", + "reason", + "role", + "scope", + "severity", + "slug", + "source", + "status", + "template", + "title", + "type", + "url", + "visibility", +]; + +// Plural-looking nouns that stay as written when singularized, e.g. +// `/trust-centers/{slugId}/data-collected` is about data, not one datum. +export const uncountableNouns = ["data"]; + // A trailing path segment whose first word is one of these is an action, and // names the command (`/vendors/{vendorId}/set-status` → `vendors set-status`). export const actionVerbs = [ diff --git a/scripts/codegen/help.ts b/scripts/codegen/help.ts index 4de686d..a2c0238 100644 --- a/scripts/codegen/help.ts +++ b/scripts/codegen/help.ts @@ -44,29 +44,6 @@ export type Option = { key: string; }; -const searchParams = new Set(["q", "search", "searchString"]); -const placeholderWords = new Set([ - "category", - "direction", - "group", - "kind", - "name", - "owner", - "permission", - "reason", - "role", - "scope", - "severity", - "slug", - "source", - "status", - "template", - "title", - "type", - "url", - "visibility", -]); - // The noun a parameter refers to: `statusMatchesAny` → `status`. function paramNoun(name: string): string { const base = name.replace(/MatchesAny$/, "").replace(/Filter$/, ""); @@ -84,8 +61,6 @@ function placeholderFor(input: Input, groupNames: Set): string { ) { return /ISO 8601 date\b/.test(description) ? "date" : "timestamp"; } - if (searchParams.has(name)) return "query"; - if (name === "orderBy") return "field"; if (item.enum) return paramNoun(name); if (/\bJSON\b/.test(description)) return "json"; if ( @@ -98,7 +73,7 @@ function placeholderFor(input: Input, groupNames: Set): string { if (item.type === "integer" || item.type === "number") return "n"; const noun = paramNoun(name); if (groupNames.has(`${noun}s`)) return "id"; - return placeholderWords.has(noun) ? noun : "text"; + return config.placeholderNouns.includes(noun) ? noun : "text"; } // First paragraph of a spec description, without "Possible values" lists. From 64372c5623e4983f9b15653e0fc76bf1ea5151d3 Mon Sep 17 00:00:00 2001 From: Dennis Chen Date: Mon, 26 Oct 2026 22:16:29 +0000 Subject: [PATCH 8/9] Move shared types to types.ts types.ts holds the command model (Operation, Group, Command, Option, ...) and the shape of config.ts entries, so config.ts is only the exceptions themselves. Types used in one file stay there. Generated commands are unchanged. Co-Authored-By: Claude Opus 5.5 --- scripts/codegen/commands.ts | 30 +-------- scripts/codegen/config.ts | 37 ++--------- scripts/codegen/generate.ts | 7 +-- scripts/codegen/help.ts | 37 ++++------- scripts/codegen/spec.ts | 38 +----------- scripts/codegen/types.ts | 120 ++++++++++++++++++++++++++++++++++++ 6 files changed, 143 insertions(+), 126 deletions(-) create mode 100644 scripts/codegen/types.ts diff --git a/scripts/codegen/commands.ts b/scripts/codegen/commands.ts index 0623094..8ab927c 100644 --- a/scripts/codegen/commands.ts +++ b/scripts/codegen/commands.ts @@ -4,8 +4,7 @@ import { camelCase, kebabCase, noCase, pascalCase } from "change-case"; import pluralize from "pluralize"; import * as config from "./config.js"; -import type { Option } from "./help.js"; -import type { Operation, Segment } from "./spec.js"; +import type { Group, Operation, Placement, Segment } from "./types.js"; // =========================================================================== // Words @@ -43,24 +42,6 @@ export const displayPlural = (segment: string, group?: string) => // Groups and commands // =========================================================================== -export type Command = { - name: string; - description: string; - op: Operation; - options: Option[]; - paginated: boolean; - download: boolean; -}; - -// A group is named after its path segment, which also names its file. -export type Group = { - name: string; - description: string; - singular: string; - plural: string; - commands: Command[]; -}; - export function newGroup(name: string): Group { const noun = config.nouns[name]; const plural = noun?.plural ?? displayPlural(name); @@ -73,15 +54,6 @@ export function newGroup(name: string): Group { }; } -// Where an operation sits in the CLI and what its path is made of. -export type Placement = { - // The path's first segment, which names the group. - groupSegment: Segment; - // Segments after the group, minus a trailing action: they form the noun. - resources: Segment[]; - action?: string; -}; - export function place(op: Operation): Placement { const [groupSegment, ...rest] = op.segments as [Segment, ...Segment[]]; const last = rest.at(-1); diff --git a/scripts/codegen/config.ts b/scripts/codegen/config.ts index 31ae739..3ab5bc3 100644 --- a/scripts/codegen/config.ts +++ b/scripts/codegen/config.ts @@ -5,37 +5,12 @@ // api-spec.json using the conventions in AGENTS.md. Everything here is an // explicit exception or naming input to those conventions. Prefer extending the // conventions over adding per-operation overrides. - -export type Noun = { - // Display name for one item, e.g. "Trust Center FAQ". - singular?: string; - // Display name for the collection, e.g. "Trust Center FAQs". - plural?: string; - // Name used in verb-noun commands when it differs from the singular path - // segment, e.g. "file" for `uploads` (`documents upload-file`). - commandSingular?: string; - // Name used in `list` and bulk commands when it differs from the path - // segment, e.g. "files" for `uploads` (`documents list-files`). - commandPlural?: string; -}; - -export type OptionOverride = { - flag?: string; - placeholder?: string; - description?: string; -}; - -export type OperationOverride = { - // Command name, replacing the derived verb or verb-noun. - name?: string; - // Keyed by API parameter or multipart field name. - options?: Record; -}; - -export type GroupOverride = { - // Overrides the group's own `--id ` flag. - id?: OptionOverride; -}; +import type { + GroupOverride, + Noun, + OperationOverride, + OptionOverride, +} from "./types.js"; // Top-level command order in `vanta --help`. Groups not listed are appended // alphabetically. diff --git a/scripts/codegen/generate.ts b/scripts/codegen/generate.ts index 7b871fb..df0615a 100644 --- a/scripts/codegen/generate.ts +++ b/scripts/codegen/generate.ts @@ -9,6 +9,7 @@ // commands.ts which commands exist and what they're called ("Command structure") // help.ts what each command's flags and help say ("Help text") // config.ts the exceptions to those rules +// types.ts the command model and the shape of config.ts entries // generate.ts this file: builds the groups, renders one TypeScript file // per group plus index.ts, and formats them with oxfmt import { execFileSync } from "node:child_process"; @@ -24,9 +25,7 @@ import { parseArgs } from "node:util"; import { camel, checkUniqueNames, - type Command, commandName, - type Group, lowerFirst, newGroup, pascal, @@ -37,9 +36,9 @@ import { applyHelpOverrides, buildOptions, commandDescription, - type Option, } from "./help.js"; -import { loadOperations, type Operation } from "./spec.js"; +import { loadOperations } from "./spec.js"; +import type { Command, Group, Operation, Option } from "./types.js"; // =========================================================================== // Groups diff --git a/scripts/codegen/help.ts b/scripts/codegen/help.ts index a2c0238..908153d 100644 --- a/scripts/codegen/help.ts +++ b/scripts/codegen/help.ts @@ -6,44 +6,31 @@ import { a, displayPlural, displaySingular, - type Group, humanize, isPlural, kebab, lowerFirst, nounFor, - type Placement, singular, upperFirst, } from "./commands.js"; import * as config from "./config.js"; -import { - type Input, - type Operation, - type Schema, - type Segment, - valueSchema, -} from "./spec.js"; +import { valueSchema } from "./spec.js"; +import type { + Group, + Input, + Operation, + Option, + OptionOverride, + Placement, + Schema, + Segment, +} from "./types.js"; // =========================================================================== // Flags // =========================================================================== -export type Option = { - flag: string; - placeholder: string; - description: string; - required: boolean; - repeatable: boolean; - // Parse "true"/"false" into a boolean. - parseBool: boolean; - // The SDK types the value more narrowly than `string`, e.g. as an enum. - sdkTyped: boolean; - // Where the value goes in the SDK call, under `key`. - target: "path" | "query" | "form" | "file"; - key: string; -}; - // The noun a parameter refers to: `statusMatchesAny` → `status`. function paramNoun(name: string): string { const base = name.replace(/MatchesAny$/, "").replace(/Filter$/, ""); @@ -156,7 +143,7 @@ function isSdkTyped(schema: Schema): boolean { function withOverrides( option: Option, opId: string, - extra?: config.OptionOverride, + extra?: OptionOverride, ): Option { const overrides = [ extra, diff --git a/scripts/codegen/spec.ts b/scripts/codegen/spec.ts index c845958..c9bae8d 100644 --- a/scripts/codegen/spec.ts +++ b/scripts/codegen/spec.ts @@ -1,9 +1,8 @@ // Reads api-spec.json into one `Operation` per API endpoint and method. import { readFileSync } from "node:fs"; +import type { Input, Method, Operation, Schema, Segment } from "./types.js"; // The parts of the OpenAPI spec the generator reads. -type Method = "get" | "post" | "put" | "patch" | "delete"; - type Spec = { paths: Record>>; }; @@ -24,41 +23,6 @@ type SpecParameter = { schema?: Schema; }; -export type Schema = { - type?: string; - format?: string; - enum?: string[]; - items?: Schema; - description?: string; - properties?: Record; - required?: string[]; -}; - -// One API input: a path or query parameter, or a multipart form field. -export type Input = { - name: string; - required: boolean; - description: string; - schema: Schema; -}; - -// A literal path segment and the ID parameter that follows it, if any: -// `/vendors/{vendorId}` is `{ name: "vendors", param: "vendorId" }`. -export type Segment = { name: string; param?: string }; - -export type Operation = { - id: string; - method: Method; - path: string; - summary: string; - segments: Segment[]; - pathParams: Input[]; - queryParams: Input[]; - body: "none" | "json" | "multipart"; - formFields: Input[]; - returnsFile: boolean; -}; - // The schema of one value: an array's items, else the schema itself. export const valueSchema = (schema: Schema): Schema => schema.type === "array" ? (schema.items ?? {}) : schema; diff --git a/scripts/codegen/types.ts b/scripts/codegen/types.ts new file mode 100644 index 0000000..c4234e4 --- /dev/null +++ b/scripts/codegen/types.ts @@ -0,0 +1,120 @@ +// Types shared across the generator: the command model it builds from the +// spec, and the shape of config.ts entries. + +// =========================================================================== +// The command model +// =========================================================================== + +export type Method = "get" | "post" | "put" | "patch" | "delete"; + +export type Schema = { + type?: string; + format?: string; + enum?: string[]; + items?: Schema; + description?: string; + properties?: Record; + required?: string[]; +}; + +// One API input: a path or query parameter, or a multipart form field. +export type Input = { + name: string; + required: boolean; + description: string; + schema: Schema; +}; + +// A literal path segment and the ID parameter that follows it, if any: +// `/vendors/{vendorId}` is `{ name: "vendors", param: "vendorId" }`. +export type Segment = { name: string; param?: string }; + +export type Operation = { + id: string; + method: Method; + path: string; + summary: string; + segments: Segment[]; + pathParams: Input[]; + queryParams: Input[]; + body: "none" | "json" | "multipart"; + formFields: Input[]; + returnsFile: boolean; +}; + +// Where an operation sits in the CLI and what its path is made of. +export type Placement = { + // The path's first segment, which names the group. + groupSegment: Segment; + // Segments after the group, minus a trailing action: they form the noun. + resources: Segment[]; + action?: string; +}; + +export type Option = { + flag: string; + placeholder: string; + description: string; + required: boolean; + repeatable: boolean; + // Parse "true"/"false" into a boolean. + parseBool: boolean; + // The SDK types the value more narrowly than `string`, e.g. as an enum. + sdkTyped: boolean; + // Where the value goes in the SDK call, under `key`. + target: "path" | "query" | "form" | "file"; + key: string; +}; + +export type Command = { + name: string; + description: string; + op: Operation; + options: Option[]; + paginated: boolean; + download: boolean; +}; + +// A group is named after its path segment, which also names its file. +export type Group = { + name: string; + description: string; + singular: string; + plural: string; + commands: Command[]; +}; + +// =========================================================================== +// config.ts entries +// =========================================================================== + +export type Noun = { + // Display name for one item, e.g. "Trust Center FAQ". + singular?: string; + // Display name for the collection, e.g. "Trust Center FAQs". + plural?: string; + // Name used in verb-noun commands when it differs from the singular path + // segment, e.g. "file" for `uploads` (`documents upload-file`). + commandSingular?: string; + // Name used in `list` and bulk commands when it differs from the path + // segment, e.g. "files" for `uploads` (`documents list-files`). + commandPlural?: string; +}; + +export type OptionOverride = { + flag?: string; + placeholder?: string; + description?: string; +}; + +export type OperationOverride = { + // Command name, replacing the derived verb or verb-noun. + name?: string; + // Keyed by API parameter or multipart field name. + options?: Record; +}; + +export type GroupOverride = { + // Overrides the group's own `--id ` flag. + id?: OptionOverride; +}; From 094ce562897422699db52616a8cff06044110971 Mon Sep 17 00:00:00 2001 From: Dennis Chen Date: Mon, 26 Oct 2026 22:45:29 +0000 Subject: [PATCH 9/9] Replace pluralize and change-case with small helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generator only needs case conversion and a few singular rules, which match the libraries on every word in the spec. Irregular plurals go in config.irregularNouns (people → person); config.uncountableNouns is no longer needed, since the rules leave "data" alone. Generated commands are unchanged. Co-Authored-By: Claude Opus 5.5 --- package.json | 3 --- pnpm-lock.yaml | 25 ------------------------- scripts/codegen/commands.ts | 30 +++++++++++++++++++++--------- scripts/codegen/config.ts | 6 +++--- 4 files changed, 24 insertions(+), 40 deletions(-) diff --git a/package.json b/package.json index b401ba7..e1dce3b 100644 --- a/package.json +++ b/package.json @@ -29,11 +29,8 @@ "devDependencies": { "@hey-api/openapi-ts": "^0.85.0", "@types/node": "^22.15.0", - "@types/pluralize": "0.0.33", "bun": "^1.3.14", - "change-case": "5.4.4", "oxfmt": "0.72.0", - "pluralize": "8.0.0", "tsup": "^8.5.0", "tsx": "^4.20.0", "typescript": "^5.8.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f453f5..d3ded7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,21 +182,12 @@ importers: '@types/node': specifier: ^22.15.0 version: 22.20.1 - '@types/pluralize': - specifier: 0.0.33 - version: 0.0.33 bun: specifier: ^1.3.14 version: 1.3.14 - change-case: - specifier: 5.4.4 - version: 5.4.4 oxfmt: specifier: 0.72.0 version: 0.72.0 - pluralize: - specifier: 8.0.0 - version: 8.0.0 tsup: specifier: ^8.5.0 version: 8.5.1(jiti@2.7.0)(tsx@4.23.12)(typescript@5.9.3) @@ -994,9 +985,6 @@ packages: '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} - '@types/pluralize@0.0.33': - resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==} - acorn@8.18.0: resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} @@ -1040,9 +1028,6 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} - change-case@5.4.4: - resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} - chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -1262,10 +1247,6 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - pluralize@8.0.0: - resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} - engines: {node: '>=4'} - postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} @@ -1838,8 +1819,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/pluralize@0.0.33': {} - acorn@8.18.0: {} ansi-colors@4.1.3: {} @@ -1893,8 +1872,6 @@ snapshots: cac@6.7.14: {} - change-case@5.4.4: {} - chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -2143,8 +2120,6 @@ snapshots: exsolve: 1.1.1 pathe: 2.0.3 - pluralize@8.0.0: {} - postcss-load-config@6.0.1(jiti@2.7.0)(tsx@4.23.12): dependencies: lilconfig: 3.1.3 diff --git a/scripts/codegen/commands.ts b/scripts/codegen/commands.ts index 8ab927c..f1ba859 100644 --- a/scripts/codegen/commands.ts +++ b/scripts/codegen/commands.ts @@ -1,8 +1,6 @@ // Which commands exist and what they're called: each operation is placed in // a group and named `-` (`vendors list-findings`). See AGENTS.md // "Command structure". -import { camelCase, kebabCase, noCase, pascalCase } from "change-case"; -import pluralize from "pluralize"; import * as config from "./config.js"; import type { Group, Operation, Placement, Segment } from "./types.js"; @@ -10,23 +8,37 @@ import type { Group, Operation, Placement, Segment } from "./types.js"; // Words // =========================================================================== -for (const noun of config.uncountableNouns) pluralize.addUncountableRule(noun); - -export { camelCase as camel, kebabCase as kebab, pascalCase as pascal }; - -// `faqCategories` and `faq-categories` → "faq categories". -export const humanize = noCase; export const lowerFirst = (s: string) => s.replace(/^./, (c) => c.toLowerCase()); export const upperFirst = (s: string) => s.replace(/^./, (c) => c.toUpperCase()); +// `statusMatchesAny` → `status-matches-any`, and back with `camel`. +export const kebab = (s: string) => + s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); +export const camel = (s: string) => + s.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase()); +export const pascal = (s: string) => upperFirst(camel(s)); +// `faqCategories` and `faq-categories` → "faq categories". +export const humanize = (s: string) => kebab(s).replace(/-/g, " "); // "a vendor", "an integration", "a user". export const a = (noun: string) => `${/^[aeiou]/i.test(noun) && !/^(us|uni|eu)/i.test(noun) ? "an" : "a"} ${noun}`; // Singularizes the last word of a kebab-case segment: `security-reviews`. -export const singular = (segment: string) => pluralize.singular(segment); +export function singular(segment: string): string { + const word = segment.split("-").at(-1)!; + const one = + config.irregularNouns[word] ?? + (word.endsWith("ies") + ? `${word.slice(0, -3)}y` + : word.endsWith("sses") + ? word.slice(0, -2) + : word.endsWith("s") && !/(ss|us)$/.test(word) + ? word.slice(0, -1) + : word); + return segment.slice(0, -word.length) + one; +} export const isPlural = (segment: string) => singular(segment) !== segment; // Display names from `config.nouns`, keyed by " " or by diff --git a/scripts/codegen/config.ts b/scripts/codegen/config.ts index 3ab5bc3..eef0d4a 100644 --- a/scripts/codegen/config.ts +++ b/scripts/codegen/config.ts @@ -117,9 +117,9 @@ export const placeholderNouns = [ "visibility", ]; -// Plural-looking nouns that stay as written when singularized, e.g. -// `/trust-centers/{slugId}/data-collected` is about data, not one datum. -export const uncountableNouns = ["data"]; +// Plurals the singular rules (-ies → -y, -sses → -ss, -s → "") get wrong, +// keyed by plural. +export const irregularNouns: Record = { people: "person" }; // A trailing path segment whose first word is one of these is an action, and // names the command (`/vendors/{vendorId}/set-status` → `vendors set-status`).