diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcbdfad..883e5db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,12 @@ jobs: - name: Generate API client run: pnpm generate + - name: Check generated commands + run: | + pnpm generate:commands + git status --porcelain -- src/commands + test -z "$(git status --porcelain -- src/commands)" + - name: Check formatting run: pnpm format:check diff --git a/AGENTS.md b/AGENTS.md index 089ce40..ff45b65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ # Command structure +Commands in `src/commands/` are generated from `api-spec.json` by `scripts/codegen/`; put exceptions in `scripts/codegen/config.ts` instead of editing generated files. + We map from the REST api path to a command structure for CLI with the following rules: - A command is ` -`. The group is the path's first segment (`/vendors/...` → `vendors`). - The verb is a trailing action in the path (`/vendors/{vendorId}/set-status` → `set-status`), or comes from the method when there is no trailing action: GET → `list` or `get`, POST → `create` or `upload` (files), PATCH → `update`, PUT → `set`, DELETE → `delete`. A POST or DELETE that attaches or detaches an existing resource is `add` or `remove` (`groups add-person`), and a GET that returns a file is `download` (`trust-centers download-resource-media`). A multi-word action puts the noun after its first word: `trust-centers send-viewer-invite-reminder`. diff --git a/package.json b/package.json index 008da44..e1dce3b 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "typecheck": "tsc --noEmit", "test": "node --import tsx --test 'src/**/*.test.ts'", "build:binaries": "tsx scripts/build-binaries.ts", + "generate:commands": "tsx scripts/codegen/generate.ts", "format": "oxfmt", "format:check": "oxfmt --check" }, diff --git a/scripts/codegen/commands.ts b/scripts/codegen/commands.ts new file mode 100644 index 0000000..f1ba859 --- /dev/null +++ b/scripts/codegen/commands.ts @@ -0,0 +1,128 @@ +// Which commands exist and what they're called: each operation is placed in +// a group and named `-` (`vendors list-findings`). See AGENTS.md +// "Command structure". +import * as config from "./config.js"; +import type { Group, Operation, Placement, Segment } from "./types.js"; + +// =========================================================================== +// Words +// =========================================================================== + +export const lowerFirst = (s: string) => + s.replace(/^./, (c) => c.toLowerCase()); +export const upperFirst = (s: string) => + s.replace(/^./, (c) => c.toUpperCase()); +// `statusMatchesAny` → `status-matches-any`, and back with `camel`. +export const kebab = (s: string) => + s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); +export const camel = (s: string) => + s.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase()); +export const pascal = (s: string) => upperFirst(camel(s)); +// `faqCategories` and `faq-categories` → "faq categories". +export const humanize = (s: string) => kebab(s).replace(/-/g, " "); + +// "a vendor", "an integration", "a user". +export const a = (noun: string) => + `${/^[aeiou]/i.test(noun) && !/^(us|uni|eu)/i.test(noun) ? "an" : "a"} ${noun}`; + +// Singularizes the last word of a kebab-case segment: `security-reviews`. +export function singular(segment: string): string { + const word = segment.split("-").at(-1)!; + const one = + config.irregularNouns[word] ?? + (word.endsWith("ies") + ? `${word.slice(0, -3)}y` + : word.endsWith("sses") + ? word.slice(0, -2) + : word.endsWith("s") && !/(ss|us)$/.test(word) + ? word.slice(0, -1) + : word); + return segment.slice(0, -word.length) + one; +} +export const isPlural = (segment: string) => singular(segment) !== segment; + +// Display names from `config.nouns`, keyed by " " or by +// segment, else from the segment itself. +export const nounFor = (segment: string, group?: string) => + (group && config.nouns[`${group} ${segment}`]) || config.nouns[segment]; +export const displaySingular = (segment: string, group?: string) => + nounFor(segment, group)?.singular ?? humanize(singular(segment)); +export const displayPlural = (segment: string, group?: string) => + nounFor(segment, group)?.plural ?? humanize(segment); + +// =========================================================================== +// Groups and commands +// =========================================================================== + +export function newGroup(name: string): Group { + const noun = config.nouns[name]; + const plural = noun?.plural ?? displayPlural(name); + return { + name, + description: `Manage ${plural}`, + singular: noun?.singular ?? displaySingular(name), + plural, + commands: [], + }; +} + +export function place(op: Operation): Placement { + const [groupSegment, ...rest] = op.segments as [Segment, ...Segment[]]; + const last = rest.at(-1); + const isAction = + last && + !last.param && + config.actionVerbs.includes(last.name.split("-")[0]!); + return { + groupSegment, + resources: isAction ? rest.slice(0, -1) : rest, + action: isAction ? last.name : undefined, + }; +} + +// `-`, unless `config.operations` names the command. +export function commandName(op: Operation, placement: Placement): string { + const override = config.operations[op.id]?.name; + if (override) return override; + const atItem = op.segments.at(-1)!.param !== undefined; + let verb: string; + let rest: string[] = []; + // Operations on a whole collection keep the last noun plural. + let plural = !atItem; + const link = config.links.includes(op.id); + if (placement.action) { + [verb, ...rest] = placement.action.split("-") as [string, ...string[]]; + plural = false; + } else if (op.method === "get") { + verb = op.returnsFile ? "download" : atItem ? "get" : "list"; + plural = verb === "list"; + } else if (op.method === "post") { + verb = link ? "add" : op.body === "multipart" ? "upload" : "create"; + // `create` and `add` make one item, unless the API adds many at once. + plural = op.id.startsWith("Bulk"); + } else if (op.method === "delete" && link) { + verb = "remove"; + } else { + verb = { patch: "update", put: "set", delete: "delete" }[op.method]; + } + const noun = placement.resources.map((s, i) => + plural && i === placement.resources.length - 1 + ? (config.nouns[s.name]?.commandPlural ?? s.name) + : (config.nouns[s.name]?.commandSingular ?? singular(s.name)), + ); + return [verb, ...noun, ...rest].join("-"); +} + +export function checkUniqueNames(groups: Group[]): void { + for (const group of groups) { + const seen = new Map(); + for (const { name, op } of group.commands) { + if (seen.has(name)) { + throw new Error( + `duplicate command "${group.name} ${name}" for ${seen.get(name)} and ${op.id}; add a name override in scripts/codegen/config.ts`, + ); + } + seen.set(name, op.id); + } + } +} diff --git a/scripts/codegen/config.ts b/scripts/codegen/config.ts new file mode 100644 index 0000000..eef0d4a --- /dev/null +++ b/scripts/codegen/config.ts @@ -0,0 +1,304 @@ +// Structural decisions the command generator cannot derive from the OpenAPI +// spec, plus help text that replaces generated descriptions. +// +// The generator (generate.ts) derives the command tree from +// api-spec.json using the conventions in AGENTS.md. Everything here is an +// explicit exception or naming input to those conventions. Prefer extending the +// conventions over adding per-operation overrides. +import type { + GroupOverride, + Noun, + OperationOverride, + OptionOverride, +} from "./types.js"; + +// Top-level command order in `vanta --help`. Groups not listed are appended +// alphabetically. +export const topLevelOrder = [ + "controls", + "policies", + "documents", + "tests", + "people", + "personnel-notification-settings", + "groups", + "frameworks", + "users", + "vulnerabilities", + "vulnerable-assets", + "vulnerability-remediations", + "contracts", + "risk-scenarios", + "monitored-computers", + "vendors", + "vendor-assessment-types", + "vendor-risk-attributes", + "discovered-vendors", + "integrations", + "event-logs", + "business-units", + "issues", + "program-scopes", + "customer-trust", + "knowledge-base", + "trust-centers", +]; + +// 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", commandPlural: "files" }, +}; + +// Keyed by group, 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" }, + // Search terms. + q: { placeholder: "query" }, + search: { placeholder: "query" }, + searchString: { placeholder: "query" }, + orderBy: { placeholder: "field" }, +}; + +// Parameter nouns that are their own placeholder (`--status `). Other +// free-text parameters use ``; see AGENTS.md "Help text". +export const placeholderNouns = [ + "category", + "direction", + "group", + "kind", + "name", + "owner", + "permission", + "reason", + "role", + "scope", + "severity", + "slug", + "source", + "status", + "template", + "title", + "type", + "url", + "visibility", +]; + +// Plurals the singular rules (-ies → -y, -sses → -ss, -s → "") get wrong, +// keyed by plural. +export const irregularNouns: Record = { people: "person" }; + +// A trailing path segment whose first word is one of these is an action, and +// names the command (`/vendors/{vendorId}/set-status` → `vendors set-status`). +export const actionVerbs = [ + "acknowledge", + "add", + "approve", + "cancel", + "clear", + "complete", + "deactivate", + "deny", + "import", + "mark", + "notify", + "offboard", + "reactivate", + "remove", + "send", + "set", + "submit", + "upload", + "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 = { + // A single settings object, not a collection. + GetSettings: { name: "get" }, + // 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-controls-library": "List controls from the library", + "controls set-owner": "Set or clear a control owner", + "customer-trust": "Manage Customer Trust accounts, questionnaires, and tags", + "customer-trust add-tag-category-product-context": + "Enable a tag category for a product context", + "customer-trust remove-tag-category-product-context": + "Disable a tag category for a product context", + "customer-trust get-questionnaire-export": + "Get a questionnaire export's status by ID", + "customer-trust 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 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 remove-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 vendor-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.ts b/scripts/codegen/generate.ts new file mode 100644 index 0000000..df0615a --- /dev/null +++ b/scripts/codegen/generate.ts @@ -0,0 +1,360 @@ +/// +// Generates the API commands in src/commands/ from api-spec.json. +// +// pnpm generate:commands [--out ] +// +// The rules are in AGENTS.md, and each file here follows one part: +// +// spec.ts what the API has: one `Operation` per endpoint and method +// commands.ts which commands exist and what they're called ("Command structure") +// help.ts what each command's flags and help say ("Help text") +// config.ts the exceptions to those rules +// types.ts the command model and the shape of config.ts entries +// generate.ts this file: builds the groups, renders one TypeScript file +// per group plus index.ts, and formats them with oxfmt +import { execFileSync } from "node:child_process"; +import { + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { parseArgs } from "node:util"; +import { + camel, + checkUniqueNames, + commandName, + lowerFirst, + newGroup, + pascal, + place, +} from "./commands.js"; +import * as config from "./config.js"; +import { + applyHelpOverrides, + buildOptions, + commandDescription, +} from "./help.js"; +import { loadOperations } from "./spec.js"; +import type { Command, Group, Operation, Option } from "./types.js"; + +// =========================================================================== +// Groups +// =========================================================================== + +function buildGroups(operations: Operation[]): Group[] { + const groupNames = new Set(operations.map((op) => op.segments[0]!.name)); + // Groups like `customer-trust` have no endpoints of their own, so they don't + // own the resources under them in descriptions. + const groupsWithEndpoints = new Set( + operations + .filter((op) => op.segments.length === 1) + .map((op) => op.segments[0]!.name), + ); + const groups = new Map(); + for (const op of operations) { + const placement = place(op); + const name = placement.groupSegment.name; + if (!groups.has(name)) groups.set(name, newGroup(name)); + const group = groups.get(name)!; + const command = commandName(op, placement); + const queryNames = op.queryParams.map((q) => q.name); + group.commands.push({ + name: command, + description: commandDescription( + command, + op, + placement, + group, + groupsWithEndpoints.has(name), + ), + op, + options: buildOptions(op, placement, group, groupNames), + paginated: + queryNames.includes("pageSize") && queryNames.includes("pageCursor"), + download: op.returnsFile, + }); + } + return [...groups.values()]; +} + +// =========================================================================== +// Render +// =========================================================================== + +// The output is unformatted; oxfmt formats it after it's written. +const generatedHeader = + "// Code generated by `pnpm generate:commands`. DO NOT EDIT."; + +const quote = (s: string) => JSON.stringify(s); +const registerFunction = (g: Group) => `register${pascal(g.name)}Command`; +const groupVariable = (g: Group) => { + const name = camel(g.name); + return ["delete", "export", "import", "new"].includes(name) + ? `${name}Group` + : name; +}; + +// Names each generated file imports, collected while rendering. +type Imports = { + sdk: Set; + types: Set; + helpers: Set; + output: boolean; +}; + +// `vendors.command("get").description(...).requiredOption(...)`, wrapped in +// `addPaginationOptions` or `addJsonFileOptions` when it takes those flags. +function renderDefinition( + group: Group, + command: Command, + imports: Imports, +): string { + const chain = [ + `${groupVariable(group)}.command(${quote(command.name)})`, + `.description(${quote(command.description)})`, + ]; + for (const o of command.options) { + const args = [ + quote(`--${o.flag} <${o.placeholder}>`), + quote(o.description), + ]; + if (o.repeatable) { + imports.helpers.add("collectString"); + args.push("collectString", "[] as string[]"); + } else if (o.parseBool) { + imports.helpers.add("parseOptionalBoolString"); + args.push(`(value) => parseOptionalBoolString(value, ${quote(o.flag)})`); + } + chain.push( + `.${o.required ? "requiredOption" : "option"}(${args.join(", ")})`, + ); + } + if (command.download) { + chain.push( + `.option("--output ", "Write downloaded bytes to file path (default stdout)")`, + ); + } + let definition = chain.join(""); + if (command.paginated) { + imports.helpers.add("addPaginationOptions"); + definition = `addPaginationOptions(${definition})`; + } + if (command.op.body === "json") { + imports.helpers.add("addJsonFileOptions"); + definition = `addJsonFileOptions(${definition})`; + } + return definition; +} + +// The SDK call for a command, and statements that must run before it. +function renderSdkCall( + command: Command, + imports: Imports, +): { before: string[]; call: string } { + const { op, options } = command; + const value = (o: Option) => + o.target === "file" ? camel(o.flag) : `opts.${camel(o.flag)}`; + const entry = (o: Option) => + o.key === value(o) ? o.key : `${o.key}: ${value(o)}`; + const entries = (...targets: Option["target"][]) => + options.filter((o) => targets.includes(o.target)).map(entry); + const before: string[] = []; + const args = ["client: api.client"]; + + // Downloads can be any file type; without this the SDK parses text and JSON. + if (command.download) args.push(`parseAs: "blob"`); + + const path = entries("path"); + if (path.length) args.push(`path: { ${path.join(", ")} }`); + + const query = entries("query"); + if (command.paginated) { + imports.helpers.add("paginationQuery"); + query.unshift("...paginationQuery(opts)"); + } + if (query.length === 1 && command.paginated) + args.push("query: paginationQuery(opts)"); + else if (query.length) args.push(`query: { ${query.join(", ")} }`); + + if (op.body === "json") { + imports.helpers.add("readJSONPayload"); + imports.types.add(`${op.id}Data`); + before.push( + `const body = (await readJSONPayload(opts.json, opts.file)) as ${op.id}Data["body"];`, + ); + args.push("body"); + } else if (op.body === "multipart") { + imports.helpers.add("readBinaryFile"); + for (const o of options.filter((o) => o.target === "file")) { + before.push( + `const ${camel(o.flag)} = await readBinaryFile(opts.${camel(o.flag)});`, + ); + } + args.push(`body: { ${entries("form", "file").join(", ")} }`); + } + + const fn = lowerFirst(op.id); + imports.sdk.add(fn); + return { before, call: `${fn}({ ${args.join(", ")} })` }; +} + +// The options type for `.action(async (opts: { ... }) => ...)`, or undefined +// when there are no options or `addPaginationOptions` already types them. +function optionsType(command: Command, imports: Imports): string | undefined { + const { op } = command; + const field = (o: Option) => { + const optional = o.required || o.repeatable ? "" : "?"; + let type = o.repeatable ? "string[]" : o.parseBool ? "boolean" : "string"; + if (o.sdkTyped) { + imports.types.add(`${op.id}Data`); + type = + o.target === "path" + ? `${op.id}Data["path"]["${o.key}"]` + : `NonNullable<${op.id}Data["query"]>["${o.key}"]`; + if (o.repeatable) type = `NonNullable<${type}>`; + } + return `${camel(o.flag)}${optional}: ${type}`; + }; + const fields = [ + ...command.options.filter((o) => o.target === "path").map(field), + ...(command.paginated ? ["pageSize?: number", "pageCursor?: string"] : []), + ...command.options.filter((o) => o.target !== "path").map(field), + ...(op.body === "json" ? ["json?: string", "file?: string"] : []), + ...(command.download ? ["output?: string"] : []), + ]; + if (fields.every((f) => f.startsWith("page"))) return undefined; + return `{ ${fields.join("; ")} }`; +} + +function renderCommand( + group: Group, + command: Command, + imports: Imports, +): string { + const sdk = renderSdkCall(command, imports); + const statements = [...sdk.before]; + if (command.download) { + imports.helpers.add("withClient"); + imports.helpers.add("writeDownloadedMedia"); + imports.output = true; + statements.push(`await withClient(getFlags, async (api, flags) => { + const result = await ${sdk.call}; + if (result.error) throw result.error; + const dest = opts.output?.trim(); + await writeDownloadedMedia(result.data, dest); + if (dest) printResponse({ savedTo: dest }, flags); + });`); + } else { + imports.helpers.add("runSdk"); + statements.push(`await runSdk(getFlags, (api) => ${sdk.call});`); + } + const body = statements.join("\n"); + const type = optionsType(command, imports); + const params = !/\bopts\b/.test(body) ? "" : type ? `opts: ${type}` : "opts"; + const definition = renderDefinition(group, command, imports); + return `${definition}.action(async (${params}) => {\n${body}\n});`; +} + +// list, get, create, update, delete first; the rest in spec order. +const commandOrder = ["list", "get", "create", "update", "delete"]; +const rank = (c: Command) => { + const i = commandOrder.indexOf(c.name); + return i === -1 ? commandOrder.length : i; +}; + +function renderFile(group: Group): string { + const imports: Imports = { + sdk: new Set(), + types: new Set(), + helpers: new Set(), + output: false, + }; + const commands = [...group.commands] + .sort((x, y) => rank(x) - rank(y)) + .map((command) => renderCommand(group, command, imports)); + const list = (names: Iterable) => [...names].join(", "); + const importLines = [ + `import type { Command } from "commander";`, + imports.sdk.size && + `import { ${list(imports.sdk)} } from "../generated/sdk.gen.js";`, + imports.types.size && + `import type { ${list(imports.types)} } from "../generated/types.gen.js";`, + imports.output && `import { printResponse } from "../output.js";`, + `import { ${list([...imports.helpers].sort())}, type GetFlags } from "./helpers.js";`, + ]; + return `${generatedHeader} +${importLines.filter(Boolean).join("\n")} + +export function ${registerFunction(group)}(program: Command, getFlags: GetFlags): void { + const ${groupVariable(group)} = program.command(${quote(group.name)}).description(${quote(group.description)}); + + ${commands.join("\n\n")} +} +`; +} + +function renderIndex(groups: Group[]): string { + return `${generatedHeader} +import type { Command } from "commander"; +${groups.map((g) => `import { ${registerFunction(g)} } from "./${g.name}.js";`).join("\n")} +import type { GetFlags } from "./helpers.js"; + +export function registerApiCommands(program: Command, getFlags: GetFlags): void { + ${groups.map((g) => `${registerFunction(g)}(program, getFlags);`).join("\n")} +} +`; +} + +// File contents keyed by file name, with groups in `config.topLevelOrder`. +function renderFiles(groups: Group[]): Map { + const order = (g: Group) => { + const i = config.topLevelOrder.indexOf(g.name); + return i === -1 ? config.topLevelOrder.length : i; + }; + const sorted = [...groups].sort( + (x, y) => order(x) - order(y) || x.name.localeCompare(y.name), + ); + const files = new Map(sorted.map((g) => [`${g.name}.ts`, renderFile(g)])); + files.set("index.ts", renderIndex(sorted)); + return files; +} + +// =========================================================================== +// Main +// =========================================================================== + +const repoRoot = new URL("../../", import.meta.url).pathname; +const commandsDir = join(repoRoot, "src/commands"); +const { values: args } = parseArgs({ options: { out: { type: "string" } } }); +const outDir = args.out ?? commandsDir; + +const groups = buildGroups(loadOperations(join(repoRoot, "api-spec.json"))); +checkUniqueNames(groups); +applyHelpOverrides(groups); + +const files = renderFiles(groups); + +// Write the files, removing generated files for groups that no longer exist. +mkdirSync(outDir, { recursive: true }); +for (const name of readdirSync(outDir)) { + const path = join(outDir, name); + if ( + !files.has(name) && + name.endsWith(".ts") && + readFileSync(path, "utf8").startsWith(generatedHeader) + ) { + rmSync(path); + } +} +const paths = [...files.keys()].map((name) => join(outDir, name)); +for (const [name, content] of files) writeFileSync(join(outDir, name), content); +// Pass the config explicitly so `--out` elsewhere gets the same output. +execFileSync( + join(repoRoot, "node_modules/.bin/oxfmt"), + ["-c", join(repoRoot, ".oxfmtrc.json"), ...paths], + { stdio: ["ignore", "ignore", "inherit"] }, +); +console.error(`wrote ${files.size} files to ${outDir}`); diff --git a/scripts/codegen/help.ts b/scripts/codegen/help.ts new file mode 100644 index 0000000..908153d --- /dev/null +++ b/scripts/codegen/help.ts @@ -0,0 +1,500 @@ +// What each command's flags and help say: flags from the operation's inputs, +// placeholders, and descriptions from the spec and the path, then the +// overrides in `config.commandHelp` and `config.optionHelp`. See AGENTS.md +// "Help text". +import { + a, + displayPlural, + displaySingular, + humanize, + isPlural, + kebab, + lowerFirst, + nounFor, + singular, + upperFirst, +} from "./commands.js"; +import * as config from "./config.js"; +import { valueSchema } from "./spec.js"; +import type { + Group, + Input, + Operation, + Option, + OptionOverride, + Placement, + Schema, + Segment, +} from "./types.js"; + +// =========================================================================== +// Flags +// =========================================================================== + +// The noun a parameter refers to: `statusMatchesAny` → `status`. +function paramNoun(name: string): string { + const base = name.replace(/MatchesAny$/, "").replace(/Filter$/, ""); + return singular(kebab(base).split("-").at(-1)!); +} + +function placeholderFor(input: Input, groupNames: Set): string { + const { name, schema, description } = input; + const item = valueSchema(schema); + if (item.type === "boolean" || /^(is|has)[A-Z]/.test(name)) return "bool"; + if (item.format === "date") return "date"; + if ( + item.format === "date-time" || + (!item.enum && /(Date|After|Before)$/.test(name)) + ) { + return /ISO 8601 date\b/.test(description) ? "date" : "timestamp"; + } + if (item.enum) return paramNoun(name); + if (/\bJSON\b/.test(description)) return "json"; + if ( + /(Id|Ids|Identifier)$/.test(name.replace(/MatchesAny$/, "")) || + name === "id" || + /\bIDs?\b/.test(description) + ) { + return "id"; + } + if (item.type === "integer" || item.type === "number") return "n"; + const noun = paramNoun(name); + if (groupNames.has(`${noun}s`)) return "id"; + return config.placeholderNouns.includes(noun) ? noun : "text"; +} + +// First paragraph of a spec description, without "Possible values" lists. +function cleanDescription(text: string): string { + return text + .replace(/\s*Possible values:.*$/s, "") + .split(/\n\s*\n/)[0]! + .replace(/\s+/g, " ") + .trim() + .replace(/\.$/, ""); +} + +// The spec description, plus enum choices and format hints. +function inputDescription( + input: Input, + placeholder: string, + listed: string, +): string { + const { name, schema } = input; + const item = valueSchema(schema); + let text = cleanDescription(input.description); + if (!text && placeholder === "query") text = `Search ${listed}`; + if (!text) { + const subject = humanize(name.replace(/MatchesAny$/, "")); + text = `Filter by ${subject.replace(/\bid(s?)$/, "ID$1")}`; + } + text = upperFirst(text) + // "Boolean determining whether X" → "Whether X". + .replace(/^Boolean (determining|indicating) w/, "W") + // `("true" / "false")` → the standard "(true/false)", added below. + .replace(/\s*\("true" ?\/ ?"false"\)/, ""); + if (placeholder === "timestamp") { + // "in ISO 8601" and "(ISO 8601 string)" → "ISO 8601 timestamp". + text = text.replace( + /ISO 8601(?: string)?(?! (timestamp|date))/g, + "ISO 8601 timestamp", + ); + } + if (item.enum && !item.enum.every((v) => text.includes(v))) { + text += `: ${item.enum.join(", ")}`; + } + if (placeholder === "bool" && !text.includes("true/false")) + text += " (true/false)"; + if (placeholder === "timestamp" && !text.includes("ISO 8601")) { + text += " (ISO 8601 timestamp)"; + } + if (schema.type === "array") text += " (repeatable)"; + return text; +} + +function fileDescription(field: Input): string { + const text = cleanDescription(field.description); + if (text) return `Path to file to upload: ${lowerFirst(text)}`; + if (field.name === "file") return "Path to file to upload"; + return `Path to ${humanize(field.name)} file to upload`; +} + +// "FAQ ID" for `faqId` when the noun has a configured display name, else the +// humanized parameter name ("Security review ID"). +function pathParamDescription(name: string, placement: Placement): string { + const segment = placement.resources.find((s) => s.param === name); + const display = + segment && + (nounFor(segment.name, placement.groupSegment.name)?.singular ?? + (name === "id" ? displaySingular(segment.name) : undefined)); + if (display) return `${upperFirst(display)} ID`; + return upperFirst(humanize(name).replace(/ id$/, " ID")); +} + +// Whether the SDK types the value as something other than a plain `string` +// (or `string[]`): an enum, or a non-string type other than boolean/integer. +function isSdkTyped(schema: Schema): boolean { + const item = valueSchema(schema); + if (item.type === "string") return Boolean(item.enum); + return item.type !== "boolean" && item.type !== "integer"; +} + +// Overrides from `config.params` and `config.operations`, ignoring empty +// values; `extra` applies first. +function withOverrides( + option: Option, + opId: string, + extra?: OptionOverride, +): Option { + const overrides = [ + extra, + config.params[option.key], + config.operations[opId]?.options?.[option.key], + ]; + for (const o of overrides) { + Object.assign( + option, + Object.fromEntries(Object.entries(o ?? {}).filter(([, v]) => v)), + ); + } + return option; +} + +export function buildOptions( + op: Operation, + placement: Placement, + group: Group, + groupNames: Set, +): Option[] { + const placeholderOverride = (key: string) => + config.operations[op.id]?.options?.[key]?.placeholder ?? + config.params[key]?.placeholder; + const placeholder = (input: Input) => + placeholderOverride(input.name) ?? placeholderFor(input, groupNames); + // What a search flag searches: the listed resource, else the group. + const last = placement.resources.at(-1); + const listed = last + ? displayPlural(last.name, placement.groupSegment.name) + : group.plural; + + const path = op.pathParams.map((param) => { + // The group's own ID is `--id`; `resourceKind` stays `--resource-kind`. + const own = + param.name === placement.groupSegment.param && + /(^id|Id)$/.test(param.name); + const option: Option = { + flag: own ? "id" : kebab(param.name), + placeholder: own || /Id$/.test(param.name) ? "id" : kebab(param.name), + description: own + ? `${upperFirst(group.singular)} ID` + : pathParamDescription(param.name, placement), + required: true, + repeatable: false, + parseBool: false, + sdkTyped: isSdkTyped(param.schema), + target: "path", + key: param.name, + }; + return withOverrides( + option, + op.id, + own ? config.groups[group.name]?.id : undefined, + ); + }); + + const query = op.queryParams + .filter((param) => param.name !== "pageSize" && param.name !== "pageCursor") + .map((param) => { + const p = placeholder(param); + return withOverrides( + { + flag: kebab(param.name), + placeholder: p, + description: inputDescription(param, p, listed), + required: param.required, + repeatable: param.schema.type === "array", + parseBool: p === "bool" && param.schema.type === "boolean", + sdkTyped: isSdkTyped(param.schema), + target: "query", + key: param.name, + }, + op.id, + ); + }); + + const form = op.formFields.map((field) => { + const isFile = field.schema.format === "binary"; + const p = isFile ? "path" : placeholder(field); + return withOverrides( + { + flag: kebab(field.name), + placeholder: p, + description: isFile + ? fileDescription(field) + : inputDescription(field, p, listed), + required: field.required, + repeatable: false, + parseBool: false, + sdkTyped: false, + target: isFile ? "file" : "form", + key: field.name, + }, + op.id, + ); + }); + + return [...path, ...query, ...form]; +} + +// =========================================================================== +// Command descriptions +// =========================================================================== + +const prepositions = new Set(["as", "for", "from", "in", "to", "with"]); + +// The words a description is built from. For `vendors get-finding` +// (`/vendors/{vendorId}/findings/{findingId}`): the subject is "finding" / +// "findings", and its owner is "vendor" / "vendors". +type Words = { + op: Operation; + placement: Placement; + verb: string; + // The path ends in an ID: `/vendors/{vendorId}`. + atItem: boolean; + topGroup: string; + // The last resource in the path, if any. + subject?: { segment: Segment; one: string; many: string }; + // The group (when it owns its resources) and the resources between it and + // the subject. + ownerWords: string[]; + owner: string; + owners: string; + // The resource just above the subject, else the group. + parent: Segment; + // The subject's owner if it has one, else the subject, else the group. + one: string; + many: string; +}; + +function words( + name: string, + op: Operation, + placement: Placement, + group: Group, + ownsResources: boolean, +): Words { + const topGroup = placement.groupSegment.name; + const subjectSegment = placement.resources.at(-1); + const parents = placement.resources.slice(0, -1); + const parent = parents.at(-1) ?? placement.groupSegment; + const ownerWords = [ + ...(ownsResources ? [group.singular] : []), + ...parents.map((s) => displaySingular(s.name, topGroup)), + ]; + const owner = ownerWords.join(" "); + const owners = parents.length + ? [...ownerWords.slice(0, -1), displayPlural(parent.name, topGroup)].join( + " ", + ) + : group.plural; + const subject = subjectSegment && { + segment: subjectSegment, + one: + nounFor(subjectSegment.name, topGroup)?.commandSingular ?? + displaySingular(subjectSegment.name, topGroup), + many: displayPlural(subjectSegment.name, topGroup), + }; + return { + op, + placement, + verb: upperFirst(name.split("-")[0]!), + atItem: op.segments.at(-1)!.param !== undefined, + topGroup, + subject, + ownerWords, + owner, + owners, + parent, + one: subject && owner ? owner : (subject?.one ?? group.singular), + many: subject && owner ? owners : (subject?.many ?? group.plural), + }; +} + +export function commandDescription( + name: string, + op: Operation, + placement: Placement, + group: Group, + ownsResources: boolean, +): string { + const w = words(name, op, placement, group, ownsResources); + if (placement.action) return describeAction(w, placement.action); + // With no owner, the subject stands alone ("List questionnaires"). + if (!w.subject || !w.owner) return describeStandalone(w); + if (w.parent.param === undefined) + return describeUnderCollection(w, w.subject); + return describeUnderItem(w, w.subject); +} + +// Commands named by a path action: `vendors set-status`, `groups add-people`. +function describeAction(w: Words, action: string): string { + const { verb, subject, owner, one, many, op } = w; + // `add-document-to-control` under `controls` → "document". + const own = singular(w.placement.groupSegment.name); + const suffix = ["to", "from", "for"] + .map((preposition) => `-${preposition}-${own}`) + .find((s) => action.endsWith(s)); + const rest = humanize(suffix ? action.slice(0, -suffix.length) : action) + .split(" ") + .slice(1); + const target = subject && owner ? `${owner} ${subject.one}` : one; + // Actions on the collection, e.g. `people offboard`. + if (!op.segments.some((s) => s.param)) { + return rest.length === 0 ? `${verb} ${many}` : op.summary; + } + if (rest.length === 0) return `${verb} ${a(target)}`; + if (prepositions.has(rest[0]!)) + return `${verb} ${a(target)} ${rest.join(" ")}`; + // `add-people` → "Add people to a group". + const object = rest.join(" "); + const linked = isPlural(rest.at(-1)!) || rest.length > 1 ? object : a(object); + if (verb === "Add") return `Add ${linked} to ${a(target)}`; + if (verb === "Remove") return `Remove ${linked} from ${a(target)}`; + return `${verb} ${object} for ${a(target)}`; +} + +// The group itself, or a resource with no owner: "Get a vendor by ID". +function describeStandalone({ verb, atItem, one, many, op }: Words): string { + switch (verb) { + case "List": + return `List ${many}`; + case "Get": + return atItem ? `Get ${a(one)} by ID` : `Get ${many}`; + case "Create": + case "Upload": + return `${verb} ${a(one)}`; + case "Update": + return atItem ? `Update ${a(one)}` : `Update ${many}`; + case "Delete": + return `Delete ${a(one)}`; + case "Set": + return `Set ${many}`; + } + return op.summary; +} + +// Under a collection rather than an item: `controls list-deactivated`, +// `trust-centers add-control-tags`. +function describeUnderCollection( + w: Words, + subject: NonNullable, +): string { + const { verb, atItem, one, many, placement } = w; + if (verb === "Add") return `Add ${subject.many} to ${many}`; + if (verb === "Remove") return `Remove ${subject.many} from ${many}`; + if (subject.segment.name === "order") return `Reorder ${many}`; + if (verb === "List") { + // `deactivated-controls` under `controls` → "List deactivated controls". + const group = placement.groupSegment.name; + const words = humanize(subject.segment.name) + .split(" ") + .filter((word) => word !== group && word !== singular(group)); + return `List ${words.join(" ")} ${many}`; + } + // A resource of its own under the collection: "Create a questionnaire export". + if (isPlural(subject.segment.name)) { + return `${verb} ${a(`${one} ${subject.one}`)}${atItem ? " by ID" : ""}`; + } + return `${verb} ${a(`${subject.one} ${one}`)}`; +} + +// Under an item: "List findings for a vendor". +function describeUnderItem( + w: Words, + subject: NonNullable, +): string { + const { verb, atItem, one, op } = w; + if (op.returnsFile) return `Download the ${subject.one} for ${a(one)}`; + const resource = a(`${one} ${subject.one}`); + switch (verb) { + case "List": + return `List ${subject.many} for ${a(one)}`; + case "Get": + return `Get ${resource}${atItem ? " by ID" : ""}`; + case "Update": + return !atItem && isPlural(subject.segment.name) + ? `Update ${subject.many} for ${a(one)}` + : `Update ${resource}`; + case "Create": + case "Delete": + return `${verb} ${resource}`; + case "Add": + return `Add ${a(subject.one)} to ${a(one)}`; + case "Remove": + return `Remove ${a(subject.one)} from ${a(one)}`; + case "Upload": + return `Upload ${a(subject.one)} for ${a(one)}`; + case "Set": + return subject.segment.name === "order" + ? `Reorder ${displayPlural(w.parent.name, w.topGroup)} for ${a(w.ownerWords.slice(0, -1).join(" "))}` + : `Set ${subject.many} for ${a(one)}`; + } + return op.summary; +} + +// Replaces generated descriptions with `config.commandHelp` and +// `config.optionHelp`, and fails on entries that match nothing or repeat the +// default, so the overrides stay a list of deliberate wording. +export function applyHelpOverrides(groups: Group[]): void { + const problems: string[] = []; + const unusedCommands = new Set(Object.keys(config.commandHelp)); + const unusedOptions = new Set( + Object.entries(config.optionHelp).flatMap(([cmd, flags]) => + Object.keys(flags).map((flag) => `${cmd} ${flag}`), + ), + ); + const override = ( + key: string, + generated: string, + text: string | undefined, + unused: Set, + ) => { + if (text === undefined) return generated; + unused.delete(key); + if (text === generated) + problems.push(`help text for "${key}" repeats the default`); + return text; + }; + + for (const group of groups) { + group.description = override( + group.name, + group.description, + config.commandHelp[group.name], + unusedCommands, + ); + for (const command of group.commands) { + const key = `${group.name} ${command.name}`; + command.description = override( + key, + command.description, + config.commandHelp[key], + unusedCommands, + ); + for (const o of command.options) { + const flag = `--${o.flag}`; + o.description = override( + `${key} ${flag}`, + o.description, + config.optionHelp[key]?.[flag], + unusedOptions, + ); + } + } + } + for (const key of unusedCommands) + problems.push(`help text for unknown command "${key}"`); + for (const key of unusedOptions) + problems.push(`help text for unknown option "${key}"`); + if (problems.length) + throw new Error(`scripts/codegen/config.ts:\n ${problems.join("\n ")}`); +} diff --git a/scripts/codegen/spec.ts b/scripts/codegen/spec.ts new file mode 100644 index 0000000..c9bae8d --- /dev/null +++ b/scripts/codegen/spec.ts @@ -0,0 +1,118 @@ +// Reads api-spec.json into one `Operation` per API endpoint and method. +import { readFileSync } from "node:fs"; +import type { Input, Method, Operation, Schema, Segment } from "./types.js"; + +// The parts of the OpenAPI spec the generator reads. +type 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; +}; + +// The schema of one value: an array's items, else the schema itself. +export const valueSchema = (schema: Schema): Schema => + schema.type === "array" ? (schema.items ?? {}) : schema; + +// The spec with every `{ "$ref": "#/components/..." }` replaced by the object it +// points to, so the rest of the generator never sees references. Shared and +// recursive schemas stay shared. +function resolveRefs(root: unknown): unknown { + // `#/components/schemas/Vendor` → root.components.schemas.Vendor + const lookup = (ref: string) => + ref + .split("/") + .slice(1) + .reduce( + (node, key) => (node as Record)[key], + root, + ); + const resolved = new Map(); + const resolve = (node: unknown): unknown => { + if (node === null || typeof node !== "object") return node; + const ref = (node as { $ref?: unknown }).$ref; + if (typeof ref === "string") return resolve(lookup(ref)); + if (resolved.has(node)) return resolved.get(node); + if (Array.isArray(node)) { + const out: unknown[] = []; + resolved.set(node, out); + for (const value of node) out.push(resolve(value)); + return out; + } + const out: Record = {}; + resolved.set(node, out); + for (const [key, value] of Object.entries(node)) out[key] = resolve(value); + return out; + }; + return resolve(root); +} + +function toSegments(path: string): Segment[] { + const segments: Segment[] = []; + for (const part of path.split("/").filter(Boolean)) { + const param = /^\{(.+)\}$/.exec(part)?.[1]; + if (param) segments.at(-1)!.param = param; + else segments.push({ name: part }); + } + return segments; +} + +const toInput = (param: SpecParameter): Input => ({ + name: param.name, + required: Boolean(param.required), + description: param.description ?? "", + schema: param.schema ?? {}, +}); + +export function loadOperations(specPath: string): Operation[] { + const spec = resolveRefs(JSON.parse(readFileSync(specPath, "utf8"))) as Spec; + const ops: Operation[] = []; + for (const [path, item] of Object.entries(spec.paths)) { + for (const method of ["get", "post", "put", "patch", "delete"] as const) { + const op = item[method]; + if (!op) continue; + const params = op.parameters ?? []; + const body = op.requestBody; + const form = body?.content?.["multipart/form-data"]; + const formSchema: Schema = form?.schema ?? {}; + const ok = op.responses?.["200"] ?? op.responses?.["201"] ?? {}; + ops.push({ + id: op.operationId, + method, + path, + summary: op.summary ?? "", + segments: toSegments(path), + pathParams: params.filter((param) => param.in === "path").map(toInput), + queryParams: params + .filter((param) => param.in === "query") + .map(toInput), + body: form ? "multipart" : body ? "json" : "none", + formFields: Object.entries(formSchema.properties ?? {}).map( + ([name, schema]) => ({ + name, + required: (formSchema.required ?? []).includes(name), + description: schema.description ?? "", + schema, + }), + ), + returnsFile: Object.keys(ok.content ?? {}).some( + (t) => t !== "application/json", + ), + }); + } + } + return ops; +} diff --git a/scripts/codegen/types.ts b/scripts/codegen/types.ts new file mode 100644 index 0000000..c4234e4 --- /dev/null +++ b/scripts/codegen/types.ts @@ -0,0 +1,120 @@ +// Types shared across the generator: the command model it builds from the +// spec, and the shape of config.ts entries. + +// =========================================================================== +// The command model +// =========================================================================== + +export type Method = "get" | "post" | "put" | "patch" | "delete"; + +export type Schema = { + type?: string; + format?: string; + enum?: string[]; + items?: Schema; + description?: string; + properties?: Record; + required?: string[]; +}; + +// One API input: a path or query parameter, or a multipart form field. +export type Input = { + name: string; + required: boolean; + description: string; + schema: Schema; +}; + +// A literal path segment and the ID parameter that follows it, if any: +// `/vendors/{vendorId}` is `{ name: "vendors", param: "vendorId" }`. +export type Segment = { name: string; param?: string }; + +export type Operation = { + id: string; + method: Method; + path: string; + summary: string; + segments: Segment[]; + pathParams: Input[]; + queryParams: Input[]; + body: "none" | "json" | "multipart"; + formFields: Input[]; + returnsFile: boolean; +}; + +// Where an operation sits in the CLI and what its path is made of. +export type Placement = { + // The path's first segment, which names the group. + groupSegment: Segment; + // Segments after the group, minus a trailing action: they form the noun. + resources: Segment[]; + action?: string; +}; + +export type Option = { + flag: string; + placeholder: string; + description: string; + required: boolean; + repeatable: boolean; + // Parse "true"/"false" into a boolean. + parseBool: boolean; + // The SDK types the value more narrowly than `string`, e.g. as an enum. + sdkTyped: boolean; + // Where the value goes in the SDK call, under `key`. + target: "path" | "query" | "form" | "file"; + key: string; +}; + +export type Command = { + name: string; + description: string; + op: Operation; + options: Option[]; + paginated: boolean; + download: boolean; +}; + +// A group is named after its path segment, which also names its file. +export type Group = { + name: string; + description: string; + singular: string; + plural: string; + commands: Command[]; +}; + +// =========================================================================== +// config.ts entries +// =========================================================================== + +export type Noun = { + // Display name for one item, e.g. "Trust Center FAQ". + singular?: string; + // Display name for the collection, e.g. "Trust Center FAQs". + plural?: string; + // Name used in verb-noun commands when it differs from the singular path + // segment, e.g. "file" for `uploads` (`documents upload-file`). + commandSingular?: string; + // Name used in `list` and bulk commands when it differs from the path + // segment, e.g. "files" for `uploads` (`documents list-files`). + commandPlural?: string; +}; + +export type OptionOverride = { + flag?: string; + placeholder?: string; + description?: string; +}; + +export type OperationOverride = { + // Command name, replacing the derived verb or verb-noun. + name?: string; + // Keyed by API parameter or multipart field name. + options?: Record; +}; + +export type GroupOverride = { + // Overrides the group's own `--id ` flag. + id?: OptionOverride; +}; diff --git a/src/commands/business-units.ts b/src/commands/business-units.ts index 9c24b25..5f40b89 100644 --- a/src/commands/business-units.ts +++ b/src/commands/business-units.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listBusinessUnits, getBusinessUnit } from "../generated/sdk.gen.js"; import { diff --git a/src/commands/contracts.ts b/src/commands/contracts.ts index 1056db6..f893006 100644 --- a/src/commands/contracts.ts +++ b/src/commands/contracts.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listContracts, diff --git a/src/commands/controls.ts b/src/commands/controls.ts index c492d90..a01d27a 100644 --- a/src/commands/controls.ts +++ b/src/commands/controls.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listControls, diff --git a/src/commands/customer-trust.ts b/src/commands/customer-trust.ts index 1b36f6c..920d213 100644 --- a/src/commands/customer-trust.ts +++ b/src/commands/customer-trust.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listCustomerTrustAccounts, diff --git a/src/commands/discovered-vendors.ts b/src/commands/discovered-vendors.ts index 17819ac..8a149a1 100644 --- a/src/commands/discovered-vendors.ts +++ b/src/commands/discovered-vendors.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listDiscoveredVendors, diff --git a/src/commands/documents.ts b/src/commands/documents.ts index 59fbf9c..fdd41a7 100644 --- a/src/commands/documents.ts +++ b/src/commands/documents.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listDocuments, diff --git a/src/commands/event-logs.ts b/src/commands/event-logs.ts index e79f7b3..29b7a98 100644 --- a/src/commands/event-logs.ts +++ b/src/commands/event-logs.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listEventLogs } from "../generated/sdk.gen.js"; import { diff --git a/src/commands/frameworks.ts b/src/commands/frameworks.ts index d8e9c97..6d9feb3 100644 --- a/src/commands/frameworks.ts +++ b/src/commands/frameworks.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listFrameworks, diff --git a/src/commands/groups.ts b/src/commands/groups.ts index 42bfa51..2cfefbd 100644 --- a/src/commands/groups.ts +++ b/src/commands/groups.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listPersonGroups, diff --git a/src/commands/index.ts b/src/commands/index.ts index cb1abce..47eedbd 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { registerControlsCommand } from "./controls.js"; import { registerPoliciesCommand } from "./policies.js"; diff --git a/src/commands/integrations.ts b/src/commands/integrations.ts index a89c123..4c092ba 100644 --- a/src/commands/integrations.ts +++ b/src/commands/integrations.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listConnectedIntegrations, diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 437d6b7..731b707 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { list, getIssue } from "../generated/sdk.gen.js"; import type { ListData } from "../generated/types.gen.js"; diff --git a/src/commands/knowledge-base.ts b/src/commands/knowledge-base.ts index 735ede9..8bde5d3 100644 --- a/src/commands/knowledge-base.ts +++ b/src/commands/knowledge-base.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listAnswerLibraryEntries, diff --git a/src/commands/monitored-computers.ts b/src/commands/monitored-computers.ts index c700576..15bdb41 100644 --- a/src/commands/monitored-computers.ts +++ b/src/commands/monitored-computers.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listMonitoredComputers, diff --git a/src/commands/people.ts b/src/commands/people.ts index 2228d93..2b2b918 100644 --- a/src/commands/people.ts +++ b/src/commands/people.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listPeople, diff --git a/src/commands/personnel-notification-settings.ts b/src/commands/personnel-notification-settings.ts index 0f06188..f6cae5f 100644 --- a/src/commands/personnel-notification-settings.ts +++ b/src/commands/personnel-notification-settings.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { getSettings, updateSettings } from "../generated/sdk.gen.js"; import type { UpdateSettingsData } from "../generated/types.gen.js"; diff --git a/src/commands/policies.ts b/src/commands/policies.ts index d04d01c..c777a67 100644 --- a/src/commands/policies.ts +++ b/src/commands/policies.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listPolicies, getPolicy } from "../generated/sdk.gen.js"; import { diff --git a/src/commands/program-scopes.ts b/src/commands/program-scopes.ts index bfd5235..70fed93 100644 --- a/src/commands/program-scopes.ts +++ b/src/commands/program-scopes.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listProgramScopes, getProgramScope } from "../generated/sdk.gen.js"; import { diff --git a/src/commands/risk-scenarios.ts b/src/commands/risk-scenarios.ts index b5dfaf3..5135594 100644 --- a/src/commands/risk-scenarios.ts +++ b/src/commands/risk-scenarios.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listRiskScenario, diff --git a/src/commands/tests.ts b/src/commands/tests.ts index b3c3d84..72a6f7d 100644 --- a/src/commands/tests.ts +++ b/src/commands/tests.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listTests, diff --git a/src/commands/trust-centers.ts b/src/commands/trust-centers.ts index b14db7e..c97c60b 100644 --- a/src/commands/trust-centers.ts +++ b/src/commands/trust-centers.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { getTrustCenter, @@ -1143,6 +1144,7 @@ export function registerTrustCentersCommand( await withClient(getFlags, async (api, flags) => { const result = await getTrustCenterResourceMedia({ client: api.client, + parseAs: "blob", path: { slugId: opts.slugId, resourceId: opts.resourceId }, }); if (result.error) throw result.error; diff --git a/src/commands/users.ts b/src/commands/users.ts index 75c06a4..2b020de 100644 --- a/src/commands/users.ts +++ b/src/commands/users.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listUsers, getUser } from "../generated/sdk.gen.js"; import { diff --git a/src/commands/vendor-assessment-types.ts b/src/commands/vendor-assessment-types.ts index 887cdcb..5c48eae 100644 --- a/src/commands/vendor-assessment-types.ts +++ b/src/commands/vendor-assessment-types.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listVendorAssessmentTypes, diff --git a/src/commands/vendor-risk-attributes.ts b/src/commands/vendor-risk-attributes.ts index 1266879..3c758fc 100644 --- a/src/commands/vendor-risk-attributes.ts +++ b/src/commands/vendor-risk-attributes.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listVendorRiskAttributes } from "../generated/sdk.gen.js"; import { diff --git a/src/commands/vendors.ts b/src/commands/vendors.ts index 676da1c..eb14006 100644 --- a/src/commands/vendors.ts +++ b/src/commands/vendors.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listVendors, diff --git a/src/commands/vulnerabilities.ts b/src/commands/vulnerabilities.ts index ed15e1a..0a370c6 100644 --- a/src/commands/vulnerabilities.ts +++ b/src/commands/vulnerabilities.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listVulnerabilities, diff --git a/src/commands/vulnerability-remediations.ts b/src/commands/vulnerability-remediations.ts index 971f114..11fff4a 100644 --- a/src/commands/vulnerability-remediations.ts +++ b/src/commands/vulnerability-remediations.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listVulnerabilityRemediations, diff --git a/src/commands/vulnerable-assets.ts b/src/commands/vulnerable-assets.ts index 496b3e2..5685164 100644 --- a/src/commands/vulnerable-assets.ts +++ b/src/commands/vulnerable-assets.ts @@ -1,3 +1,4 @@ +// Code generated by `pnpm generate:commands`. DO NOT EDIT. import type { Command } from "commander"; import { listVulnerableAssets,