diff --git a/.agents/skills/godaddy-cli/SKILL.md b/.agents/skills/godaddy-cli/SKILL.md new file mode 100644 index 0000000..a104e00 --- /dev/null +++ b/.agents/skills/godaddy-cli/SKILL.md @@ -0,0 +1,382 @@ +--- +name: godaddy-cli +description: "Use the GoDaddy CLI (godaddy) to manage applications, authentication, environments, deployments, extensions, and webhooks on the GoDaddy Developer Platform. Load this skill when a task involves running godaddy commands, parsing their JSON output, managing GoDaddy applications, deploying extensions, or interacting with the GoDaddy commerce APIs." +version: 1.0.0 +author: GoDaddy Commerce +tags: [godaddy, cli, commerce, applications, deploy] +--- + +# Using the GoDaddy CLI + +The `godaddy` CLI is an agent-first tool. Every command returns a single JSON envelope to stdout. There is no plain text mode, no `--json` flag, and no table output. Parse stdout as JSON. + +## Quick Start + +```bash +# Discover all commands and check current state +godaddy + +# Authenticate (opens browser for OAuth) +godaddy auth login + +# Check auth + environment +godaddy auth status +godaddy env get + +# List applications +godaddy application list + +# Get details on one application +godaddy application info +``` + +## Output Contract + +### Every response is JSON + +Every command writes exactly one JSON object to stdout followed by a newline. Parse it directly. Debug/verbose messages go to stderr only. + +### Success + +```json +{ + "ok": true, + "command": "godaddy application list", + "result": { ... }, + "next_actions": [ ... ] +} +``` + +### Error + +```json +{ + "ok": false, + "command": "godaddy application info demo", + "error": { + "message": "Application 'demo' not found", + "code": "NOT_FOUND" + }, + "fix": "Use discovery commands such as: godaddy application list or godaddy actions list.", + "next_actions": [ ... ] +} +``` + +Check `ok` first. On failure, read `error.code` for programmatic handling and `fix` for the suggested recovery step. + +Error codes: `NOT_FOUND`, `AUTH_REQUIRED`, `VALIDATION_ERROR`, `NETWORK_ERROR`, `CONFIG_ERROR`, `SECURITY_BLOCKED`, `COMMAND_NOT_FOUND`, `UNSUPPORTED_OPTION`, `UNEXPECTED_ERROR`. + +### next_actions (HATEOAS) + +Every response includes `next_actions` — an array of commands you can run next. These are contextual: they change based on what just happened. + +```json +{ + "next_actions": [ + { + "command": "godaddy application validate ", + "description": "Validate application configuration", + "params": { + "name": { "value": "wes-test-app-devs2", "required": true } + } + }, + { + "command": "godaddy application release --release-version ", + "description": "Create a release", + "params": { + "name": { "value": "wes-test-app-devs2", "required": true }, + "version": { "required": true } + } + } + ] +} +``` + +How to read `next_actions`: +- **No `params`**: the command is literal — run it as-is. +- **`params` present**: the command is a template. Fill `` with values. +- **`params.*.value`**: pre-filled from context. Use this value unless you have a reason to override. +- **`params.*.default`**: value to use if the param is omitted. +- **`params.*.enum`**: valid choices for this param. +- **`params.*.required`**: must be provided (corresponds to `` args). + +Template syntax: `` for positional args, `[--flag ]` for optional flags, `[--flag]` for optional booleans. + +### Truncated output + +Large results are automatically truncated to protect context windows. When this happens: + +```json +{ + "result": { + "events": [ ... ], + "total": 190, + "shown": 50, + "truncated": true, + "full_output": "/var/folders/.../godaddy-cli/1771947169904-webhook-events.json" + } +} +``` + +If `truncated` is `true`, the complete data is at the `full_output` file path. Read that file if you need everything. + +## Global Options + +| Flag | Alias | Effect | +|------|-------|--------| +| `--pretty` | | Pretty-print JSON with 2-space indentation | +| `--env ` | `-e` | Override environment for this command (`ote` or `prod`) | +| `--verbose` | `-v` | Log HTTP requests/responses to stderr | +| `--debug` | `-vv` | Full verbose output to stderr | + +These can appear anywhere in the command. They do not affect the JSON structure — only formatting and stderr diagnostics. + +## Discovery + +Run any group command without a subcommand to get its command tree: + +```bash +godaddy # Full tree + environment + auth snapshot +godaddy application # Application subcommands +godaddy application add # Add configuration subcommands +godaddy auth # Auth subcommands +godaddy env # Environment subcommands +godaddy actions # Action subcommands +godaddy webhook # Webhook subcommands +``` + +The root command (`godaddy` with no args) returns the complete `command_tree`, current environment, and authentication state — everything needed to decide what to do next. + +## Environments + +Two environments: `ote` (test, default) and `prod`. + +```bash +godaddy env get # Check current +godaddy env set prod # Switch to production +godaddy env list # List all with active first +godaddy env info ote # Show config details for an environment +godaddy --env prod app list # One-off override without switching +``` + +The active environment determines which API endpoint and config file are used. + +## Authentication + +OAuth 2.0 PKCE flow. Opens a browser for login. Tokens are stored in the OS keychain. + +```bash +godaddy auth login # Standard login +godaddy auth login --scope commerce.orders:read # Request additional scope +godaddy auth status # Check token state +godaddy auth logout # Clear credentials +``` + +If a command fails with `AUTH_REQUIRED`, run `godaddy auth login` and retry. + +The `godaddy api` command supports automatic re-auth: if a request returns 403 and `--scope` was provided, it re-authenticates with the requested scope and retries once. + +## Commands + +### Application Lifecycle + +```bash +# List and inspect +godaddy application list +godaddy application info +godaddy application validate + +# Create +godaddy application init \ + --name my-app \ + --description "My application" \ + --url https://my-app.example.com \ + --proxy-url https://my-app.example.com/api \ + --scopes "apps.app-registry:read apps.app-registry:write" + +# Update +godaddy application update --label "New Label" +godaddy application update --status INACTIVE +godaddy application update --description "Updated description" + +# Enable/disable on a store +godaddy application enable --store-id +godaddy application disable --store-id + +# Archive +godaddy application archive +``` + +### Configuration (godaddy.toml) + +Add actions, subscriptions, and extensions to the config file: + +```bash +# Actions +godaddy application add action --name my-action --url /actions/handler + +# Webhook subscriptions +godaddy application add subscription \ + --name order-events \ + --events "commerce.order.created,commerce.order.updated" \ + --url /webhooks/orders + +# Extensions +godaddy application add extension embed \ + --name my-widget \ + --handle my-widget-ext \ + --source src/extensions/widget/index.tsx \ + --target admin.product.detail + +godaddy application add extension checkout \ + --name my-checkout \ + --handle my-checkout-ext \ + --source src/extensions/checkout/index.tsx \ + --target checkout.cart.summary + +godaddy application add extension blocks --source src/extensions/blocks/index.tsx +``` + +All `add` commands accept `--config ` and `--environment ` to target a specific config file. + +### Release and Deploy + +```bash +# Create a release (required before deploy) +godaddy application release --release-version 1.0.0 +godaddy application release --release-version 1.0.0 --description "Initial release" + +# Deploy +godaddy application deploy + +# Deploy with streaming progress (NDJSON) +godaddy application deploy --follow +``` + +Release and deploy accept `--config ` and `--environment `. + +### Raw API Requests + +Make authenticated requests to any GoDaddy API endpoint: + +```bash +# GET request +godaddy api /v1/commerce/catalog/products + +# POST with inline fields +godaddy api /v1/some/endpoint -X POST -f "name=value" -f "count=5" + +# POST with body from file +godaddy api /v1/some/endpoint -X POST -F body.json + +# Custom headers +godaddy api /v1/some/endpoint -H "X-Custom: value" + +# Extract a field from the response +godaddy api /v1/some/endpoint -q ".data[0].id" + +# Include response headers in output +godaddy api /v1/some/endpoint -i + +# Auto re-auth on 403 with specific scope +godaddy api /v1/commerce/orders -s commerce.orders:read +``` + +### Actions + +```bash +# List all available actions +godaddy actions list + +# Describe an action's request/response contract +godaddy actions describe location.address.verify +godaddy actions describe commerce.taxes.calculate +``` + +Available actions: `location.address.verify`, `commerce.taxes.calculate`, `commerce.shipping-rates.calculate`, `commerce.price-adjustment.apply`, `commerce.price-adjustment.list`, `notifications.email.send`, `commerce.payment.get`, `commerce.payment.cancel`, `commerce.payment.refund`, `commerce.payment.process`, `commerce.payment.auth`. + +### Webhooks + +```bash +# List all available webhook event types +godaddy webhook events +``` + +Returns up to 50 events inline; use `full_output` path for the complete list (190+ events). + +## NDJSON Streaming + +When `--follow` is used (currently on `deploy`), output is multiple JSON lines instead of one envelope. Each line has a `type` field: + +``` +{"type":"start","command":"godaddy application deploy my-app --follow","ts":"..."} +{"type":"step","name":"security-scan","status":"started","ts":"..."} +{"type":"step","name":"security-scan","status":"completed","ts":"..."} +{"type":"step","name":"bundle","status":"started","extension_name":"my-widget","ts":"..."} +{"type":"progress","name":"bundle","percent":50,"ts":"..."} +{"type":"step","name":"bundle","status":"completed","ts":"..."} +{"type":"result","ok":true,"command":"...","result":{...},"next_actions":[...]} +``` + +The **last line is always terminal** (`type: "result"` or `type: "error"`). It has the same shape as a standard envelope. If you only care about the final outcome, read the last line. + +Stream event types: +| Type | Meaning | Terminal? | +|------|---------|-----------| +| `start` | Stream begun | No | +| `step` | Step lifecycle (started/completed/failed) | No | +| `progress` | Progress update (percent, message) | No | +| `result` | Success envelope | Yes | +| `error` | Error envelope | Yes | + +## Typical Workflows + +### Create and deploy a new application + +```bash +godaddy env get # 1. Check environment +godaddy auth status # 2. Verify auth +godaddy application init --name my-app \ # 3. Create app + --description "My app" \ + --url https://my-app.example.com \ + --proxy-url https://my-app.example.com/api \ + --scopes "apps.app-registry:read apps.app-registry:write" +godaddy application add action --name my-action \ # 4. Add action + --url /actions/handler +godaddy application validate my-app # 5. Validate +godaddy application release my-app \ # 6. Release + --release-version 1.0.0 +godaddy application deploy my-app --follow # 7. Deploy +godaddy application enable my-app --store-id # 8. Enable +``` + +### Update and redeploy + +```bash +godaddy application info my-app # 1. Check current state +godaddy application update my-app --description "New" # 2. Update +godaddy application validate my-app # 3. Validate +godaddy application release my-app \ # 4. Bump version + --release-version 1.1.0 +godaddy application deploy my-app --follow # 5. Deploy +``` + +### Diagnose failures + +```bash +godaddy # 1. Check overall state +godaddy auth status # 2. Token expired? → godaddy auth login +godaddy env info # 3. Config correct? +godaddy application validate # 4. Config issues? +godaddy application info # 5. App status? +``` + +## Parsing Tips + +1. **Always parse stdout as JSON.** The only non-JSON output is `--help` text. +2. **Check `ok` first.** Branch on `true`/`false` before reading `result` or `error`. +3. **Use `next_actions`** to discover what to do next. Fill template params from context. +4. **Exit code**: 0 = success, 1 = error. But always prefer the JSON `ok` field. +5. **stderr is diagnostic only.** Verbose/debug output goes there. Never parse stderr for data. +6. **Truncated lists**: check `truncated` field. Read `full_output` file for complete data. +7. **Streaming**: for `--follow` commands, parse each line as an independent JSON object. The last line is the final result. diff --git a/CHANGELOG.md b/CHANGELOG.md index b6077f0..46cbd48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Patch Changes +- Add API catalog discovery commands (`api list`, `api describe`, `api search`) and preserve backward compatibility by routing legacy `godaddy api ` usage to `godaddy api call `. Also add the public `godaddy-cli` agent skill documentation. - b3cba2f: Security hardening: bind OAuth server to 127.0.0.1, sanitize headers in debug and --include output, HTML-escape OAuth error page, harden PowerShell keychain escaping, stop forwarding raw server errors to userMessage, redact sensitive fields in debug request body, add 120s OAuth timeout. ## 0.2.0 diff --git a/package.json b/package.json index 0c7a481..4bf187c 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "format": "pnpm biome format --write", "lint": "pnpm biome lint --write", "check": "pnpm biome check --fix --unsafe", + "generate:api-catalog": "pnpm tsx scripts/generate-api-catalog.ts", "build": "node build.mjs", "build:dev": "NODE_ENV=development node build.mjs --dev && NODE_OPTIONS=--enable-source-maps node dist/cli-dev.js", "prepare": "pnpm run build", @@ -43,7 +44,8 @@ "ms": "^2.1.3", "msw": "^2.4.0", "tsx": "^4.19.3", - "vitest": "^3.2.2" + "vitest": "^3.2.2", + "yaml": "^2.8.2" }, "peerDependencies": { "typescript": "^5.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0f1f5a..34183b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,6 +120,9 @@ importers: vitest: specifier: ^3.2.2 version: 3.2.4(@types/node@22.19.3)(@vitest/ui@3.2.4)(msw@2.12.7(@types/node@22.19.3)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) + yaml: + specifier: ^2.8.2 + version: 2.8.2 packages: diff --git a/scripts/generate-api-catalog.ts b/scripts/generate-api-catalog.ts new file mode 100644 index 0000000..b94723a --- /dev/null +++ b/scripts/generate-api-catalog.ts @@ -0,0 +1,665 @@ +/** + * Build-time script: reads OpenAPI specs from specification submodules and + * produces a JSON catalog that the CLI bundles for `godaddy api list / describe`. + * + * Resolves external $ref URLs (e.g. schemas.api.godaddy.com) at build time + * so the CLI catalog is fully self-contained. + * + * Usage: + * pnpm tsx scripts/generate-api-catalog.ts + * + * Output: + * src/cli/schemas/api/manifest.json – domain index + * src/cli/schemas/api/.json – per-domain endpoint catalog + */ + +import { lookup } from "node:dns/promises"; +import * as fs from "node:fs"; +import { isIP } from "node:net"; +import * as path from "node:path"; +import { parse as parseYaml } from "yaml"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface OpenApiParameter { + name: string; + in: string; + required?: boolean; + description?: string; + schema?: Record; +} + +interface OpenApiRequestBody { + description?: string; + required?: boolean; + content?: Record }>; +} + +interface OpenApiResponse { + description?: string; + content?: Record }>; +} + +interface OpenApiOperation { + operationId?: string; + summary?: string; + description?: string; + parameters?: OpenApiParameter[]; + requestBody?: OpenApiRequestBody; + responses?: Record; + security?: Array>; +} + +interface OpenApiPathItem { + [method: string]: OpenApiOperation | OpenApiParameter[] | undefined; + parameters?: OpenApiParameter[]; +} + +interface OpenApiServer { + url: string; + variables?: Record; +} + +interface OpenApiSpec { + openapi: string; + info: { + title: string; + description?: string; + version: string; + contact?: Record; + }; + paths: Record; + servers?: OpenApiServer[]; + components?: Record; +} + +// --------------------------------------------------------------------------- +// Output types — what the CLI consumes at runtime +// --------------------------------------------------------------------------- + +interface CatalogEndpoint { + operationId: string; + method: string; + path: string; + summary: string; + description?: string; + parameters?: Array<{ + name: string; + in: string; + required: boolean; + description?: string; + schema?: Record; + }>; + requestBody?: { + required: boolean; + description?: string; + contentType: string; + schema?: Record; + }; + responses: Record< + string, + { + description: string; + schema?: Record; + } + >; + scopes: string[]; +} + +interface CatalogDomain { + name: string; + title: string; + description: string; + version: string; + baseUrl: string; + endpoints: CatalogEndpoint[]; +} + +interface CatalogManifest { + generated: string; + domains: Record< + string, + { file: string; title: string; endpointCount: number } + >; +} + +// --------------------------------------------------------------------------- +// Spec source registry — add new spec submodules here +// --------------------------------------------------------------------------- + +interface SpecSource { + /** Domain key used in the CLI (e.g. "location-addresses") */ + domain: string; + /** Relative path from workspace root to the OpenAPI YAML file */ + specPath: string; +} + +const __dirname = path.dirname(new URL(import.meta.url).pathname); +const WORKSPACE_ROOT = path.resolve(__dirname, "../.."); +const OUTPUT_DIR = path.resolve(__dirname, "../src/cli/schemas/api"); + +const SPEC_SOURCES: SpecSource[] = [ + { + domain: "location-addresses", + specPath: "location.addresses-specification/v1/schemas/openapi.yaml", + }, + // Add more spec submodules here as they become available: + // { domain: "catalog", specPath: "catalog-specification/v1/schemas/openapi.yaml" }, +]; + +const ALLOWED_REF_HOSTS = new Set(["schemas.api.godaddy.com"]); +const MAX_REF_REDIRECTS = 5; +const MAX_REF_BYTES = 1_000_000; // 1 MB +const REF_FETCH_TIMEOUT_MS = 10_000; + +// --------------------------------------------------------------------------- +// External $ref resolution +// --------------------------------------------------------------------------- + +const refCache = new Map>(); +const hostValidationCache = new Set(); + +function isPrivateOrReservedIp(address: string): boolean { + const version = isIP(address); + if (version === 4) { + const parts = address.split(".").map((part) => Number.parseInt(part, 10)); + if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) + return true; + const [a, b] = parts; + + if (a === 0 || a === 10 || a === 127) return true; + if (a === 100 && b >= 64 && b <= 127) return true; // RFC 6598 + if (a === 169 && b === 254) return true; // link-local + if (a === 172 && b >= 16 && b <= 31) return true; // private + if (a === 192 && b === 0) return true; + if (a === 192 && b === 168) return true; // private + if (a === 192 && b === 2) return true; // TEST-NET-1 + if (a === 198 && (b === 18 || b === 19)) return true; // benchmark + if (a === 198 && b === 51) return true; // TEST-NET-2 + if (a === 203 && b === 0) return true; // TEST-NET-3 + if (a >= 224) return true; // multicast + reserved + return false; + } + + if (version === 6) { + const lower = address.toLowerCase(); + if (lower === "::" || lower === "::1") return true; + if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // ULA + if ( + lower.startsWith("fe8") || + lower.startsWith("fe9") || + lower.startsWith("fea") || + lower.startsWith("feb") + ) { + return true; // link-local + } + if (lower.startsWith("2001:db8")) return true; // documentation range + if (lower.startsWith("::ffff:")) { + const mapped = lower.slice("::ffff:".length); + if (isIP(mapped) === 4) { + return isPrivateOrReservedIp(mapped); + } + } + return false; + } + + return true; +} + +function validateRefUrl(urlString: string): URL { + const parsed = new URL(urlString); + + if (parsed.protocol !== "https:") { + throw new Error( + `Blocked external $ref '${urlString}': only https URLs are allowed`, + ); + } + + if (parsed.username || parsed.password) { + throw new Error( + `Blocked external $ref '${urlString}': credentialed URLs are not allowed`, + ); + } + + if (parsed.port && parsed.port !== "443") { + throw new Error( + `Blocked external $ref '${urlString}': non-default HTTPS ports are not allowed`, + ); + } + + if (!ALLOWED_REF_HOSTS.has(parsed.hostname)) { + throw new Error( + `Blocked external $ref '${urlString}': host '${parsed.hostname}' is not allowlisted`, + ); + } + + return parsed; +} + +async function validateResolvedHost(url: URL): Promise { + const host = url.hostname.toLowerCase(); + if (hostValidationCache.has(host)) return; + + const addresses = await lookup(host, { all: true, verbatim: true }); + if (addresses.length === 0) { + throw new Error( + `Blocked external $ref '${url}': DNS lookup returned no IPs`, + ); + } + + for (const record of addresses) { + if (isPrivateOrReservedIp(record.address)) { + throw new Error( + `Blocked external $ref '${url}': host resolves to private/reserved IP ${record.address}`, + ); + } + } + + hostValidationCache.add(host); +} + +async function readResponseTextWithLimit( + response: Response, + maxBytes: number, +): Promise { + if (!response.body) { + const text = await response.text(); + const size = Buffer.byteLength(text, "utf8"); + if (size > maxBytes) { + throw new Error( + `External $ref response exceeded ${maxBytes} bytes (${size} bytes)`, + ); + } + return text; + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + throw new Error( + `External $ref response exceeded ${maxBytes} bytes while streaming`, + ); + } + + chunks.push(value); + } + + const merged = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.byteLength; + } + + return new TextDecoder().decode(merged); +} + +async function fetchWithValidation( + initialUrl: string, +): Promise<{ response: Response; finalUrl: string }> { + let currentUrl = initialUrl; + + for (let redirects = 0; redirects <= MAX_REF_REDIRECTS; redirects++) { + const parsed = validateRefUrl(currentUrl); + await validateResolvedHost(parsed); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REF_FETCH_TIMEOUT_MS); + let response: Response; + + try { + response = await fetch(currentUrl, { + redirect: "manual", + signal: controller.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error( + `Timed out fetching external $ref '${currentUrl}' after ${REF_FETCH_TIMEOUT_MS}ms`, + ); + } + throw error; + } finally { + clearTimeout(timeout); + } + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location"); + if (!location) { + throw new Error( + `External $ref redirect from '${currentUrl}' missing Location header`, + ); + } + if (redirects === MAX_REF_REDIRECTS) { + throw new Error( + `Too many redirects while fetching external $ref '${initialUrl}'`, + ); + } + currentUrl = new URL(location, currentUrl).toString(); + continue; + } + + const finalUrl = response.url || currentUrl; + const finalParsed = validateRefUrl(finalUrl); + await validateResolvedHost(finalParsed); + + return { response, finalUrl }; + } + + throw new Error( + `Unexpected redirect handling failure for external $ref '${initialUrl}'`, + ); +} + +async function fetchExternalRef(url: string): Promise> { + const cached = refCache.get(url); + if (cached) return cached; + + console.log(` Fetching external $ref: ${url}`); + const { response, finalUrl } = await fetchWithValidation(url); + if (!response.ok) { + throw new Error( + `Failed to fetch external $ref '${finalUrl}': ${response.status}`, + ); + } + + const contentLength = response.headers.get("content-length"); + if (contentLength) { + const size = Number.parseInt(contentLength, 10); + if (Number.isFinite(size) && size > MAX_REF_BYTES) { + throw new Error( + `External $ref '${finalUrl}' is too large (${size} bytes > ${MAX_REF_BYTES})`, + ); + } + } + + const text = await readResponseTextWithLimit(response, MAX_REF_BYTES); + let parsed: Record; + const finalPath = new URL(finalUrl).pathname.toLowerCase(); + try { + if (finalPath.endsWith(".yaml") || finalPath.endsWith(".yml")) { + parsed = parseYaml(text) as Record; + } else { + parsed = JSON.parse(text) as Record; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to parse external $ref '${finalUrl}': ${message}`); + } + + // Strip JSON Schema meta-fields that add noise for agents + const { $id, $schema, ...rest } = parsed; + const cleaned = rest as Record; + + refCache.set(finalUrl, cleaned); + refCache.set(url, cleaned); + return cleaned; +} + +/** + * Resolve a potentially relative $ref URL against a base URL. + * "./country-code.yaml" resolved against + * "https://schemas.api.godaddy.com/common-types/v1/schemas/yaml/address.yaml" + * becomes "https://schemas.api.godaddy.com/common-types/v1/schemas/yaml/country-code.yaml" + */ +function resolveRefUrl(ref: string, baseUrl?: string): string | null { + if (ref.startsWith("https://") || ref.startsWith("http://")) return ref; + if (ref.startsWith("#")) return null; // local JSON pointer — skip + if (!baseUrl) return null; + // Relative path: resolve against the base URL's directory + const base = baseUrl.substring(0, baseUrl.lastIndexOf("/") + 1); + return new URL(ref, base).toString(); +} + +/** + * Walk an object tree and resolve any { $ref: "..." } nodes by + * fetching the URL and inlining the result. Resolves both absolute + * and relative $refs (relative to parentUrl). Local JSON pointer + * refs (e.g. "#/components/schemas/Foo") are left as-is. + */ +async function resolveRefs(obj: unknown, parentUrl?: string): Promise { + if (obj === null || obj === undefined) return obj; + if (typeof obj !== "object") return obj; + + if (Array.isArray(obj)) { + return Promise.all(obj.map((item) => resolveRefs(item, parentUrl))); + } + + const record = obj as Record; + + // Check if this node is a $ref + if (typeof record.$ref === "string") { + const resolvedUrl = resolveRefUrl(record.$ref, parentUrl); + if (resolvedUrl) { + const resolved = await fetchExternalRef(resolvedUrl); + // Preserve sibling properties (e.g. "description" next to "$ref") + const { $ref, ...siblings } = record; + const merged = { ...resolved, ...siblings }; + // Recursively resolve any nested $refs, using this URL as the new base + return resolveRefs(merged, resolvedUrl); + } + } + + // Recurse into all properties + const result: Record = {}; + for (const [key, value] of Object.entries(record)) { + result[key] = await resolveRefs(value, parentUrl); + } + return result; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const HTTP_METHODS = new Set([ + "get", + "post", + "put", + "patch", + "delete", + "options", + "head", + "trace", +]); + +function resolveBaseUrl(servers?: OpenApiServer[]): string { + if (!servers || servers.length === 0) return ""; + const server = servers[0]; + let url = server.url; + if (server.variables) { + for (const [key, variable] of Object.entries(server.variables)) { + url = url.replace(`{${key}}`, variable.default); + } + } + return url; +} + +function extractScopes(security?: Array>): string[] { + if (!security) return []; + const scopes: string[] = []; + for (const entry of security) { + for (const scopeList of Object.values(entry)) { + scopes.push(...scopeList); + } + } + return [...new Set(scopes)]; +} + +function processOperation( + httpMethod: string, + pathStr: string, + operation: OpenApiOperation, + pathLevelParams?: OpenApiParameter[], +): CatalogEndpoint { + // Merge path-level and operation-level parameters + const allParams = [ + ...(pathLevelParams || []), + ...(operation.parameters || []), + ]; + + const parameters = allParams.map((p) => ({ + name: p.name, + in: p.in, + required: p.required ?? false, + description: p.description, + schema: p.schema, + })); + + // Process request body + let requestBody: CatalogEndpoint["requestBody"]; + if (operation.requestBody) { + const rb = operation.requestBody; + const contentTypes = rb.content ? Object.keys(rb.content) : []; + const primaryCt = contentTypes[0] || "application/json"; + const schema = rb.content?.[primaryCt]?.schema; + + requestBody = { + required: rb.required ?? false, + description: rb.description, + contentType: primaryCt, + schema: schema, + }; + } + + // Process responses (skip $ref responses that we can't resolve inline) + const responses: CatalogEndpoint["responses"] = {}; + if (operation.responses) { + for (const [status, resp] of Object.entries(operation.responses)) { + if ("$ref" in resp) { + responses[status] = { + description: `See ${(resp as { $ref: string }).$ref}`, + }; + continue; + } + const contentTypes = resp.content ? Object.keys(resp.content) : []; + const primaryCt = contentTypes[0] || "application/json"; + responses[status] = { + description: resp.description || "", + schema: resp.content?.[primaryCt]?.schema, + }; + } + } + + // Generate a stable operationId if missing + const operationId = + operation.operationId || + `${httpMethod}${pathStr.replace(/[^a-zA-Z0-9]/g, "_")}`; + + return { + operationId, + method: httpMethod.toUpperCase(), + path: pathStr, + summary: operation.summary || "", + description: operation.description, + parameters: parameters.length > 0 ? parameters : undefined, + requestBody, + responses, + scopes: extractScopes(operation.security), + }; +} + +function processSpec(spec: OpenApiSpec, domain: string): CatalogDomain { + const baseUrl = resolveBaseUrl(spec.servers); + const endpoints: CatalogEndpoint[] = []; + + for (const [pathStr, pathItem] of Object.entries(spec.paths)) { + const pathLevelParams = pathItem.parameters as + | OpenApiParameter[] + | undefined; + + for (const [key, value] of Object.entries(pathItem)) { + if (key === "parameters" || !HTTP_METHODS.has(key) || !value) continue; + const operation = value as OpenApiOperation; + endpoints.push( + processOperation(key, pathStr, operation, pathLevelParams), + ); + } + } + + return { + name: domain, + title: spec.info.title, + description: spec.info.description || "", + version: spec.info.version, + baseUrl, + endpoints, + }; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + + const manifest: CatalogManifest = { + generated: new Date().toISOString(), + domains: {}, + }; + + let totalEndpoints = 0; + + for (const source of SPEC_SOURCES) { + const specFile = path.join(WORKSPACE_ROOT, source.specPath); + + if (!fs.existsSync(specFile)) { + console.error(`WARNING: spec not found: ${specFile} — skipping`); + continue; + } + + const raw = fs.readFileSync(specFile, "utf-8"); + const spec = parseYaml(raw) as OpenApiSpec; + const catalog = processSpec(spec, source.domain); + + // Resolve all external $refs in the catalog + console.log(` Resolving external $refs for ${source.domain}...`); + const resolved = (await resolveRefs(catalog)) as CatalogDomain; + + const filename = `${source.domain}.json`; + + fs.writeFileSync( + path.join(OUTPUT_DIR, filename), + JSON.stringify(resolved, null, "\t"), + "utf-8", + ); + + manifest.domains[source.domain] = { + file: filename, + title: resolved.title, + endpointCount: resolved.endpoints.length, + }; + + totalEndpoints += resolved.endpoints.length; + console.log( + ` ${source.domain}: ${resolved.endpoints.length} endpoints from ${spec.info.title} v${spec.info.version}`, + ); + } + + fs.writeFileSync( + path.join(OUTPUT_DIR, "manifest.json"), + JSON.stringify(manifest, null, "\t"), + "utf-8", + ); + + console.log( + `\nGenerated API catalog: ${Object.keys(manifest.domains).length} domains, ${totalEndpoints} endpoints`, + ); +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/src/cli-entry.ts b/src/cli-entry.ts index cb47ca1..6748ccd 100644 --- a/src/cli-entry.ts +++ b/src/cli-entry.ts @@ -76,9 +76,31 @@ const COMMAND_TREE: CommandNode = { description: "Manage GoDaddy environments (ote, prod)", }, { - id: "api.request", - command: "godaddy api ", - description: "Make authenticated requests to GoDaddy APIs", + id: "api.group", + command: "godaddy api", + description: "Explore and call GoDaddy API endpoints", + children: [ + { + id: "api.list", + command: "godaddy api list", + description: "List all API domains and their endpoints", + }, + { + id: "api.describe", + command: "godaddy api describe ", + description: "Show detailed schema information for an API endpoint", + }, + { + id: "api.search", + command: "godaddy api search ", + description: "Search for API endpoints by keyword", + }, + { + id: "api.call", + command: "godaddy api call ", + description: "Make an authenticated API request", + }, + ], }, { id: "actions.group", @@ -196,6 +218,67 @@ function normalizeVerbosityArgs(argv: readonly string[]): string[] { return retained; } +const API_SUBCOMMANDS = new Set(["list", "describe", "search", "call"]); +const ROOT_FLAG_WITH_VALUE = new Set([ + "--env", + "-e", + "--log-level", + "--completions", +]); +const ROOT_BOOLEAN_FLAGS = new Set([ + "--pretty", + "--verbose", + "-v", + "--info", + "--debug", + "--help", + "-h", + "--version", + "--wizard", +]); + +function rewriteLegacyApiEndpointArgs(argv: readonly string[]): string[] { + const rewritten = [...argv]; + let index = 0; + + while (index < rewritten.length) { + const token = rewritten[index]; + + if (ROOT_FLAG_WITH_VALUE.has(token)) { + index += 2; + continue; + } + + if (ROOT_BOOLEAN_FLAGS.has(token)) { + index += 1; + continue; + } + + if (token.startsWith("-")) { + index += 1; + continue; + } + + if (token !== "api") { + return rewritten; + } + + const maybeSubcommandOrEndpoint = rewritten[index + 1]; + if ( + !maybeSubcommandOrEndpoint || + maybeSubcommandOrEndpoint.startsWith("-") || + API_SUBCOMMANDS.has(maybeSubcommandOrEndpoint) + ) { + return rewritten; + } + + rewritten.splice(index + 1, 0, "call"); + return rewritten; + } + + return rewritten; +} + // --------------------------------------------------------------------------- // Root command // --------------------------------------------------------------------------- @@ -323,6 +406,7 @@ export function runCli(rawArgv: ReadonlyArray): Promise { } const frameworkArgs = normalized.filter((_, i) => !stripIndices.has(i)); + const rewrittenFrameworkArgs = rewriteLegacyApiEndpointArgs(frameworkArgs); // Detect unsupported --output option before handing to framework const outputIdx = normalized.indexOf("--output"); @@ -377,7 +461,7 @@ export function runCli(rawArgv: ReadonlyArray): Promise { // We pass a synthetic prefix so the framework strips the first two. // Use frameworkArgs (global flags already stripped) so @effect/cli // doesn't reject them as unknown options on subcommands. - ["node", "godaddy", ...frameworkArgs], + ["node", "godaddy", ...rewrittenFrameworkArgs], ).pipe( // Centralized error boundary: catch ALL errors, emit JSON envelope Effect.catchAll((error) => diff --git a/src/cli/agent/truncation.ts b/src/cli/agent/truncation.ts index 2ae7170..dc4cd10 100644 --- a/src/cli/agent/truncation.ts +++ b/src/cli/agent/truncation.ts @@ -41,10 +41,23 @@ function slugify(commandId: string): string { function writeFullOutput(commandId: string, payload: unknown): string { const dir = join(tmpdir(), "godaddy-cli"); - fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + try { + fs.chmodSync(dir, 0o700); + } catch { + // Best-effort on platforms that don't honor POSIX modes. + } const filename = `${Date.now()}-${slugify(commandId)}.json`; const fullPath = join(dir, filename); - fs.writeFileSync(fullPath, JSON.stringify(payload, null, 2), "utf8"); + fs.writeFileSync(fullPath, JSON.stringify(payload, null, 2), { + encoding: "utf8", + mode: 0o600, + }); + try { + fs.chmodSync(fullPath, 0o600); + } catch { + // Best-effort on platforms that don't honor POSIX modes. + } return fullPath; } diff --git a/src/cli/commands/api.ts b/src/cli/commands/api.ts index 6909ea7..889a00f 100644 --- a/src/cli/commands/api.ts +++ b/src/cli/commands/api.ts @@ -13,7 +13,17 @@ import { } from "../../core/api"; import { authLoginEffect, getTokenInfoEffect } from "../../core/auth"; import { AuthenticationError, ValidationError } from "../../effect/errors"; +import { protectPayload, truncateList } from "../agent/truncation"; import type { NextAction } from "../agent/types"; +import { + type CatalogDomain, + type CatalogEndpoint, + findEndpointByAnyMethodEffect, + findEndpointByOperationIdEffect, + listDomainsEffect, + loadDomainEffect, + searchEndpointsEffect, +} from "../schemas/api/index"; import { CliConfig } from "../services/cli-config"; import { EnvelopeWriter } from "../services/envelope-writer"; @@ -26,24 +36,172 @@ const VALID_METHODS: readonly HttpMethod[] = [ ]; // --------------------------------------------------------------------------- -// Colocated next_actions +// next_actions helpers // --------------------------------------------------------------------------- -const apiRequestActions: NextAction[] = [ +const apiGroupActions: NextAction[] = [ + { + command: "godaddy api list", + description: "List all API domains and endpoints", + }, + { + command: "godaddy api describe ", + description: "Describe an API endpoint's schema and parameters", + params: { + endpoint: { + description: + "Operation ID or path (e.g. commerce.location.verify-address or /location/addresses)", + required: true, + }, + }, + }, + { + command: "godaddy api search ", + description: "Search API endpoints by keyword", + params: { + query: { description: "Search term", required: true }, + }, + }, { - command: "godaddy api ", - description: "Call another API endpoint", + command: "godaddy api call ", + description: "Make an authenticated API request", params: { endpoint: { - description: "Relative API endpoint (for example /v1/domains)", + description: + "Relative API endpoint (e.g. /v1/commerce/location/addresses)", required: true, }, }, }, - { command: "godaddy auth status", description: "Check auth status" }, - { command: "godaddy env get", description: "Check active environment" }, ]; +function describeNextActions( + domain: CatalogDomain, + endpoint: CatalogEndpoint, +): NextAction[] { + // Build a call template with strongly-typed params instead of embedding + // schema-sourced values directly into an executable command string. + const fullPath = `${domain.baseUrl}${endpoint.path}`.replace( + /^https:\/\/api\.godaddy\.com/, + "", + ); + const callParams: NonNullable = { + endpoint: { + description: "Relative API endpoint", + value: fullPath, + required: true, + }, + method: { + description: "HTTP method", + value: endpoint.method, + }, + }; + if (endpoint.scopes.length > 0) { + callParams.scope = { + description: "Required OAuth scope", + value: endpoint.scopes[0], + }; + } + + const actions: NextAction[] = [ + { + command: "godaddy api call ", + description: `Execute ${endpoint.method} ${endpoint.path}`, + params: callParams, + }, + { + command: "godaddy api list", + description: "List all API domains and endpoints", + }, + ]; + + // Suggest other endpoints in the same domain + const otherEndpoints = domain.endpoints.filter( + (e) => e.operationId !== endpoint.operationId, + ); + if (otherEndpoints.length > 0) { + const next = otherEndpoints[0]; + actions.push({ + command: "godaddy api describe ", + description: `Describe ${next.summary}`, + params: { + endpoint: { + description: "Operation ID or path", + value: next.operationId, + required: true, + }, + }, + }); + } + + return actions; +} + +function listNextActions(firstDomain?: string): NextAction[] { + return [ + { + command: "godaddy api list --domain ", + description: "List endpoints for a specific API domain", + params: { + domain: { + description: "Domain name", + value: firstDomain, + required: true, + }, + }, + }, + { + command: "godaddy api search ", + description: "Search for API endpoints by keyword", + params: { + query: { description: "Search term", required: true }, + }, + }, + ]; +} + +function searchNextActions(firstOperationId?: string): NextAction[] { + const actions: NextAction[] = []; + if (firstOperationId) { + actions.push({ + command: "godaddy api describe ", + description: "Describe this endpoint", + params: { + endpoint: { + description: "Operation ID or path", + value: firstOperationId, + required: true, + }, + }, + }); + } + actions.push({ + command: "godaddy api list", + description: "List all API domains", + }); + return actions; +} + +function callNextActions(): NextAction[] { + return [ + { + command: "godaddy api call ", + description: "Call another API endpoint", + params: { + endpoint: { + description: "Relative API endpoint (e.g. /v1/domains)", + required: true, + }, + }, + }, + { command: "godaddy auth status", description: "Check auth status" }, + { + command: "godaddy api list", + description: "Browse available API endpoints", + }, + ]; +} + // --------------------------------------------------------------------------- // extractPath — public for unit testing // --------------------------------------------------------------------------- @@ -106,14 +264,6 @@ function normalizeStringArray(value: ReadonlyArray): string[] { return value.filter((entry): entry is string => typeof entry === "string"); } -// --------------------------------------------------------------------------- -// Command -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Helpers — JWT scope check -// --------------------------------------------------------------------------- - /** Decode a JWT payload without verification (we only need the claims). */ function decodeJwtPayload(token: string): Record | null { try { @@ -136,14 +286,277 @@ function tokenHasScopes(token: string, required: string[]): boolean { } // --------------------------------------------------------------------------- -// Command +// Subcommand: api list +// --------------------------------------------------------------------------- + +const apiList = Command.make( + "list", + { + domain: Options.text("domain").pipe( + Options.withAlias("d"), + Options.withDescription("Filter by API domain name"), + Options.optional, + ), + }, + (config) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + const domainFilter = Option.getOrUndefined(config.domain); + + if (domainFilter) { + // List endpoints for a specific domain + const maybeDomain = yield* loadDomainEffect(domainFilter); + if (Option.isNone(maybeDomain)) { + return yield* Effect.fail( + new ValidationError({ + message: `API domain '${domainFilter}' not found`, + userMessage: `API domain '${domainFilter}' does not exist. Run: godaddy api list`, + }), + ); + } + const domain = maybeDomain.value; + + const endpointSummaries = domain.endpoints.map((e) => ({ + operationId: e.operationId, + method: e.method, + path: e.path, + summary: e.summary, + scopes: e.scopes, + })); + + const truncated = truncateList( + endpointSummaries, + `api-list-${domainFilter}`, + ); + + yield* writer.emitSuccess( + "godaddy api list", + { + domain: domain.name, + title: domain.title, + description: domain.description, + version: domain.version, + baseUrl: domain.baseUrl, + endpoints: truncated.items, + total: truncated.metadata.total, + shown: truncated.metadata.shown, + truncated: truncated.metadata.truncated, + full_output: truncated.metadata.full_output, + }, + endpointSummaries.length > 0 + ? [ + { + command: "godaddy api describe ", + description: `Describe ${endpointSummaries[0].summary}`, + params: { + endpoint: { + description: "Operation ID or path", + value: endpointSummaries[0].operationId, + required: true, + }, + }, + }, + { + command: "godaddy api list", + description: "List all API domains", + }, + { + command: "godaddy api search ", + description: "Search for endpoints by keyword", + params: { + query: { description: "Search term", required: true }, + }, + }, + ] + : listNextActions(), + ); + } else { + // List all domains + const domains = yield* listDomainsEffect(); + const truncated = truncateList(domains, "api-list-domains"); + + yield* writer.emitSuccess( + "godaddy api list", + { + domains: truncated.items, + total: truncated.metadata.total, + shown: truncated.metadata.shown, + truncated: truncated.metadata.truncated, + full_output: truncated.metadata.full_output, + }, + listNextActions(domains[0]?.name), + ); + } + }), +).pipe(Command.withDescription("List available API domains and endpoints")); + +// --------------------------------------------------------------------------- +// Subcommand: api describe // --------------------------------------------------------------------------- -const apiCommand = Command.make( - "api", +const apiDescribe = Command.make( + "describe", { endpoint: Args.text({ name: "endpoint" }).pipe( - Args.withDescription("API endpoint (for example: /v1/domains)"), + Args.withDescription( + "Operation ID (e.g. commerce.location.verify-address) or path (e.g. /location/addresses)", + ), + ), + }, + ({ endpoint }) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + + // Try to find by operation ID first, then by path + let result = yield* findEndpointByOperationIdEffect(endpoint); + + if (Option.isNone(result)) { + // Try as a path, testing all HTTP methods + result = yield* findEndpointByAnyMethodEffect(endpoint); + } + + // Fallback: fuzzy search + if (Option.isNone(result)) { + const searchResults = yield* searchEndpointsEffect(endpoint); + + if (searchResults.length === 1) { + result = Option.some(searchResults[0]); + } else if (searchResults.length > 1) { + // Multiple matches — list them for the agent to choose + const matches = searchResults.map((r) => ({ + operationId: r.endpoint.operationId, + method: r.endpoint.method, + path: r.endpoint.path, + summary: r.endpoint.summary, + domain: r.domain.name, + })); + yield* writer.emitSuccess( + "godaddy api describe", + { + message: `Multiple endpoints match '${endpoint}'. Be more specific:`, + matches, + }, + matches.map((m) => ({ + command: "godaddy api describe ", + description: `${m.method} ${m.path} — ${m.summary}`, + params: { + endpoint: { + description: "Operation ID or path", + value: m.operationId, + required: true, + }, + }, + })), + ); + return; + } + } + + if (Option.isNone(result)) { + return yield* Effect.fail( + new ValidationError({ + message: `Endpoint '${endpoint}' not found`, + userMessage: `Endpoint '${endpoint}' not found in the API catalog. Run: godaddy api list or godaddy api search `, + }), + ); + } + + const { domain, endpoint: ep } = result.value; + + const payload = protectPayload( + { + domain: domain.name, + baseUrl: domain.baseUrl, + operationId: ep.operationId, + method: ep.method, + path: ep.path, + fullPath: `${domain.baseUrl}${ep.path}`.replace( + /^https:\/\/api\.godaddy\.com/, + "", + ), + summary: ep.summary, + description: ep.description, + parameters: ep.parameters, + requestBody: ep.requestBody, + responses: ep.responses, + scopes: ep.scopes, + }, + `api-describe-${ep.operationId}`, + ); + + yield* writer.emitSuccess( + "godaddy api describe", + { + ...payload.value, + truncated: payload.metadata?.truncated ?? false, + total: payload.metadata?.total, + shown: payload.metadata?.shown, + full_output: payload.metadata?.full_output, + }, + describeNextActions(domain, ep), + ); + }), +).pipe( + Command.withDescription( + "Show detailed schema information for an API endpoint", + ), +); + +// --------------------------------------------------------------------------- +// Subcommand: api search +// --------------------------------------------------------------------------- + +const apiSearch = Command.make( + "search", + { + query: Args.text({ name: "query" }).pipe( + Args.withDescription( + "Search term (matches operation ID, summary, description, path)", + ), + ), + }, + ({ query }) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + const results = yield* searchEndpointsEffect(query); + + const items = results.map((r) => ({ + operationId: r.endpoint.operationId, + method: r.endpoint.method, + path: r.endpoint.path, + summary: r.endpoint.summary, + domain: r.domain.name, + scopes: r.endpoint.scopes, + })); + + const truncated = truncateList(items, `api-search-${query}`); + + yield* writer.emitSuccess( + "godaddy api search", + { + query, + results: truncated.items, + total: truncated.metadata.total, + shown: truncated.metadata.shown, + truncated: truncated.metadata.truncated, + full_output: truncated.metadata.full_output, + }, + searchNextActions(items[0]?.operationId), + ); + }), +).pipe(Command.withDescription("Search for API endpoints by keyword")); + +// --------------------------------------------------------------------------- +// Subcommand: api call (the original raw request behavior) +// --------------------------------------------------------------------------- + +const apiCall = Command.make( + "call", + { + endpoint: Args.text({ name: "endpoint" }).pipe( + Args.withDescription( + "API endpoint (for example: /v1/commerce/location/addresses)", + ), ), method: Options.text("method").pipe( Options.withAlias("X"), @@ -213,13 +626,12 @@ const apiCommand = Command.make( body = yield* readBodyFromFileEffect(filePath); } - const requiredScopes = config.scope - .flatMap((s) => - s - .split(/[\s,]+/) - .map((t) => t.trim()) - .filter((t) => t.length > 0), - ); + const requiredScopes = config.scope.flatMap((s) => + s + .split(/[\s,]+/) + .map((t) => t.trim()) + .filter((t) => t.length > 0), + ); const requestOpts = { endpoint: config.endpoint, @@ -311,7 +723,7 @@ const apiCommand = Command.make( } yield* writer.emitSuccess( - "godaddy api", + "godaddy api call", { endpoint: config.endpoint.startsWith("/") ? config.endpoint @@ -324,11 +736,65 @@ const apiCommand = Command.make( : undefined, data: output ?? null, }, - apiRequestActions, + callNextActions(), ); }), ).pipe( Command.withDescription("Make authenticated requests to the GoDaddy API"), ); -export { apiCommand }; +// --------------------------------------------------------------------------- +// Parent command: godaddy api +// --------------------------------------------------------------------------- + +const apiParent = Command.make("api", {}, () => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + + const domains = yield* listDomainsEffect(); + + yield* writer.emitSuccess( + "godaddy api", + { + command: "godaddy api", + description: + "Explore and call GoDaddy API endpoints. Use subcommands to discover endpoints before making requests.", + commands: [ + { + command: "godaddy api list", + description: "List all API domains and their endpoints", + usage: "godaddy api list [--domain ]", + }, + { + command: "godaddy api describe ", + description: + "Show detailed schema information for an API endpoint (by operation ID or path)", + usage: "godaddy api describe ", + }, + { + command: "godaddy api search ", + description: "Search for API endpoints by keyword", + usage: "godaddy api search ", + }, + { + command: "godaddy api call ", + description: "Make an authenticated API request", + usage: + "godaddy api call [-X method] [-f field=value] [-F file] [-H header] [-q path] [-i] [-s scope]", + }, + ], + domains: domains.map((d) => ({ + name: d.name, + title: d.title, + endpoints: d.endpointCount, + })), + }, + apiGroupActions, + ); + }), +).pipe( + Command.withDescription("Explore and call GoDaddy API endpoints"), + Command.withSubcommands([apiList, apiDescribe, apiSearch, apiCall]), +); + +export { apiParent as apiCommand }; diff --git a/src/cli/schemas/api/index.ts b/src/cli/schemas/api/index.ts new file mode 100644 index 0000000..ffc23e6 --- /dev/null +++ b/src/cli/schemas/api/index.ts @@ -0,0 +1,224 @@ +/** + * Runtime loader for the API catalog. + * + * The pre-generated JSON files (produced by scripts/generate-api-catalog.ts) + * are imported directly so esbuild inlines them into the bundle. All public + * functions return Effect values — file I/O is resolved at build time, but + * the catalog access pattern stays within the Effect pipeline. + */ + +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +// --------------------------------------------------------------------------- +// Static imports — esbuild inlines these at bundle time +// --------------------------------------------------------------------------- + +import manifestJson from "./manifest.json"; +import locationAddressesJson from "./location-addresses.json"; + +// --------------------------------------------------------------------------- +// Types (match the generate-api-catalog output) +// --------------------------------------------------------------------------- + +export interface CatalogParameter { + name: string; + in: string; + required: boolean; + description?: string; + schema?: Record; +} + +export interface CatalogRequestBody { + required: boolean; + description?: string; + contentType: string; + schema?: Record; +} + +export interface CatalogResponse { + description: string; + schema?: Record; +} + +export interface CatalogEndpoint { + operationId: string; + method: string; + path: string; + summary: string; + description?: string; + parameters?: CatalogParameter[]; + requestBody?: CatalogRequestBody; + responses: Record; + scopes: string[]; +} + +export interface CatalogDomain { + name: string; + title: string; + description: string; + version: string; + baseUrl: string; + endpoints: CatalogEndpoint[]; +} + +interface ManifestEntry { + file: string; + title: string; + endpointCount: number; +} + +interface CatalogManifest { + generated: string; + domains: Record; +} + +// --------------------------------------------------------------------------- +// Domain registry — maps domain names to their inlined JSON data. +// When adding a new spec, add its import above and register it here. +// --------------------------------------------------------------------------- + +const DOMAIN_REGISTRY: Record = { + "location-addresses": locationAddressesJson as unknown as CatalogDomain, +}; + +const manifest = manifestJson as unknown as CatalogManifest; + +// --------------------------------------------------------------------------- +// Public API — all return Effect values +// --------------------------------------------------------------------------- + +/** + * Load the catalog manifest. + */ +export function loadManifestEffect(): Effect.Effect { + return Effect.succeed(manifest); +} + +/** + * List all available API domain names. + */ +export function listDomainNamesEffect(): Effect.Effect { + return Effect.succeed(Object.keys(manifest.domains)); +} + +/** + * Get manifest metadata for all domains. + */ +export function listDomainsEffect(): Effect.Effect< + Array<{ name: string; title: string; endpointCount: number }> +> { + return Effect.succeed( + Object.entries(manifest.domains).map(([name, entry]) => ({ + name, + title: entry.title, + endpointCount: entry.endpointCount, + })), + ); +} + +/** + * Load a single API domain catalog by name. + * Returns Option.none() if the domain is not in the registry. + */ +export function loadDomainEffect( + name: string, +): Effect.Effect> { + const domain = DOMAIN_REGISTRY[name]; + return Effect.succeed(domain ? Option.some(domain) : Option.none()); +} + +/** + * Find an endpoint by operation ID across all domains. + */ +export function findEndpointByOperationIdEffect( + operationId: string, +): Effect.Effect< + Option.Option<{ domain: CatalogDomain; endpoint: CatalogEndpoint }> +> { + return Effect.sync(() => { + for (const domain of Object.values(DOMAIN_REGISTRY)) { + const endpoint = domain.endpoints.find( + (e) => e.operationId === operationId, + ); + if (endpoint) return Option.some({ domain, endpoint }); + } + return Option.none(); + }); +} + +/** + * Find an endpoint by HTTP method + path across all domains. + */ +export function findEndpointByPathEffect( + method: string, + apiPath: string, +): Effect.Effect< + Option.Option<{ domain: CatalogDomain; endpoint: CatalogEndpoint }> +> { + return Effect.sync(() => { + const upperMethod = method.toUpperCase(); + for (const domain of Object.values(DOMAIN_REGISTRY)) { + const endpoint = domain.endpoints.find( + (e) => e.method === upperMethod && e.path === apiPath, + ); + if (endpoint) return Option.some({ domain, endpoint }); + } + return Option.none(); + }); +} + +/** + * Find an endpoint by path, trying all common HTTP methods. + */ +export function findEndpointByAnyMethodEffect( + apiPath: string, +): Effect.Effect< + Option.Option<{ domain: CatalogDomain; endpoint: CatalogEndpoint }> +> { + return Effect.gen(function* () { + for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) { + const result = yield* findEndpointByPathEffect(method, apiPath); + if (Option.isSome(result)) return result; + } + return Option.none(); + }); +} + +/** + * Search endpoints by keyword (matches against operationId, summary, + * description, and path). + */ +export function searchEndpointsEffect( + query: string, +): Effect.Effect< + Array<{ domain: CatalogDomain; endpoint: CatalogEndpoint }> +> { + return Effect.sync(() => { + const lower = query.toLowerCase(); + const results: Array<{ + domain: CatalogDomain; + endpoint: CatalogEndpoint; + }> = []; + + for (const domain of Object.values(DOMAIN_REGISTRY)) { + for (const endpoint of domain.endpoints) { + const searchable = [ + endpoint.operationId, + endpoint.summary, + endpoint.description || "", + endpoint.path, + endpoint.method, + ] + .join(" ") + .toLowerCase(); + + if (searchable.includes(lower)) { + results.push({ domain, endpoint }); + } + } + } + + return results; + }); +} diff --git a/src/cli/schemas/api/location-addresses.json b/src/cli/schemas/api/location-addresses.json new file mode 100644 index 0000000..7eb8e20 --- /dev/null +++ b/src/cli/schemas/api/location-addresses.json @@ -0,0 +1,496 @@ +{ + "name": "location-addresses", + "title": "Addresses API", + "description": "This API can be used to verify a given mailing addresses. The result will provide one or many matches back.", + "version": "1.1.0", + "baseUrl": "https://api.godaddy.com/v1/commerce", + "endpoints": [ + { + "operationId": "commerce.location.search-addresses", + "method": "GET", + "path": "/location/addresses", + "summary": "Search addresses for autocomplete", + "description": "Returns a list of addresses matching the search query for autocomplete functionality", + "parameters": [ + { + "name": "query", + "in": "query", + "required": true, + "description": "The address search query string", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "GET /location/addresses Successful response", + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "SUCCESS" + ] + }, + "data": { + "type": "object", + "properties": { + "matches": { + "type": "array", + "items": { + "type": "object", + "title": "Postal Address (Medium-Grained)", + "description": "An internationalized postal address. Maps to [AddressValidationMetadata](https://github.com/googlei18n/libaddressinput/wiki/AddressValidationMetadata) from Google's Address Data Service and HTML 5.1 [Autofilling form controls: the autocomplete attribute](https://www.w3.org/TR/html51/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute).", + "properties": { + "addressLine1": { + "type": "string", + "description": "The first line of the address. For example, number and street name. For example, `3032 Bunker Hill Lane`. Required for compliance and risk checks. Must contain the full address.", + "maxLength": 300 + }, + "addressLine2": { + "type": "string", + "description": "The second line of the address. For example, office suite or apartment number.", + "maxLength": 300 + }, + "addressLine3": { + "type": "string", + "description": "The third line of the address, if needed. For example, a street complement for Brazil; direction text, such as `next to Opera House`; or a landmark reference in an Indian address.", + "maxLength": 100 + }, + "adminArea4": { + "description": "The neighborhood, ward, or district. Smaller than `adminArea3` or `subLocality`. For example, the postal sorting code that is used in Guernsey and many French territories, such as French Guiana; the fine-grained administrative levels in China.", + "type": "string", + "maxLength": 100 + }, + "adminArea3": { + "description": "A sub-locality, suburb, neighborhood, or district. Smaller than `adminArea2`. For example, in Brazil - Suburb, bairro, or neighborhood; in India - Sub-locality or district. Street name information may not always available and a sub-locality or district can reference a very small area.", + "type": "string", + "maxLength": 100 + }, + "adminArea2": { + "description": "A city, town, or village. Smaller than `adminArea1`.", + "type": "string", + "maxLength": 300 + }, + "adminArea1": { + "type": "string", + "description": "The highest level sub-division in a country, which is usually a province, state, or ISO-3166-2 subdivision; formatted for postal delivery. For example, `CA` and not `California`. For example, for UK - A county; for USs - A state; for Canada - A province; for Japan - A prefecture; for Switzerland - A kanton.", + "maxLength": 300 + }, + "postalCode": { + "type": "string", + "description": "The postal code, which is the zip code or equivalent. Typically required for countries that have a postal code or an equivalent. See [Postal code](https://en.wikipedia.org/wiki/Postal_code).", + "maxLength": 60 + }, + "countryCode": { + "description": "The [two-character ISO 3166-1 code](https://en.wikipedia.org/wiki/ISO_3166-1) that identifies the country or region. Note: The country code for Great Britain is `GB` and not `UK` as used in the top-level domain names for that country. Use country code `C2` for China for comparable uncontrolled price (CUP) method, bank-card, and cross-border transactions.", + "type": "string", + "maxLength": 2, + "minLength": 2, + "pattern": "^([A-Z]{2}|C2)$" + }, + "addressDetails": { + "type": "object", + "title": "Address Details", + "description": "The non-portable additional address details that are sometimes needed for compliance, risk, or other scenarios where fine-grain address information might be needed. Not portable with common third-party and open-source address libraries and redundant with core fields. For example, `address.addressLine1` is usually a combination of `addressDetails.streetNumber` and `streetName` and `streetType`.", + "properties": { + "streetNumber": { + "description": "The street number.", + "type": "string", + "maxLength": 100 + }, + "streetName": { + "description": "The street name. Just `Drury` in `Drury Lane`.", + "type": "string", + "maxLength": 100 + }, + "streetType": { + "description": "The street type. For example, avenue, boulevard, road, or expressway.", + "type": "string", + "maxLength": 100 + }, + "deliveryService": { + "description": "The delivery service. Post office box, bag number, or post office name.", + "type": "string", + "maxLength": 100 + }, + "buildingName": { + "description": "A named locations that represents the premise. Usually a building name or number or collection of buildings with a common name or number. For example, Craven House.", + "type": "string", + "maxLength": 100 + }, + "subBuilding": { + "description": "The first-order entity below a named building or location that represents the sub-premise. Usually a single building within a collection of buildings with a common name. Can be a flat, story, floor, room, or apartment.", + "type": "string", + "maxLength": 100 + }, + "addressType": { + "description": "The type of address. Single character representation of a type. For example: 'B' for building, 'F' for organization, 'G' for general delivery, 'H' for high-rise, 'L' for large-volume organization, 'P' for Post Office box or delivery service, 'R' for rural route, 'S' for street, 'U' for unidentified address.", + "type": "string", + "maxLength": 1 + }, + "geoCoordinates": { + "type": "object", + "title": "Geographic Coordinates", + "description": "The latitude and longitude of the address. For example, `37.42242` and `-122.08585`.", + "properties": { + "longitude": { + "type": "string", + "pattern": "^-?([1]?[1-7][1-9]|[1]?[1-8][0]|[1-9]?[0-9])\\.{1}\\d{1,6}" + }, + "latitude": { + "type": "string", + "pattern": "^-?([1-8]?[1-9]|[1-9]0)\\.{1}\\d{1,6}" + } + }, + "required": [ + "longitude", + "latitude" + ] + } + } + } + }, + "required": [ + "countryCode" + ] + } + } + } + } + }, + "required": [ + "status", + "data" + ] + } + }, + "401": { + "description": "See https://schemas.api.godaddy.com/common-types/v1/schemas/yaml/error.yaml" + }, + "403": { + "description": "See https://schemas.api.godaddy.com/common-types/v1/schemas/yaml/error.yaml" + }, + "404": { + "description": "See https://schemas.api.godaddy.com/common-types/v1/schemas/yaml/error.yaml" + }, + "500": { + "description": "See https://schemas.api.godaddy.com/common-types/v1/schemas/yaml/error.yaml" + }, + "502": { + "description": "See https://schemas.api.godaddy.com/common-types/v1/schemas/yaml/error.yaml" + }, + "503": { + "description": "See https://schemas.api.godaddy.com/common-types/v1/schemas/yaml/error.yaml" + }, + "504": { + "description": "See https://schemas.api.godaddy.com/common-types/v1/schemas/yaml/error.yaml" + } + }, + "scopes": [ + "location.address-verification:execute" + ] + }, + { + "operationId": "commerce.location.verify-address", + "method": "POST", + "path": "/location/address-verifications", + "summary": "Verify a Mailing Address", + "requestBody": { + "required": true, + "description": "This is the address to be verified", + "contentType": "application/json", + "schema": { + "type": "object", + "title": "Postal Address (Medium-Grained)", + "description": "An internationalized postal address. Maps to [AddressValidationMetadata](https://github.com/googlei18n/libaddressinput/wiki/AddressValidationMetadata) from Google's Address Data Service and HTML 5.1 [Autofilling form controls: the autocomplete attribute](https://www.w3.org/TR/html51/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute).", + "properties": { + "addressLine1": { + "type": "string", + "description": "The first line of the address. For example, number and street name. For example, `3032 Bunker Hill Lane`. Required for compliance and risk checks. Must contain the full address.", + "maxLength": 300 + }, + "addressLine2": { + "type": "string", + "description": "The second line of the address. For example, office suite or apartment number.", + "maxLength": 300 + }, + "addressLine3": { + "type": "string", + "description": "The third line of the address, if needed. For example, a street complement for Brazil; direction text, such as `next to Opera House`; or a landmark reference in an Indian address.", + "maxLength": 100 + }, + "adminArea4": { + "description": "The neighborhood, ward, or district. Smaller than `adminArea3` or `subLocality`. For example, the postal sorting code that is used in Guernsey and many French territories, such as French Guiana; the fine-grained administrative levels in China.", + "type": "string", + "maxLength": 100 + }, + "adminArea3": { + "description": "A sub-locality, suburb, neighborhood, or district. Smaller than `adminArea2`. For example, in Brazil - Suburb, bairro, or neighborhood; in India - Sub-locality or district. Street name information may not always available and a sub-locality or district can reference a very small area.", + "type": "string", + "maxLength": 100 + }, + "adminArea2": { + "description": "A city, town, or village. Smaller than `adminArea1`.", + "type": "string", + "maxLength": 300 + }, + "adminArea1": { + "type": "string", + "description": "The highest level sub-division in a country, which is usually a province, state, or ISO-3166-2 subdivision; formatted for postal delivery. For example, `CA` and not `California`. For example, for UK - A county; for USs - A state; for Canada - A province; for Japan - A prefecture; for Switzerland - A kanton.", + "maxLength": 300 + }, + "postalCode": { + "type": "string", + "description": "The postal code, which is the zip code or equivalent. Typically required for countries that have a postal code or an equivalent. See [Postal code](https://en.wikipedia.org/wiki/Postal_code).", + "maxLength": 60 + }, + "countryCode": { + "description": "The [two-character ISO 3166-1 code](https://en.wikipedia.org/wiki/ISO_3166-1) that identifies the country or region. Note: The country code for Great Britain is `GB` and not `UK` as used in the top-level domain names for that country. Use country code `C2` for China for comparable uncontrolled price (CUP) method, bank-card, and cross-border transactions.", + "type": "string", + "maxLength": 2, + "minLength": 2, + "pattern": "^([A-Z]{2}|C2)$" + }, + "addressDetails": { + "type": "object", + "title": "Address Details", + "description": "The non-portable additional address details that are sometimes needed for compliance, risk, or other scenarios where fine-grain address information might be needed. Not portable with common third-party and open-source address libraries and redundant with core fields. For example, `address.addressLine1` is usually a combination of `addressDetails.streetNumber` and `streetName` and `streetType`.", + "properties": { + "streetNumber": { + "description": "The street number.", + "type": "string", + "maxLength": 100 + }, + "streetName": { + "description": "The street name. Just `Drury` in `Drury Lane`.", + "type": "string", + "maxLength": 100 + }, + "streetType": { + "description": "The street type. For example, avenue, boulevard, road, or expressway.", + "type": "string", + "maxLength": 100 + }, + "deliveryService": { + "description": "The delivery service. Post office box, bag number, or post office name.", + "type": "string", + "maxLength": 100 + }, + "buildingName": { + "description": "A named locations that represents the premise. Usually a building name or number or collection of buildings with a common name or number. For example, Craven House.", + "type": "string", + "maxLength": 100 + }, + "subBuilding": { + "description": "The first-order entity below a named building or location that represents the sub-premise. Usually a single building within a collection of buildings with a common name. Can be a flat, story, floor, room, or apartment.", + "type": "string", + "maxLength": 100 + }, + "addressType": { + "description": "The type of address. Single character representation of a type. For example: 'B' for building, 'F' for organization, 'G' for general delivery, 'H' for high-rise, 'L' for large-volume organization, 'P' for Post Office box or delivery service, 'R' for rural route, 'S' for street, 'U' for unidentified address.", + "type": "string", + "maxLength": 1 + }, + "geoCoordinates": { + "type": "object", + "title": "Geographic Coordinates", + "description": "The latitude and longitude of the address. For example, `37.42242` and `-122.08585`.", + "properties": { + "longitude": { + "type": "string", + "pattern": "^-?([1]?[1-7][1-9]|[1]?[1-8][0]|[1-9]?[0-9])\\.{1}\\d{1,6}" + }, + "latitude": { + "type": "string", + "pattern": "^-?([1-8]?[1-9]|[1-9]0)\\.{1}\\d{1,6}" + } + }, + "required": [ + "longitude", + "latitude" + ] + } + } + } + }, + "required": [ + "countryCode" + ] + } + }, + "responses": { + "200": { + "description": "POST /location/address-verifications Successful response", + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "SUCCESS" + ] + }, + "data": { + "type": "object", + "properties": { + "matches": { + "type": "array", + "items": { + "type": "object", + "title": "Postal Address (Medium-Grained)", + "description": "An internationalized postal address. Maps to [AddressValidationMetadata](https://github.com/googlei18n/libaddressinput/wiki/AddressValidationMetadata) from Google's Address Data Service and HTML 5.1 [Autofilling form controls: the autocomplete attribute](https://www.w3.org/TR/html51/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute).", + "properties": { + "addressLine1": { + "type": "string", + "description": "The first line of the address. For example, number and street name. For example, `3032 Bunker Hill Lane`. Required for compliance and risk checks. Must contain the full address.", + "maxLength": 300 + }, + "addressLine2": { + "type": "string", + "description": "The second line of the address. For example, office suite or apartment number.", + "maxLength": 300 + }, + "addressLine3": { + "type": "string", + "description": "The third line of the address, if needed. For example, a street complement for Brazil; direction text, such as `next to Opera House`; or a landmark reference in an Indian address.", + "maxLength": 100 + }, + "adminArea4": { + "description": "The neighborhood, ward, or district. Smaller than `adminArea3` or `subLocality`. For example, the postal sorting code that is used in Guernsey and many French territories, such as French Guiana; the fine-grained administrative levels in China.", + "type": "string", + "maxLength": 100 + }, + "adminArea3": { + "description": "A sub-locality, suburb, neighborhood, or district. Smaller than `adminArea2`. For example, in Brazil - Suburb, bairro, or neighborhood; in India - Sub-locality or district. Street name information may not always available and a sub-locality or district can reference a very small area.", + "type": "string", + "maxLength": 100 + }, + "adminArea2": { + "description": "A city, town, or village. Smaller than `adminArea1`.", + "type": "string", + "maxLength": 300 + }, + "adminArea1": { + "type": "string", + "description": "The highest level sub-division in a country, which is usually a province, state, or ISO-3166-2 subdivision; formatted for postal delivery. For example, `CA` and not `California`. For example, for UK - A county; for USs - A state; for Canada - A province; for Japan - A prefecture; for Switzerland - A kanton.", + "maxLength": 300 + }, + "postalCode": { + "type": "string", + "description": "The postal code, which is the zip code or equivalent. Typically required for countries that have a postal code or an equivalent. See [Postal code](https://en.wikipedia.org/wiki/Postal_code).", + "maxLength": 60 + }, + "countryCode": { + "description": "The [two-character ISO 3166-1 code](https://en.wikipedia.org/wiki/ISO_3166-1) that identifies the country or region. Note: The country code for Great Britain is `GB` and not `UK` as used in the top-level domain names for that country. Use country code `C2` for China for comparable uncontrolled price (CUP) method, bank-card, and cross-border transactions.", + "type": "string", + "maxLength": 2, + "minLength": 2, + "pattern": "^([A-Z]{2}|C2)$" + }, + "addressDetails": { + "type": "object", + "title": "Address Details", + "description": "The non-portable additional address details that are sometimes needed for compliance, risk, or other scenarios where fine-grain address information might be needed. Not portable with common third-party and open-source address libraries and redundant with core fields. For example, `address.addressLine1` is usually a combination of `addressDetails.streetNumber` and `streetName` and `streetType`.", + "properties": { + "streetNumber": { + "description": "The street number.", + "type": "string", + "maxLength": 100 + }, + "streetName": { + "description": "The street name. Just `Drury` in `Drury Lane`.", + "type": "string", + "maxLength": 100 + }, + "streetType": { + "description": "The street type. For example, avenue, boulevard, road, or expressway.", + "type": "string", + "maxLength": 100 + }, + "deliveryService": { + "description": "The delivery service. Post office box, bag number, or post office name.", + "type": "string", + "maxLength": 100 + }, + "buildingName": { + "description": "A named locations that represents the premise. Usually a building name or number or collection of buildings with a common name or number. For example, Craven House.", + "type": "string", + "maxLength": 100 + }, + "subBuilding": { + "description": "The first-order entity below a named building or location that represents the sub-premise. Usually a single building within a collection of buildings with a common name. Can be a flat, story, floor, room, or apartment.", + "type": "string", + "maxLength": 100 + }, + "addressType": { + "description": "The type of address. Single character representation of a type. For example: 'B' for building, 'F' for organization, 'G' for general delivery, 'H' for high-rise, 'L' for large-volume organization, 'P' for Post Office box or delivery service, 'R' for rural route, 'S' for street, 'U' for unidentified address.", + "type": "string", + "maxLength": 1 + }, + "geoCoordinates": { + "type": "object", + "title": "Geographic Coordinates", + "description": "The latitude and longitude of the address. For example, `37.42242` and `-122.08585`.", + "properties": { + "longitude": { + "type": "string", + "pattern": "^-?([1]?[1-7][1-9]|[1]?[1-8][0]|[1-9]?[0-9])\\.{1}\\d{1,6}" + }, + "latitude": { + "type": "string", + "pattern": "^-?([1-8]?[1-9]|[1-9]0)\\.{1}\\d{1,6}" + } + }, + "required": [ + "longitude", + "latitude" + ] + } + } + } + }, + "required": [ + "countryCode" + ] + } + } + } + } + }, + "required": [ + "status", + "data" + ] + } + }, + "401": { + "description": "See https://schemas.api.godaddy.com/common-types/v1/schemas/yaml/error.yaml" + }, + "403": { + "description": "See https://schemas.api.godaddy.com/common-types/v1/schemas/yaml/error.yaml" + }, + "404": { + "description": "See https://schemas.api.godaddy.com/common-types/v1/schemas/yaml/error.yaml" + }, + "500": { + "description": "See https://schemas.api.godaddy.com/common-types/v1/schemas/yaml/error.yaml" + }, + "502": { + "description": "See https://schemas.api.godaddy.com/common-types/v1/schemas/yaml/error.yaml" + }, + "503": { + "description": "See https://schemas.api.godaddy.com/common-types/v1/schemas/yaml/error.yaml" + }, + "504": { + "description": "See https://schemas.api.godaddy.com/common-types/v1/schemas/yaml/error.yaml" + } + }, + "scopes": [ + "location.address-verification:execute" + ] + } + ] +} \ No newline at end of file diff --git a/src/cli/schemas/api/manifest.json b/src/cli/schemas/api/manifest.json new file mode 100644 index 0000000..3b8ce6b --- /dev/null +++ b/src/cli/schemas/api/manifest.json @@ -0,0 +1,10 @@ +{ + "generated": "2026-02-26T02:05:00.692Z", + "domains": { + "location-addresses": { + "file": "location-addresses.json", + "title": "Addresses API", + "endpointCount": 2 + } + } +} \ No newline at end of file diff --git a/src/core/applications.ts b/src/core/applications.ts index f3806ab..00b1ec0 100644 --- a/src/core/applications.ts +++ b/src/core/applications.ts @@ -1,4 +1,4 @@ -import { resolve } from "node:path"; +import { isAbsolute, relative, resolve } from "node:path"; import type { Fetch } from "@effect/platform/FetchHttpClient"; import { FileSystem } from "@effect/platform/FileSystem"; import { type ArkErrors, type } from "arktype"; @@ -24,6 +24,7 @@ import { import { type ActionConfig, type Config, + type ConfigExtensionInfo, type SubscriptionConfig, createConfigFileEffect, createEnvFileEffect, @@ -238,6 +239,46 @@ function emitProgress( }); } +function isPathWithin(basePath: string, candidatePath: string): boolean { + const rel = relative(basePath, candidatePath); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +function resolveExtensionPathsEffect( + repoRoot: string, + extension: ConfigExtensionInfo, +): Effect.Effect< + { extensionDir: string; sourcePath: string }, + ValidationError +> { + return Effect.gen(function* () { + const extensionsRoot = resolve(repoRoot, "extensions"); + const extensionDir = resolve(extensionsRoot, extension.handle); + if (!isPathWithin(extensionsRoot, extensionDir)) { + return yield* Effect.fail( + new ValidationError({ + message: `Invalid extension handle path: ${extension.handle}`, + userMessage: + "Invalid extension handle path. Extension directories must stay within ./extensions.", + }), + ); + } + + const sourcePath = resolve(extensionDir, extension.source); + if (!isPathWithin(extensionDir, sourcePath)) { + return yield* Effect.fail( + new ValidationError({ + message: `Invalid extension source path for '${extension.name}': ${extension.source}`, + userMessage: + "Invalid extension source path. Source files must stay within the extension directory.", + }), + ); + } + + return { extensionDir, sourcePath }; + }); +} + /** * Clean up bundle artifacts (best-effort, errors ignored). */ @@ -1014,7 +1055,10 @@ export function applicationDeployEffect( // Scan each extension (scan the directory containing the source file) for (const [index, extension] of extensions.entries()) { - const extensionDir = resolve(repoRoot, "extensions", extension.handle); + const { extensionDir } = yield* resolveExtensionPathsEffect( + repoRoot, + extension, + ); yield* emitProgress(options, { type: "step", name: "scan.prebundle", @@ -1090,8 +1134,10 @@ export function applicationDeployEffect( const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); for (const [index, extension] of extensions.entries()) { - const extensionDir = resolve(repoRoot, "extensions", extension.handle); - const sourcePath = resolve(extensionDir, extension.source); + const { extensionDir, sourcePath } = yield* resolveExtensionPathsEffect( + repoRoot, + extension, + ); yield* emitProgress(options, { type: "step", name: "bundle", diff --git a/src/services/config.ts b/src/services/config.ts index d65f11c..82b7dd2 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -167,6 +167,11 @@ function toConfigError( }); } +function formatEnvValue(value: string): string { + // Always quote to prevent newline/comment/env injection in .env files. + return JSON.stringify(value.replace(/\0/g, "")); +} + function resolveConfigEnvironment( env?: ConfigEnvironment, ): ConfigEnvironment | undefined { @@ -704,10 +709,10 @@ export function createEnvFileEffect( } } - envVars.GODADDY_WEBHOOK_SECRET = secret; - envVars.GODADDY_PUBLIC_KEY = publicKey; - envVars.GODADDY_CLIENT_ID = clientId; - envVars.GODADDY_CLIENT_SECRET = clientSecret; + envVars.GODADDY_WEBHOOK_SECRET = formatEnvValue(secret); + envVars.GODADDY_PUBLIC_KEY = formatEnvValue(publicKey); + envVars.GODADDY_CLIENT_ID = formatEnvValue(clientId); + envVars.GODADDY_CLIENT_SECRET = formatEnvValue(clientSecret); envContent = Object.entries(envVars) .map(([key, value]) => `${key}=${value}`) @@ -719,10 +724,10 @@ export function createEnvFileEffect( } } } else { - envContent = `GODADDY_WEBHOOK_SECRET=${secret}\nGODADDY_PUBLIC_KEY=${publicKey}\nGODADDY_CLIENT_ID=${clientId}\nGODADDY_CLIENT_SECRET=${clientSecret}`; + envContent = `GODADDY_WEBHOOK_SECRET=${formatEnvValue(secret)}\nGODADDY_PUBLIC_KEY=${formatEnvValue(publicKey)}\nGODADDY_CLIENT_ID=${formatEnvValue(clientId)}\nGODADDY_CLIENT_SECRET=${formatEnvValue(clientSecret)}`; } } else { - envContent = `GODADDY_WEBHOOK_SECRET=${secret}\nGODADDY_PUBLIC_KEY=${publicKey}\nGODADDY_CLIENT_ID=${clientId}\nGODADDY_CLIENT_SECRET=${clientSecret}`; + envContent = `GODADDY_WEBHOOK_SECRET=${formatEnvValue(secret)}\nGODADDY_PUBLIC_KEY=${formatEnvValue(publicKey)}\nGODADDY_CLIENT_ID=${formatEnvValue(clientId)}\nGODADDY_CLIENT_SECRET=${formatEnvValue(clientSecret)}`; } yield* fs.writeFileString(envPath, envContent); diff --git a/tests/integration/cli-smoke.test.ts b/tests/integration/cli-smoke.test.ts index ad6b447..0eb28bf 100644 --- a/tests/integration/cli-smoke.test.ts +++ b/tests/integration/cli-smoke.test.ts @@ -87,6 +87,88 @@ describe("CLI Smoke Tests", () => { expect(Array.isArray(payload.result.commands)).toBe(true); }); + it("api list returns catalog domains", () => { + const result = runCli(["api", "list"]); + expect(result.status).toBe(0); + + const payload = JSON.parse(result.stdout) as { + ok: boolean; + command: string; + result: { domains: Array<{ name: string }> }; + }; + expect(payload.ok).toBe(true); + expect(payload.command).toBe("godaddy api list"); + expect( + payload.result.domains.some( + (domain) => domain.name === "location-addresses", + ), + ).toBe(true); + }); + + it("api describe returns endpoint details", () => { + const result = runCli([ + "api", + "describe", + "commerce.location.verify-address", + ]); + expect(result.status).toBe(0); + + const payload = JSON.parse(result.stdout) as { + ok: boolean; + command: string; + result: { operationId: string; method: string; path: string }; + next_actions: Array<{ + command: string; + params?: Record; + }>; + }; + expect(payload.ok).toBe(true); + expect(payload.command).toBe("godaddy api describe"); + expect(payload.result.operationId).toBe("commerce.location.verify-address"); + expect(payload.result.method).toBe("POST"); + expect(payload.result.path).toBe("/location/address-verifications"); + expect(payload.next_actions[0]?.command).toBe( + "godaddy api call ", + ); + expect(payload.next_actions[0]?.params?.endpoint?.value).toBe( + "/v1/commerce/location/address-verifications", + ); + }); + + it("api search returns matching endpoints", () => { + const result = runCli(["api", "search", "address"]); + expect(result.status).toBe(0); + + const payload = JSON.parse(result.stdout) as { + ok: boolean; + command: string; + result: { results: Array<{ operationId: string }> }; + next_actions: Array<{ + command: string; + params?: Record; + }>; + }; + expect(payload.ok).toBe(true); + expect(payload.command).toBe("godaddy api search"); + expect( + payload.result.results.some( + (item) => item.operationId === "commerce.location.verify-address", + ), + ).toBe(true); + expect(payload.next_actions[0]?.command).toBe( + "godaddy api describe ", + ); + }); + + it("legacy api endpoint syntax routes to api call", () => { + const result = runCli(["api", "/v1/commerce/location/addresses", "--help"]); + expect(result.status).toBe(0); + expect(result.stdout).toContain( + "Make authenticated requests to the GoDaddy API", + ); + expect(result.stdout).toContain(""); + }); + it("unknown command returns structured error envelope", () => { const result = runCli(["nonexistent-command"]); expect(result.status).toBe(1); diff --git a/tests/unit/application-deploy-security.test.ts b/tests/unit/application-deploy-security.test.ts index 7cbf61f..fa742fa 100644 --- a/tests/unit/application-deploy-security.test.ts +++ b/tests/unit/application-deploy-security.test.ts @@ -390,6 +390,62 @@ exec('dangerous command'); // SEC001 } }); + test("deployment rejects extension handle traversal outside ./extensions", async () => { + const originalCwd = process.cwd(); + process.chdir(testDir); + + try { + getExtensionsFromConfigSpy.mockReturnValue( + Effect.succeed([ + { + type: "embed", + name: "@test/bad-handle", + handle: "../outside", + source: "index.ts", + targets: [{ target: "body.start" }], + }, + ]), + ); + + const exit = await runEffectExit(applicationDeployEffect("test-app")); + const err = extractFailure(exit) as { userMessage: string }; + expect(err.userMessage).toContain("handle path"); + expect( + applicationsService.updateApplicationEffect, + ).not.toHaveBeenCalled(); + } finally { + process.chdir(originalCwd); + } + }); + + test("deployment rejects extension source traversal outside extension directory", async () => { + const originalCwd = process.cwd(); + process.chdir(testDir); + + try { + getExtensionsFromConfigSpy.mockReturnValue( + Effect.succeed([ + { + type: "embed", + name: "@test/bad-source", + handle: "bad-source", + source: "../outside.ts", + targets: [{ target: "body.start" }], + }, + ]), + ); + + const exit = await runEffectExit(applicationDeployEffect("test-app")); + const err = extractFailure(exit) as { userMessage: string }; + expect(err.userMessage).toContain("source path"); + expect( + applicationsService.updateApplicationEffect, + ).not.toHaveBeenCalled(); + } finally { + process.chdir(originalCwd); + } + }); + test("deployment cleans bundle artifacts when upload fails", async () => { const originalCwd = process.cwd(); process.chdir(testDir); diff --git a/tests/unit/cli-registry.test.ts b/tests/unit/cli-registry.test.ts index 7efd6ed..13bd59c 100644 --- a/tests/unit/cli-registry.test.ts +++ b/tests/unit/cli-registry.test.ts @@ -33,7 +33,7 @@ describe("CLI command tree coverage", () => { const ids = children.map((c: { id: string }) => c.id); expect(ids).toContain("auth.group"); expect(ids).toContain("env.group"); - expect(ids).toContain("api.request"); + expect(ids).toContain("api.group"); expect(ids).toContain("actions.group"); expect(ids).toContain("webhook.group"); expect(ids).toContain("application.group"); @@ -65,7 +65,7 @@ describe("CLI command tree coverage", () => { }); it("sub-group envelopes include next_actions", () => { - const groupCommands = ["application", "auth", "env", "actions", "webhook"]; + const groupCommands = ["application", "auth", "env", "actions", "webhook", "api"]; for (const group of groupCommands) { const result = runCli([group]); if (result.status === 0) { diff --git a/tests/unit/cli/truncation.test.ts b/tests/unit/cli/truncation.test.ts new file mode 100644 index 0000000..3287606 --- /dev/null +++ b/tests/unit/cli/truncation.test.ts @@ -0,0 +1,71 @@ +import { existsSync, rmSync, statSync } from "node:fs"; +import { dirname } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { + protectPayload, + truncateList, +} from "../../../src/cli/agent/truncation"; + +const createdFiles = new Set(); + +afterEach(() => { + for (const filePath of createdFiles) { + if (existsSync(filePath)) { + rmSync(filePath, { force: true }); + } + } + createdFiles.clear(); +}); + +function assertOwnerOnlyPermissions(filePath: string): void { + const fileMode = statSync(filePath).mode & 0o777; + const dirMode = statSync(dirname(filePath)).mode & 0o777; + + // Group/other permissions should be zero on POSIX systems. + expect(fileMode & 0o077).toBe(0); + expect(dirMode & 0o077).toBe(0); +} + +describe("truncation full output", () => { + test("writes list full_output with owner-only permissions", () => { + const list = Array.from({ length: 51 }, (_, index) => ({ + id: index, + value: `item-${index}`, + })); + + const result = truncateList(list, "security-list-test"); + expect(result.metadata.truncated).toBe(true); + expect(result.metadata.full_output).toBeDefined(); + + const fullOutput = result.metadata.full_output; + if (!fullOutput) { + throw new Error("expected full_output path"); + } + + createdFiles.add(fullOutput); + expect(existsSync(fullOutput)).toBe(true); + + if (process.platform !== "win32") { + assertOwnerOnlyPermissions(fullOutput); + } + }); + + test("writes payload full_output with owner-only permissions", () => { + const hugeValue = "x".repeat(20_000); + const result = protectPayload({ hugeValue }, "security-payload-test"); + expect(result.metadata?.truncated).toBe(true); + expect(result.metadata?.full_output).toBeDefined(); + + const fullOutput = result.metadata?.full_output; + if (!fullOutput) { + throw new Error("expected full_output path"); + } + + createdFiles.add(fullOutput); + expect(existsSync(fullOutput)).toBe(true); + + if (process.platform !== "win32") { + assertOwnerOnlyPermissions(fullOutput); + } + }); +}); diff --git a/tests/unit/services/config-routing.test.ts b/tests/unit/services/config-routing.test.ts index eba5615..9711493 100644 --- a/tests/unit/services/config-routing.test.ts +++ b/tests/unit/services/config-routing.test.ts @@ -85,4 +85,29 @@ describe("Config Environment Routing", () => { expect(fs.existsSync(path.join(tempDir, ".env.test"))).toBe(true); expect(fs.existsSync(path.join(tempDir, ".env.ote"))).toBe(false); }); + + test("quotes env values to prevent multiline/env injection", async () => { + await runEffect( + createEnvFileEffect( + { + secret: "line1\nINJECTED_KEY=evil", + publicKey: "public#key", + clientId: 'client"id', + clientSecret: "client\\secret", + }, + "ote", + ), + ); + + const envPath = path.join(tempDir, ".env.ote"); + const content = fs.readFileSync(envPath, "utf-8"); + + expect(content).toContain( + 'GODADDY_WEBHOOK_SECRET="line1\\nINJECTED_KEY=evil"', + ); + expect(content).toContain('GODADDY_PUBLIC_KEY="public#key"'); + expect(content).toContain('GODADDY_CLIENT_ID="client\\"id"'); + expect(content).toContain('GODADDY_CLIENT_SECRET="client\\\\secret"'); + expect(content).not.toMatch(/^INJECTED_KEY=/m); + }); });