From 42a6922dce198582fc8c620e8f538857f65e16b7 Mon Sep 17 00:00:00 2001 From: Dennis Chen Date: Wed, 21 Oct 2026 00:56:06 +0000 Subject: [PATCH 1/4] 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 | 3 +- 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, 1313 insertions(+), 1 deletion(-) 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 7762ac8..3bad886 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: Typecheck run: pnpm typecheck diff --git a/AGENTS.md b/AGENTS.md index 59dbab6..ee1095b 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 b2a1e66..9978f4b 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "build": "pnpm run generate && tsup", "typecheck": "tsc --noEmit", "test": "node --import tsx --test 'src/**/*.test.ts'", - "build:binaries": "tsx scripts/build-binaries.ts" + "build:binaries": "tsx scripts/build-binaries.ts", + "generate:commands": "tsx scripts/codegen/generate-commands.ts" }, "packageManager": "pnpm@12.6.0", "engines": { 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 7ec683e..4767091 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 407a8b6..f0eee3d 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 6150e45..d6e4bfa 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 067aeb7..e96146c 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 201f1c8..f605a05 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 0696570..de4d2e5 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 1ecaa7f..f8feafc 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 a9fc796..759a055 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 f750514..be8b8e9 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 d6f74bb..280b08f 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 27eb6a6..36242a0 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 4d02701..8e8169c 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 a2779d2..e532c06 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 4459396..9d0d79f 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 ce20b3d..bb293b0 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 9d116af..a5d086f 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 2a1f6a1..364eed3 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 43527bd..d9f9634 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 cafb9ee7716b79140ec9a4cf8f375d2f2265e753 Mon Sep 17 00:00:00 2001 From: Dennis Chen Date: Wed, 21 Oct 2026 01:52:23 +0000 Subject: [PATCH 2/4] 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 55dd55742da15bb23c9fd3164bd8b18f7fc71dd4 Mon Sep 17 00:00:00 2001 From: Dennis Chen Date: Wed, 21 Oct 2026 01:59:19 +0000 Subject: [PATCH 3/4] 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 80902d020c38c0dd13f4ca3e3e1976b4a33a3bbf Mon Sep 17 00:00:00 2001 From: Dennis Chen Date: Wed, 21 Oct 2026 17:45:22 +0000 Subject: [PATCH 4/4] 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 {",