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..0673d04 --- /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; + // 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; +}; + +// 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" }, +}; + +// 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-commands.ts b/scripts/codegen/generate-commands.ts new file mode 100644 index 0000000..dc0f564 --- /dev/null +++ b/scripts/codegen/generate-commands.ts @@ -0,0 +1,1005 @@ +/// +// Generates the API commands in src/commands/ from api-spec.json. +// +// tsx scripts/codegen/generate-commands.ts [--out ] +// +// AGENTS.md ("Command structure") describes the naming rules; ./config.ts holds the +// exceptions and help text overrides. The script runs in stages: +// +// 1. Spec read each API operation into an `Operation` +// 2. Naming place it in a group and name it (`vendors list-findings`) +// 3. Options turn its parameters and body into flags +// 4. Help text describe the command +// 5. Groups collect commands into groups, check names, apply overrides +// 6. Render write one TypeScript file per group, plus index.ts +import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import * as config from "./config.js"; + +const repoRoot = new URL("../../", import.meta.url).pathname; +const generatedHeader = "// Code generated by scripts/codegen/generate-commands.ts. DO NOT EDIT."; + +// =========================================================================== +// 1. Spec +// =========================================================================== + +// The parts of the OpenAPI spec the generator reads. +type Method = "get" | "post" | "put" | "patch" | "delete"; + +type Spec = { + paths: Record>>; +}; + +type SpecOperation = { + operationId: string; + summary?: string; + parameters?: SpecParameter[]; + requestBody?: { content?: Record }; + responses?: Record }>; +}; + +type SpecParameter = { + name: string; + in: "path" | "query" | "header" | "cookie"; + required?: boolean; + description?: string; + schema?: Schema; +}; + +type Schema = { + type?: string; + format?: string; + enum?: string[]; + items?: Schema; + description?: string; + properties?: Record; + required?: string[]; +}; + +// One API input: a path or query parameter, or a multipart form field. +type Input = { + name: string; + required: boolean; + description: string; + schema: Schema; +}; + +// A literal path segment and the ID parameter that follows it, if any: +// `/vendors/{vendorId}` is `{ name: "vendors", param: "vendorId" }`. +type Segment = { name: string; param?: string }; + +type Operation = { + id: string; + method: Method; + path: string; + summary: string; + segments: Segment[]; + pathParams: Input[]; + queryParams: Input[]; + body: "none" | "json" | "multipart"; + formFields: Input[]; + returnsFile: boolean; +}; + +// The spec with every `{ "$ref": "#/components/..." }` replaced by the object it +// points to, so the rest of the script never sees references. Shared and +// recursive schemas stay shared. +function resolveRefs(root: unknown): unknown { + // `#/components/schemas/Vendor` → root.components.schemas.Vendor + const lookup = (ref: string) => + ref + .split("/") + .slice(1) + .reduce((node, key) => (node as Record)[key], root); + const resolved = new Map(); + const resolve = (node: unknown): unknown => { + if (node === null || typeof node !== "object") return node; + const ref = (node as { $ref?: unknown }).$ref; + if (typeof ref === "string") return resolve(lookup(ref)); + if (resolved.has(node)) return resolved.get(node); + if (Array.isArray(node)) { + const out: unknown[] = []; + resolved.set(node, out); + for (const value of node) out.push(resolve(value)); + return out; + } + const out: Record = {}; + resolved.set(node, out); + for (const [key, value] of Object.entries(node)) out[key] = resolve(value); + return out; + }; + return resolve(root); +} + +const spec = resolveRefs(JSON.parse(readFileSync(join(repoRoot, "api-spec.json"), "utf8"))) as Spec; + +function toSegments(path: string): Segment[] { + const segments: Segment[] = []; + for (const part of path.split("/").filter(Boolean)) { + const param = /^\{(.+)\}$/.exec(part)?.[1]; + if (param) segments.at(-1)!.param = param; + else segments.push({ name: part }); + } + return segments; +} + +function loadOperations(): Operation[] { + const ops: Operation[] = []; + for (const [path, item] of Object.entries(spec.paths)) { + for (const method of ["get", "post", "put", "patch", "delete"] as const) { + const op = item[method]; + if (!op) continue; + const params = op.parameters ?? []; + const toInput = (param: SpecParameter): Input => ({ + name: param.name, + required: Boolean(param.required), + description: param.description ?? "", + schema: param.schema ?? {}, + }); + const body = op.requestBody; + const form = body?.content?.["multipart/form-data"]; + const formSchema: Schema = form?.schema ?? {}; + const ok = op.responses?.["200"] ?? op.responses?.["201"] ?? {}; + ops.push({ + id: op.operationId, + method, + path, + summary: op.summary ?? "", + segments: toSegments(path), + pathParams: params.filter((param) => param.in === "path").map(toInput), + queryParams: params.filter((param) => param.in === "query").map(toInput), + body: form ? "multipart" : body ? "json" : "none", + formFields: Object.entries(formSchema.properties ?? {}).map(([name, schema]) => ({ + name, + required: (formSchema.required ?? []).includes(name), + description: schema.description ?? "", + schema, + })), + returnsFile: Object.keys(ok.content ?? {}).some((t) => t !== "application/json"), + }); + } + } + return ops; +} + +const operations = loadOperations(); + +// =========================================================================== +// Words +// =========================================================================== + +const kebab = (s: string) => s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); +const camel = (s: string) => s.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()); +const pascal = (s: string) => camel(s).replace(/^./, (c) => c.toUpperCase()); +const lowerFirst = (s: string) => s.replace(/^./, (c) => c.toLowerCase()); +const upperFirst = (s: string) => s.replace(/^./, (c) => c.toUpperCase()); +const humanize = (s: string) => kebab(s).replace(/-/g, " "); +const article = (s: string) => (/^[aeiou]/i.test(s) && !/^(us|uni|eu)/i.test(s) ? "an" : "a"); +const a = (noun: string) => `${article(noun)} ${noun}`; + +function singularize(word: string): string { + if (word === "people") return "person"; + if (word.endsWith("ies")) return `${word.slice(0, -3)}y`; + if (word.endsWith("sses")) return word.slice(0, -2); + if (word.endsWith("s") && !/(ss|us)$/.test(word)) return word.slice(0, -1); + return word; +} + +// Singularizes the last word of a kebab-case segment: `security-reviews`. +function singular(segment: string): string { + const words = segment.split("-"); + words.push(singularize(words.pop()!)); + return words.join("-"); +} + +const isPlural = (segment: string) => singular(segment) !== segment; + +// The schema of one value: an array's items, else the schema itself. +const valueSchema = (schema: Schema): Schema => (schema.type === "array" ? (schema.items ?? {}) : schema); + +// Display names from `config.nouns`, keyed by " " or by +// segment, else from the segment itself. +const nounFor = (segment: string, group?: string) => + (group && config.nouns[`${group} ${segment}`]) || config.nouns[segment]; +const displaySingular = (segment: string, group?: string) => + nounFor(segment, group)?.singular ?? humanize(singular(segment)); +const displayPlural = (segment: string, group?: string) => + nounFor(segment, group)?.plural ?? humanize(segment); + +// =========================================================================== +// 2. Naming +// =========================================================================== + +// Where an operation sits in the CLI and what its path is made of. +type Placement = { + // The path's first segment, which names the group. + groupSegment: Segment; + // Segments after the group, minus a trailing action: they form the noun. + resources: Segment[]; + action?: string; +}; + +function place(op: Operation): Placement { + const [groupSegment, ...rest] = op.segments as [Segment, ...Segment[]]; + const last = rest.at(-1); + const isAction = + last && !last.param && config.actionVerbs.includes(last.name.split("-")[0]!); + return { + groupSegment, + resources: isAction ? rest.slice(0, -1) : rest, + action: isAction ? last.name : undefined, + }; +} + +// `-`; see AGENTS.md "Command structure". +function commandName(op: Operation, placement: Placement): string { + const atItem = op.segments.at(-1)!.param !== undefined; + let verb: string; + let rest: string[] = []; + // Operations on a whole collection keep the last noun plural. + let plural = !atItem; + const link = config.links.includes(op.id); + if (placement.action) { + [verb, ...rest] = placement.action.split("-") as [string, ...string[]]; + plural = false; + } else if (op.method === "get") { + verb = op.returnsFile ? "download" : atItem ? "get" : "list"; + plural = verb === "list"; + } else if (op.method === "post") { + verb = link ? "add" : op.body === "multipart" ? "upload" : "create"; + // `create` and `add` make one item, unless the API adds many at once. + plural = op.id.startsWith("Bulk"); + } else if (op.method === "delete" && link) { + verb = "remove"; + } else { + verb = { patch: "update", put: "set", delete: "delete" }[op.method]; + } + const noun = placement.resources.map((s, i) => + plural && i === placement.resources.length - 1 + ? (config.nouns[s.name]?.commandPlural ?? s.name) + : (config.nouns[s.name]?.commandSingular ?? singular(s.name)), + ); + return [verb, ...noun, ...rest].join("-"); +} + +// =========================================================================== +// 3. Options +// =========================================================================== + +type Option = { + flag: string; + placeholder: string; + description: string; + required: boolean; + repeatable: boolean; + // Parse "true"/"false" into a boolean. + parseBool: boolean; + // Takes one of the spec's enum values, typed by the SDK. + isEnum: boolean; + // Where the value goes in the SDK call, under `key`. + target: "path" | "query" | "form" | "file"; + key: string; +}; + +const topLevelGroups = new Set(operations.map((op) => op.segments[0]!.name)); + +const searchParams = new Set(["q", "search", "searchString"]); +const placeholderWords = new Set([ + "category", + "direction", + "group", + "kind", + "name", + "owner", + "permission", + "reason", + "role", + "scope", + "severity", + "slug", + "source", + "status", + "template", + "title", + "type", + "url", + "visibility", +]); + +// The noun a parameter refers to: `statusMatchesAny` → `status`. +function paramNoun(name: string): string { + const base = name.replace(/(MatchesAny|Filter)$/, "").replace(/Filter$/, ""); + return singularize(kebab(base).split("-").at(-1)!); +} + +// See AGENTS.md "Help text" for the placeholder vocabulary. +function placeholderFor(input: Input): string { + const { name, schema, description } = input; + const item = valueSchema(schema); + if (item.type === "boolean" || /^(is|has)[A-Z]/.test(name)) return "bool"; + if (item.format === "date") return "date"; + if (item.format === "date-time" || (!item.enum && /(Date|After|Before)$/.test(name))) { + return /ISO 8601 date\b/.test(description) ? "date" : "timestamp"; + } + if (searchParams.has(name)) return "query"; + if (name === "orderBy") return "field"; + if (item.enum) return paramNoun(name); + if (/\bJSON\b/.test(description)) return "json"; + if ( + /(Id|Ids|Identifier)$/.test(name.replace(/MatchesAny$/, "")) || + name === "id" || + /\bIDs?\b/.test(description) + ) { + return "id"; + } + if (item.type === "integer" || item.type === "number") return "n"; + const noun = paramNoun(name); + if (topLevelGroups.has(`${noun}s`)) return "id"; + return placeholderWords.has(noun) ? noun : "text"; +} + +// First paragraph of a spec description, without "Possible values" lists. +function cleanDescription(text: string): string { + return text + .replace(/\s*Possible values:.*$/s, "") + .split(/\n\s*\n/)[0]! + .replace(/\s+/g, " ") + .trim() + .replace(/\.$/, ""); +} + +// The spec description, plus enum choices and format hints. +function inputDescription(input: Input, placeholder: string, listed: string): string { + const { name, schema } = input; + const item = valueSchema(schema); + let text = cleanDescription(input.description); + if (!text && placeholder === "query") text = `Search ${listed}`; + if (!text) { + const subject = humanize(name.replace(/MatchesAny$/, "")); + text = `Filter by ${subject.replace(/\bid(s?)$/, "ID$1")}`; + } + text = upperFirst(text) + // "Boolean determining whether X" → "Whether X". + .replace(/^Boolean (determining|indicating) w/, "W") + // `("true" / "false")` → the standard "(true/false)", added below. + .replace(/\s*\("true" ?\/ ?"false"\)/, ""); + if (placeholder === "timestamp") { + // "in ISO 8601" and "(ISO 8601 string)" → "ISO 8601 timestamp". + text = text.replace(/ISO 8601(?: string)?(?! (timestamp|date))/g, "ISO 8601 timestamp"); + } + if (item.enum && !item.enum.every((v) => text.includes(v))) { + text += `: ${item.enum.join(", ")}`; + } + if (placeholder === "bool" && !text.includes("true/false")) text += " (true/false)"; + if (placeholder === "timestamp" && !text.includes("ISO 8601")) { + text += " (ISO 8601 timestamp)"; + } + if (schema.type === "array") text += " (repeatable)"; + return text; +} + +// "FAQ ID" for `faqId` when the noun has a configured display name, else the +// humanized parameter name ("Security review ID"). +function pathParamDescription(name: string, placement: Placement): string { + const segment = placement.resources.find((s) => s.param === name); + const display = + segment && + (nounFor(segment.name, placement.groupSegment.name)?.singular ?? + (name === "id" ? displaySingular(segment.name) : undefined)); + if (display) return `${upperFirst(display)} ID`; + return upperFirst(humanize(name).replace(/ id$/, " ID")); +} + +// Whether the SDK types the value as plain `string` (or `string[]`), rather +// than an enum or union. +function isPlainString(schema: Schema): boolean { + const item = valueSchema(schema); + return item.type !== "string" ? item.type === "boolean" || item.type === "integer" : !item.enum; +} + +function buildOptions(op: Operation, placement: Placement, group: Group): Option[] { + const overrides = config.operations[op.id]?.options ?? {}; + const placeholderOverride = (key: string) => + overrides[key]?.placeholder ?? config.params[key]?.placeholder; + // What a search flag searches: the listed resource, else the group. + const last = placement.resources.at(-1); + const listed = last ? displayPlural(last.name, placement.groupSegment.name) : group.plural; + const withOverrides = (option: Option, ...extra: (config.OptionOverride | undefined)[]) => { + for (const o of [...extra, config.params[option.key], overrides[option.key]]) { + Object.assign(option, Object.fromEntries(Object.entries(o ?? {}).filter(([, v]) => v))); + } + return option; + }; + const options: Option[] = []; + + for (const param of op.pathParams) { + // The group's own ID is `--id`; `resourceKind` stays `--resource-kind`. + const own = param.name === placement.groupSegment.param && /(^id|Id)$/.test(param.name); + const option: Option = { + flag: own ? "id" : kebab(param.name), + placeholder: own || /Id$/.test(param.name) ? "id" : kebab(param.name), + description: own + ? `${upperFirst(group.singular)} ID` + : pathParamDescription(param.name, placement), + required: true, + repeatable: false, + parseBool: false, + isEnum: !isPlainString(param.schema), + target: "path", + key: param.name, + }; + options.push(withOverrides(option, own ? config.groups[group.name]?.id : undefined)); + } + + for (const param of op.queryParams) { + if (param.name === "pageSize" || param.name === "pageCursor") continue; + const placeholder = placeholderOverride(param.name) ?? placeholderFor(param); + options.push( + withOverrides({ + flag: kebab(param.name), + placeholder, + description: inputDescription(param, placeholder, listed), + required: param.required, + repeatable: param.schema.type === "array", + parseBool: placeholder === "bool" && param.schema.type === "boolean", + isEnum: !isPlainString(param.schema), + target: "query", + key: param.name, + }), + ); + } + + for (const field of op.formFields) { + const isFile = field.schema.format === "binary"; + const placeholder = isFile ? "path" : (placeholderOverride(field.name) ?? placeholderFor(field)); + const fileDescription = cleanDescription(field.description); + options.push( + withOverrides({ + flag: kebab(field.name), + placeholder, + description: !isFile + ? inputDescription(field, placeholder, listed) + : fileDescription + ? `Path to file to upload: ${lowerFirst(fileDescription)}` + : field.name === "file" + ? "Path to file to upload" + : `Path to ${humanize(field.name)} file to upload`, + required: field.required, + repeatable: false, + parseBool: false, + isEnum: false, + target: isFile ? "file" : "form", + key: field.name, + }), + ); + } + + return options; +} + +// =========================================================================== +// 4. Help text +// =========================================================================== + +// Groups like `customer-trust` have no endpoints of their own, so they don't +// own the resources under them in descriptions. +const groupsWithEndpoints = new Set( + operations.filter((op) => op.segments.length === 1).map((op) => op.segments[0]!.name), +); + +const prepositions = new Set(["as", "for", "from", "in", "to", "with"]); + +// Describes a command from its verb, its subject (the last resource), and the +// subject's owner (the group and any resources in between). +function commandDescription(name: string, op: Operation, placement: Placement, group: Group): string { + const atItem = op.segments.at(-1)!.param !== undefined; + const verb = upperFirst(name.split("-")[0]!); + const topGroup = placement.groupSegment.name; + const subjectSegment = placement.resources.at(-1); + const parents = placement.resources.slice(0, -1); + const parentSegment = parents.at(-1) ?? placement.groupSegment; + + const ownsResources = groupsWithEndpoints.has(placement.groupSegment.name); + const ownerWords = [ + ...(ownsResources ? [group.singular] : []), + ...parents.map((s) => displaySingular(s.name, topGroup)), + ]; + const owner = ownerWords.join(" "); + const owners = parents.length + ? [...ownerWords.slice(0, -1), displayPlural(parentSegment.name, topGroup)].join(" ") + : group.plural; + const subject = subjectSegment && { + one: + nounFor(subjectSegment.name, topGroup)?.commandSingular ?? + displaySingular(subjectSegment.name, topGroup), + many: displayPlural(subjectSegment.name, topGroup), + }; + // With no owner, the subject stands alone ("List questionnaires"). + const one = subject && owner ? owner : (subject?.one ?? group.singular); + const many = subject && owner ? owners : (subject?.many ?? group.plural); + + if (placement.action) { + // `add-document-to-control` under `controls` → "document". + const own = singular(placement.groupSegment.name); + const suffix = ["to", "from", "for"] + .map((preposition) => `-${preposition}-${own}`) + .find((s) => placement.action!.endsWith(s)); + const action = suffix ? placement.action.slice(0, -suffix.length) : placement.action; + const rest = humanize(action).split(" ").slice(1); + const target = subject && owner ? `${owner} ${subject.one}` : one; + // Actions on the collection, e.g. `people offboard`. + if (!op.segments.some((s) => s.param)) { + return rest.length === 0 ? `${verb} ${many}` : op.summary; + } + if (rest.length === 0) return `${verb} ${a(target)}`; + if (prepositions.has(rest[0]!)) return `${verb} ${a(target)} ${rest.join(" ")}`; + // `add-people` → "Add people to a group". + const object = rest.join(" "); + const linked = isPlural(rest.at(-1)!) || rest.length > 1 ? object : a(object); + if (verb === "Add") return `Add ${linked} to ${a(target)}`; + if (verb === "Remove") return `Remove ${linked} from ${a(target)}`; + return `${verb} ${object} for ${a(target)}`; + } + + if (!subject || !owner) { + switch (verb) { + case "List": + return `List ${many}`; + case "Get": + return atItem ? `Get ${a(one)} by ID` : `Get ${many}`; + case "Create": + case "Upload": + return `${verb} ${a(one)}`; + case "Update": + return atItem ? `Update ${a(one)}` : `Update ${many}`; + case "Delete": + return `Delete ${a(one)}`; + case "Set": + return `Set ${many}`; + } + return op.summary; + } + + // Under a collection rather than an item: `controls list-deactivated`, + // `trust-centers add-control-tags`. + if (parentSegment.param === undefined) { + if (verb === "Add") return `Add ${subject.many} to ${many}`; + if (verb === "Remove") return `Remove ${subject.many} from ${many}`; + if (subjectSegment.name === "order") return `Reorder ${many}`; + if (verb === "List") { + // `deactivated-controls` under `controls` → "List deactivated controls". + const words = humanize(subjectSegment.name) + .split(" ") + .filter((w) => w !== placement.groupSegment.name && w !== singular(placement.groupSegment.name)); + return `List ${words.join(" ")} ${many}`; + } + // A resource of its own under the collection: "Create a questionnaire export". + if (isPlural(subjectSegment.name)) { + return `${verb} ${a(`${one} ${subject.one}`)}${atItem ? " by ID" : ""}`; + } + return `${verb} ${a(`${subject.one} ${one}`)}`; + } + + if (op.returnsFile) return `Download the ${subject.one} for ${a(one)}`; + const resource = a(`${one} ${subject.one}`); + switch (verb) { + case "List": + return `List ${subject.many} for ${a(one)}`; + case "Get": + return `Get ${resource}${atItem ? " by ID" : ""}`; + case "Update": + return !atItem && isPlural(subjectSegment.name) + ? `Update ${subject.many} for ${a(one)}` + : `Update ${resource}`; + case "Create": + case "Delete": + return `${verb} ${resource}`; + case "Add": + return `Add ${a(subject.one)} to ${a(one)}`; + case "Remove": + return `Remove ${a(subject.one)} from ${a(one)}`; + case "Upload": + return `Upload ${a(subject.one)} for ${a(one)}`; + case "Set": + return subjectSegment.name === "order" + ? `Reorder ${displayPlural(parentSegment.name, topGroup)} for ${a(ownerWords.slice(0, -1).join(" "))}` + : `Set ${subject.many} for ${a(one)}`; + } + return op.summary; +} + +// =========================================================================== +// 5. Groups +// =========================================================================== + +type Command = { + name: string; + description: string; + op: Operation; + options: Option[]; + paginated: boolean; + download: boolean; +}; + +// A group is named after its path segment, which also names its file. +type Group = { + name: string; + description: string; + singular: string; + plural: string; + commands: Command[]; +}; + +function newGroup(name: string): Group { + const noun = config.nouns[name]; + const plural = noun?.plural ?? displayPlural(name); + return { + name, + description: `Manage ${plural}`, + singular: noun?.singular ?? displaySingular(name), + plural, + commands: [], + }; +} + +// Every group, keyed by name. +function buildGroups(): Map { + const groups = new Map(); + for (const op of operations) { + const placement = place(op); + const name = placement.groupSegment.name; + if (!groups.has(name)) groups.set(name, newGroup(name)); + const group = groups.get(name)!; + const command = config.operations[op.id]?.name ?? commandName(op, placement); + const queryNames = op.queryParams.map((q) => q.name); + group.commands.push({ + name: command, + description: commandDescription(command, op, placement, group), + op, + options: buildOptions(op, placement, group), + paginated: queryNames.includes("pageSize") && queryNames.includes("pageCursor"), + download: op.returnsFile, + }); + } + return groups; +} + +function checkUniqueNames(groups: Group[]): void { + for (const group of groups) { + const seen = new Map(); + for (const { name, op } of group.commands) { + const path = `${group.name} ${name}`; + if (seen.has(name)) { + throw new Error( + `duplicate command "${path}" for ${seen.get(name)} and ${op.id}; add a name override in scripts/codegen/config.ts`, + ); + } + seen.set(name, op.id); + } + } +} + +// Replaces generated descriptions with `config.commandHelp` and +// `config.optionHelp`, and fails on entries that match nothing or repeat the +// default, so the overrides stay a list of deliberate wording. +function applyHelpOverrides(groups: Group[]): void { + const problems: string[] = []; + const unusedCommands = new Set(Object.keys(config.commandHelp)); + const unusedOptions = new Set( + Object.entries(config.optionHelp).flatMap(([cmd, flags]) => + Object.keys(flags).map((flag) => `${cmd} ${flag}`), + ), + ); + const override = (key: string, generated: string, text: string | undefined, unused: Set) => { + if (text === undefined) return generated; + unused.delete(key); + if (text === generated) problems.push(`help text for "${key}" repeats the default`); + return text; + }; + + for (const group of groups) { + const groupKey = group.name; + group.description = override(groupKey, group.description, config.commandHelp[groupKey], unusedCommands); + for (const command of group.commands) { + const key = `${groupKey} ${command.name}`; + command.description = override(key, command.description, config.commandHelp[key], unusedCommands); + for (const o of command.options) { + const flag = `--${o.flag}`; + o.description = override(`${key} ${flag}`, o.description, config.optionHelp[key]?.[flag], unusedOptions); + } + } + } + for (const key of unusedCommands) problems.push(`help text for unknown command "${key}"`); + for (const key of unusedOptions) problems.push(`help text for unknown option "${key}"`); + if (problems.length) throw new Error(`scripts/codegen/config.ts:\n ${problems.join("\n ")}`); +} + +// =========================================================================== +// 6. Render +// =========================================================================== + +const quote = (s: string) => JSON.stringify(s); +const pad = (n: number) => " ".repeat(n); +const indent = (text: string, n: number) => + text + .split("\n") + .map((line) => (line ? pad(n) + line : line)) + .join("\n"); +const sdkFunction = (op: Operation) => lowerFirst(op.id); +const registerFunction = (g: Group) => `register${pascal(g.name)}Command`; +const groupVariable = (g: Group) => { + const name = camel(g.name); + return ["delete", "export", "import", "new"].includes(name) ? `${name}Group` : name; +}; + +// A call on one line when it fits in `width`, else one argument per line. +function call(callee: string, args: string[], width: number): string { + const inline = `${callee}(${args.join(", ")})`; + if (inline.length <= width && !inline.includes("\n")) return inline; + return `${callee}(\n${args.map((arg) => `${indent(arg, 2)},`).join("\n")}\n)`; +} + +// An object literal on one line when it fits in `width`, else one entry per line. +function object(entries: string[], width: number): string { + const inline = `{ ${entries.join(", ")} }`; + if (inline.length <= width && !inline.includes("\n")) return inline; + return `{\n${entries.map((e) => `${indent(e, 2)},`).join("\n")}\n}`; +} + +// Names each generated file imports, collected while rendering. +type Imports = { + sdk: Set; + types: Set; + helpers: Set; + output: boolean; +}; + +// The SDK call for a command, and statements that must run before it. +function renderSdkCall(command: Command, imports: Imports, width: number) { + const { op, options } = command; + const value = (o: Option) => (o.target === "file" ? camel(o.flag) : `opts.${camel(o.flag)}`); + const entry = (o: Option) => (o.key === value(o) ? o.key : `${o.key}: ${value(o)}`); + const of = (target: Option["target"][]) => options.filter((o) => target.includes(o.target)); + const before: string[] = []; + const args = ["client: api.client"]; + + const path = of(["path"]).map(entry); + if (path.length) args.push(`path: ${object(path, width - 8)}`); + + const query = of(["query"]).map(entry); + if (command.paginated) { + imports.helpers.add("paginationQuery"); + if (query.length === 0) args.push("query: paginationQuery(opts)"); + else query.unshift("...paginationQuery(opts)"); + } + if (query.length) args.push(`query: ${object(query, width - 9)}`); + + if (op.body === "json") { + imports.helpers.add("readJSONPayload"); + imports.types.add(`${op.id}Data`); + before.push( + `const body = (await readJSONPayload(\n opts.json,\n opts.file,\n)) as ${op.id}Data["body"];`, + ); + args.push("body"); + } else if (op.body === "multipart") { + imports.helpers.add("readBinaryFile"); + for (const o of of(["file"])) { + before.push(`const ${camel(o.flag)} = await readBinaryFile(${`opts.${camel(o.flag)}`});`); + } + args.push(`body: ${object(of(["form", "file"]).map(entry), width - 8)}`); + } + + imports.sdk.add(sdkFunction(op)); + return { before, call: `${sdkFunction(op)}(${object(args, width - sdkFunction(op).length - 2)})` }; +} + +function renderCommand(group: Group, command: Command, imports: Imports): string { + // Wrapped in addPaginationOptions/addJsonFileOptions, the chain sits two + // columns deeper and `.action()` attaches to the wrapper. + const wrapped = command.paginated || command.op.body === "json"; + const chainIndent = wrapped ? 6 : 4; + const width = 80 - chainIndent; + + const chain = [ + groupVariable(group), + `.command(${quote(command.name)})`, + `.description(${quote(command.description)})`, + ]; + for (const o of command.options) { + const args = [quote(`--${o.flag} <${o.placeholder}>`), quote(o.description)]; + if (o.repeatable) { + imports.helpers.add("collectString"); + args.push("collectString", "[] as string[]"); + } else if (o.parseBool) { + imports.helpers.add("parseOptionalBoolString"); + args.push(`(value) => parseOptionalBoolString(value, ${quote(o.flag)})`); + } + chain.push(call(`.${o.required ? "requiredOption" : "option"}`, args, width)); + } + if (command.download) { + const help = "Write downloaded bytes to file path (default stdout)"; + chain.push(call(".option", [quote("--output "), quote(help)], width)); + } + // Short chains stay on one line: `policies.command("list").description(...)`. + const inline = chain.join(""); + let head = + chain.length === 3 && chainIndent - 2 + inline.length < 80 + ? inline + : [chain[0], ...chain.slice(1).map((line) => indent(line, 2))].join("\n"); + for (const [use, helper] of [ + [command.paginated, "addPaginationOptions"], + [command.op.body === "json", "addJsonFileOptions"], + ] as const) { + if (!use) continue; + imports.helpers.add(helper); + head = `${helper}(\n${indent(head, 2)},\n)`; + } + + const bodyIndent = wrapped ? 4 : 6; + const sdk = renderSdkCall(command, imports, 80 - bodyIndent - 2); + const statements = [...sdk.before]; + if (command.download) { + imports.helpers.add("withClient"); + imports.helpers.add("writeDownloadedMedia"); + imports.output = true; + statements.push( + [ + "await withClient(getFlags, async (api, flags) => {", + indent(`const result = await ${sdk.call};`, 2), + " if (result.error) throw result.error;", + " const dest = opts.output?.trim();", + " await writeDownloadedMedia(result.data, dest);", + " if (dest) printResponse({ savedTo: dest }, flags);", + "});", + ].join("\n"), + ); + } else { + imports.helpers.add("runSdk"); + statements.push(`await runSdk(getFlags, (api) =>\n${indent(sdk.call, 2)},\n);`); + } + const body = statements.join("\n"); + const action = renderAction(command, body, wrapped ? 2 : 4, imports); + const text = wrapped ? `${head}${action};` : `${head}\n${indent(action, 2)};`; + return indent(text, 2); +} + +// `.action(async (opts: { id: string }) => { ... })`, with the options type +// on its own lines when it doesn't fit. +function renderAction( + command: Command, + body: string, + column: number, + imports: Imports, +): string { + if (!/\bopts\b/.test(body)) return `.action(async () => {\n${indent(body, 2)}\n})`; + const fields: string[] = []; + const add = (o: Option) => { + const optional = o.required || o.repeatable ? "" : "?"; + let type = o.repeatable ? "string[]" : o.parseBool ? "boolean" : "string"; + if (o.isEnum) { + imports.types.add(`${command.op.id}Data`); + type = + o.target === "path" + ? `${command.op.id}Data["path"]["${o.key}"]` + : `NonNullable<${command.op.id}Data["query"]>["${o.key}"]`; + if (o.repeatable) type = `NonNullable<${type}>`; + } + fields.push(`${camel(o.flag)}${optional}: ${type}`); + }; + command.options.filter((o) => o.target === "path").forEach(add); + if (command.paginated) fields.push("pageSize?: number", "pageCursor?: string"); + command.options.filter((o) => o.target !== "path").forEach(add); + if (command.op.body === "json") fields.push("json?: string", "file?: string"); + if (command.download) fields.push("output?: string"); + // Pagination alone doesn't need a type: `addPaginationOptions` provides it. + if (fields.every((f) => f.startsWith("page"))) { + return `.action(async (opts) => {\n${indent(body, 2)}\n})`; + } + + const signature = `async (opts: { ${fields.join("; ")} }) => {`; + if (column + ".action(".length + signature.length <= 80) { + return `.action(${signature}\n${indent(body, 2)}\n})`; + } + const type = fields.map((field) => ` ${field};`).join("\n"); + return `.action(\n async (opts: {\n${type}\n }) => {\n${indent(body, 4)}\n },\n)`; +} + +function renderGroup(group: Group, imports: Imports): string { + const v = groupVariable(group); + const declaration = ` const ${v} = program.command(${quote(group.name)}).description(${quote(group.description)});`; + const parts = [ + declaration.length <= 80 + ? declaration + : ` const ${v} = program\n .command(${quote(group.name)})\n .description(${quote(group.description)});`, + ]; + // list, get, create, update, delete first; the rest in spec order. + const order = ["list", "get", "create", "update", "delete"]; + const rank = (c: Command) => (order.includes(c.name) ? order.indexOf(c.name) : order.length); + const commands = [...group.commands].sort((x, y) => rank(x) - rank(y)); + for (const command of commands) parts.push(renderCommand(group, command, imports)); + return parts.join("\n\n"); +} + +// Names in the order given, on one line when they fit. +function renderImport(kind: string, names: Iterable, from: string): string { + const list = [...names]; + const inline = `${kind} { ${list.join(", ")} } from "${from}";`; + if (inline.length <= 80) return inline; + return `${kind} {\n${list.map((n) => ` ${n},`).join("\n")}\n} from "${from}";`; +} + +function renderFile(group: Group, files: Map): void { + const imports: Imports = { + sdk: new Set(), + types: new Set(), + helpers: new Set(), + output: false, + }; + const body = renderGroup(group, imports); + + const lines = [generatedHeader, `import type { Command } from "commander";`]; + if (imports.sdk.size) lines.push(renderImport("import", imports.sdk, "../generated/sdk.gen.js")); + if (imports.types.size) { + lines.push(renderImport("import type", imports.types, "../generated/types.gen.js")); + } + if (imports.output) lines.push(`import { printResponse } from "../output.js";`); + // Helpers alphabetically, then the GetFlags type. + lines.push(renderImport("import", [...[...imports.helpers].sort(), "type GetFlags"], "./helpers.js")); + + files.set( + `${group.name}.ts`, + `${lines.join("\n")}\n\nexport function ${registerFunction(group)}(\n program: Command,\n getFlags: GetFlags,\n): void {\n${body}\n}\n`, + ); +} + +function renderFiles(groups: Group[]): Map { + const files = new Map(); + const order = (g: Group) => { + const i = config.topLevelOrder.indexOf(g.name); + return i === -1 ? config.topLevelOrder.length : i; + }; + const topLevel = [...groups].sort((x, y) => order(x) - order(y) || x.name.localeCompare(y.name)); + for (const group of topLevel) renderFile(group, files); + files.set( + "index.ts", + [ + generatedHeader, + `import type { Command } from "commander";`, + ...topLevel.map((g) => `import { ${registerFunction(g)} } from "./${g.name}.js";`), + `import type { GetFlags } from "./helpers.js";`, + "", + "export function registerApiCommands(program: Command, getFlags: GetFlags): void {", + ...topLevel.map((g) => ` ${registerFunction(g)}(program, getFlags);`), + "}", + "", + ].join("\n"), + ); + return files; +} + +// Writes the files, removing generated files for groups that no longer exist. +function writeFiles(files: Map, outDir: string): void { + mkdirSync(outDir, { recursive: true }); + for (const name of readdirSync(outDir)) { + const path = join(outDir, name); + if (!files.has(name) && name.endsWith(".ts") && readFileSync(path, "utf8").startsWith(generatedHeader)) { + rmSync(path); + } + } + for (const [name, content] of files) writeFileSync(join(outDir, name), content); + console.error(`wrote ${files.size} files to ${outDir}`); +} + +// =========================================================================== +// Main +// =========================================================================== + +const args = process.argv.slice(2); +const outDir = args.includes("--out") + ? args[args.indexOf("--out") + 1]! + : join(repoRoot, "src/commands"); + +const groups = [...buildGroups().values()]; +checkUniqueNames(groups); +applyHelpOverrides(groups); +writeFiles(renderFiles(groups), outDir); diff --git a/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,