diff --git a/.changeset/config.json b/.changeset/config.json index 38a3feb..b821332 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,11 +1,11 @@ { - "$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json", - "changelog": "@changesets/cli/changelog", - "commit": false, - "fixed": [], - "linked": [], - "access": "public", - "baseBranch": "main", - "updateInternalDependencies": "patch", - "ignore": [] + "$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] } diff --git a/biome.json b/biome.json index 22afd3f..5051962 100644 --- a/biome.json +++ b/biome.json @@ -1,39 +1,40 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "vcs": { - "enabled": false, - "clientKind": "git", - "useIgnoreFile": false - }, - "files": { - "ignoreUnknown": false, - "ignore": [ - "node_modules", - "dist", - "build", - "tests/fixtures/**/*.mjs", - "tests/fixtures/extensions/invalid-syntax/**" - ] - }, - "formatter": { - "enabled": true, - "indentStyle": "tab" - }, - "organizeImports": { - "enabled": true - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "style": { - "noNonNullAssertion": "off" - } - } - }, - "javascript": { - "formatter": { - "quoteStyle": "double" - } - } + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "ignore": [ + "node_modules", + "dist", + "build", + "tests/fixtures/**/*.mjs", + "tests/fixtures/extensions/invalid-syntax/**" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "noNonNullAssertion": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } } diff --git a/build.mjs b/build.mjs index f97b33d..13b34f8 100644 --- a/build.mjs +++ b/build.mjs @@ -11,17 +11,17 @@ const OUT_DIR = "dist"; * to make this work, we have to strip the import out of the build. */ const ignoreReactDevToolsPlugin = { - name: "ignore-react-devtools", - setup(build) { - // When an import for 'react-devtools-core' is encountered, - // return an empty module. - build.onResolve({ filter: /^react-devtools-core$/ }, (args) => { - return { path: args.path, namespace: "ignore-devtools" }; - }); - build.onLoad({ filter: /.*/, namespace: "ignore-devtools" }, () => { - return { contents: "", loader: "js" }; - }); - }, + name: "ignore-react-devtools", + setup(build) { + // When an import for 'react-devtools-core' is encountered, + // return an empty module. + build.onResolve({ filter: /^react-devtools-core$/ }, (args) => { + return { path: args.path, namespace: "ignore-devtools" }; + }); + build.onLoad({ filter: /.*/, namespace: "ignore-devtools" }, () => { + return { contents: "", loader: "js" }; + }); + }, }; // ---------------------------------------------------------------------------- @@ -35,61 +35,61 @@ const ignoreReactDevToolsPlugin = { // ---------------------------------------------------------------------------- const isDevBuild = - process.argv.includes("--dev") || process.env.NODE_ENV === "development"; + process.argv.includes("--dev") || process.env.NODE_ENV === "development"; const plugins = [ignoreReactDevToolsPlugin]; // Build Hygiene, ensure we drop previous dist dir and any leftover files const outPath = path.resolve(OUT_DIR); if (fs.existsSync(outPath)) { - fs.rmSync(outPath, { recursive: true, force: true }); + fs.rmSync(outPath, { recursive: true, force: true }); } // Add a shebang that enables source‑map support for dev builds so that stack // traces point to the original TypeScript lines without requiring callers to // remember to set NODE_OPTIONS manually. if (isDevBuild) { - const devShebangLine = - "#!/usr/bin/env -S NODE_OPTIONS=--enable-source-maps node\n"; - const devShebangPlugin = { - name: "dev-shebang", - setup(build) { - build.onEnd(async () => { - const outFile = path.resolve(`${OUT_DIR}/cli-dev.js`); + const devShebangLine = + "#!/usr/bin/env -S NODE_OPTIONS=--enable-source-maps node\n"; + const devShebangPlugin = { + name: "dev-shebang", + setup(build) { + build.onEnd(async () => { + const outFile = path.resolve(`${OUT_DIR}/cli-dev.js`); - let code = await fs.promises.readFile(outFile, "utf8"); - if (code.startsWith("#!")) { - code = code.replace(/^#!.*\n/, devShebangLine); - await fs.promises.writeFile(outFile, code, "utf8"); - } - }); - }, - }; - plugins.push(devShebangPlugin); + let code = await fs.promises.readFile(outFile, "utf8"); + if (code.startsWith("#!")) { + code = code.replace(/^#!.*\n/, devShebangLine); + await fs.promises.writeFile(outFile, code, "utf8"); + } + }); + }, + }; + plugins.push(devShebangPlugin); } esbuild - .build({ - entryPoints: ["src/index.ts"], - bundle: true, - format: "esm", - platform: "node", - tsconfig: "tsconfig.json", - outfile: `${OUT_DIR}/${isDevBuild ? "cli-dev.js" : "cli.js"}`, - minify: !isDevBuild, - sourcemap: isDevBuild ? "inline" : true, - plugins, - inject: ["./require-shim.js"], - external: [ - // Exclude native modules from bundling - "*.node", - // TypeScript is a peer dependency and should not be bundled - "typescript", - // pino-pretty uses worker threads and must be external - "pino-pretty", - ], - }) - .catch((e) => { - console.error(e); - process.exit(1); - }); + .build({ + entryPoints: ["src/index.ts"], + bundle: true, + format: "esm", + platform: "node", + tsconfig: "tsconfig.json", + outfile: `${OUT_DIR}/${isDevBuild ? "cli-dev.js" : "cli.js"}`, + minify: !isDevBuild, + sourcemap: isDevBuild ? "inline" : true, + plugins, + inject: ["./require-shim.js"], + external: [ + // Exclude native modules from bundling + "*.node", + // TypeScript is a peer dependency and should not be bundled + "typescript", + // pino-pretty uses worker threads and must be external + "pino-pretty", + ], + }) + .catch((e) => { + console.error(e); + process.exit(1); + }); diff --git a/package.json b/package.json index 58913f1..b402658 100644 --- a/package.json +++ b/package.json @@ -1,81 +1,75 @@ { - "name": "@godaddy/cli", - "version": "0.2.3", - "description": "GoDaddy CLI for managing applications and webhooks", - "keywords": [ - "godaddy", - "cli", - "developer-tools" - ], - "main": "./dist/cli.js", - "type": "module", - "bin": { - "godaddy": "./dist/cli.js" - }, - "files": [ - "dist" - ], - "scripts": { - "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", - "test": "vitest", - "test:ui": "vitest --ui", - "test:coverage": "vitest --coverage", - "changeset": "changeset", - "version": "changeset version", - "release": "pnpm build && changeset publish" - }, - "devDependencies": { - "@biomejs/biome": "1.9.4", - "@changesets/cli": "^2.29.8", - "@types/node": "^22.14.1", - "@types/picomatch": "^4.0.2", - "@types/prompts": "^2.4.9", - "@types/react": "18.3.1", - "@types/semver": "^7.7.0", - "@vitest/coverage-v8": "^3.2.2", - "@vitest/ui": "^3.2.2", - "esbuild": "^0.25.12", - "ms": "^2.1.3", - "msw": "^2.4.0", - "tsx": "^4.19.3", - "vitest": "^3.2.2", - "yaml": "^2.8.2" - }, - "peerDependencies": { - "typescript": "^5.0.0" - }, - "dependencies": { - "@clack/prompts": "^0.10.0", - "@effect/cli": "^0.73.2", - "@effect/platform": "^0.94.5", - "@effect/platform-node": "^0.104.1", - "@iarna/toml": "^2.2.5", - "@inkjs/ui": "^2.0.0", - "@tanstack/react-query": "^5.68.0", - "arktype": "^2.1.9", - "copy-to-clipboard": "^3.3.3", - "effect": "^3.19.19", - "gql.tada": "^1.8.10", - "graphql": "^16.10.0", - "graphql-request": "^7.1.2", - "ink": "^5.2.0", - "ink-select-input": "^6.0.0", - "open": "^10.1.1", - "picomatch": "^4.0.3", - "pino": "^9.6.0", - "pino-pretty": "^13.0.0", - "react": "18.3.1", - "semver": "^7.7.1", - "uuid": "^11.1.0" - }, - "publishConfig": { - "access": "public" - }, - "packageManager": "pnpm@10.14.0" + "name": "@godaddy/cli", + "version": "0.2.3", + "description": "GoDaddy CLI for managing applications and webhooks", + "keywords": ["godaddy", "cli", "developer-tools"], + "main": "./dist/cli.js", + "type": "module", + "bin": { + "godaddy": "./dist/cli.js" + }, + "files": ["dist"], + "scripts": { + "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", + "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest --coverage", + "changeset": "changeset", + "version": "changeset version", + "release": "pnpm build && changeset publish" + }, + "devDependencies": { + "@biomejs/biome": "1.9.4", + "@changesets/cli": "^2.29.8", + "@types/node": "^22.14.1", + "@types/picomatch": "^4.0.2", + "@types/prompts": "^2.4.9", + "@types/react": "18.3.1", + "@types/semver": "^7.7.0", + "@vitest/coverage-v8": "^3.2.2", + "@vitest/ui": "^3.2.2", + "esbuild": "^0.25.12", + "ms": "^2.1.3", + "msw": "^2.4.0", + "tsx": "^4.19.3", + "vitest": "^3.2.2", + "yaml": "^2.8.2" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@clack/prompts": "^0.10.0", + "@effect/cli": "^0.73.2", + "@effect/platform": "^0.94.5", + "@effect/platform-node": "^0.104.1", + "@iarna/toml": "^2.2.5", + "@inkjs/ui": "^2.0.0", + "@tanstack/react-query": "^5.68.0", + "arktype": "^2.1.9", + "copy-to-clipboard": "^3.3.3", + "effect": "^3.19.19", + "gql.tada": "^1.8.10", + "graphql": "^16.10.0", + "graphql-request": "^7.1.2", + "ink": "^5.2.0", + "ink-select-input": "^6.0.0", + "open": "^10.1.1", + "picomatch": "^4.0.3", + "pino": "^9.6.0", + "pino-pretty": "^13.0.0", + "react": "18.3.1", + "semver": "^7.7.1", + "uuid": "^11.1.0" + }, + "publishConfig": { + "access": "public" + }, + "packageManager": "pnpm@10.14.0" } diff --git a/scripts/generate-api-catalog.ts b/scripts/generate-api-catalog.ts index b94723a..9c11c1c 100644 --- a/scripts/generate-api-catalog.ts +++ b/scripts/generate-api-catalog.ts @@ -24,55 +24,55 @@ import { parse as parseYaml } from "yaml"; // --------------------------------------------------------------------------- interface OpenApiParameter { - name: string; - in: string; - required?: boolean; - description?: string; - schema?: Record; + name: string; + in: string; + required?: boolean; + description?: string; + schema?: Record; } interface OpenApiRequestBody { - description?: string; - required?: boolean; - content?: Record }>; + description?: string; + required?: boolean; + content?: Record }>; } interface OpenApiResponse { - description?: string; - content?: Record }>; + description?: string; + content?: Record }>; } interface OpenApiOperation { - operationId?: string; - summary?: string; - description?: string; - parameters?: OpenApiParameter[]; - requestBody?: OpenApiRequestBody; - responses?: Record; - security?: Array>; + operationId?: string; + summary?: string; + description?: string; + parameters?: OpenApiParameter[]; + requestBody?: OpenApiRequestBody; + responses?: Record; + security?: Array>; } interface OpenApiPathItem { - [method: string]: OpenApiOperation | OpenApiParameter[] | undefined; - parameters?: OpenApiParameter[]; + [method: string]: OpenApiOperation | OpenApiParameter[] | undefined; + parameters?: OpenApiParameter[]; } interface OpenApiServer { - url: string; - variables?: Record; + url: string; + variables?: Record; } interface OpenApiSpec { - openapi: string; - info: { - title: string; - description?: string; - version: string; - contact?: Record; - }; - paths: Record; - servers?: OpenApiServer[]; - components?: Record; + openapi: string; + info: { + title: string; + description?: string; + version: string; + contact?: Record; + }; + paths: Record; + servers?: OpenApiServer[]; + components?: Record; } // --------------------------------------------------------------------------- @@ -80,49 +80,49 @@ interface OpenApiSpec { // --------------------------------------------------------------------------- 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[]; + 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[]; + 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 } - >; + generated: string; + domains: Record< + string, + { file: string; title: string; endpointCount: number } + >; } // --------------------------------------------------------------------------- @@ -130,10 +130,10 @@ interface CatalogManifest { // --------------------------------------------------------------------------- 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; + /** 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); @@ -141,12 +141,12 @@ 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" }, + { + 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"]); @@ -162,249 +162,249 @@ 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; + 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; + 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); + 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, + 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); + 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, + 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}'`, - ); + 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; + 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; } /** @@ -414,12 +414,12 @@ async function fetchExternalRef(url: string): Promise> { * 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(); + 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(); } /** @@ -429,34 +429,34 @@ function resolveRefUrl(ref: string, baseUrl?: string): string | null { * 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; + 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; } // --------------------------------------------------------------------------- @@ -464,138 +464,138 @@ async function resolveRefs(obj: unknown, parentUrl?: string): Promise { // --------------------------------------------------------------------------- const HTTP_METHODS = new Set([ - "get", - "post", - "put", - "patch", - "delete", - "options", - "head", - "trace", + "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; + 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)]; + 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[], + 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), - }; + // 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, - }; + 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, + }; } // --------------------------------------------------------------------------- @@ -603,63 +603,63 @@ function processSpec(spec: OpenApiSpec, domain: string): CatalogDomain { // --------------------------------------------------------------------------- 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`, - ); + 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); + console.error("Fatal error:", err); + process.exit(1); }); diff --git a/skills-lock.json b/skills-lock.json index 2bb39fe..fe85b9c 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,10 +1,10 @@ { - "version": 1, - "skills": { - "cli-design": { - "source": "joelhooks/joelclaw", - "sourceType": "github", - "computedHash": "2e7baa5bbb3ef5be51e4dd36dc498471f0fde068d51bfd68900db4fffda43689" - } - } + "version": 1, + "skills": { + "cli-design": { + "source": "joelhooks/joelclaw", + "sourceType": "github", + "computedHash": "2e7baa5bbb3ef5be51e4dd36dc498471f0fde068d51bfd68900db4fffda43689" + } + } } diff --git a/src/cli-entry.ts b/src/cli-entry.ts index 6748ccd..0cca914 100644 --- a/src/cli-entry.ts +++ b/src/cli-entry.ts @@ -11,14 +11,14 @@ import { mapRuntimeError, mapValidationError } from "./cli/agent/errors"; import type { NextAction } from "./cli/agent/types"; import { makeCliConfigLayer } from "./cli/services/cli-config"; import { - EnvelopeWriter, - EnvelopeWriterLive, + EnvelopeWriter, + EnvelopeWriterLive, } from "./cli/services/envelope-writer"; import { authStatusEffect } from "./core/auth"; import { - type Environment, - envGetEffect, - validateEnvironment, + type Environment, + envGetEffect, + validateEnvironment, } from "./core/environment"; import { NodeLiveLayer } from "./effect/runtime"; import { setVerbosityLevel } from "./services/logger"; @@ -36,12 +36,12 @@ import { webhookCommand } from "./cli/commands/webhook"; // --------------------------------------------------------------------------- const rootNextActions: NextAction[] = [ - { - command: "godaddy auth status", - description: "Check authentication status", - }, - { command: "godaddy env get", description: "Get current active environment" }, - { command: "godaddy application list", description: "List all applications" }, + { + command: "godaddy auth status", + description: "Check authentication status", + }, + { command: "godaddy env get", description: "Get current active environment" }, + { command: "godaddy application list", description: "List all applications" }, ]; // --------------------------------------------------------------------------- @@ -50,133 +50,133 @@ const rootNextActions: NextAction[] = [ // --------------------------------------------------------------------------- const ROOT_DESCRIPTION = - "GoDaddy Developer Platform CLI - Agent-first JSON interface for platform operations"; + "GoDaddy Developer Platform CLI - Agent-first JSON interface for platform operations"; interface CommandNode { - id: string; - command: string; - description: string; - usage?: string; - children?: CommandNode[]; + id: string; + command: string; + description: string; + usage?: string; + children?: CommandNode[]; } const COMMAND_TREE: CommandNode = { - id: "root", - command: "godaddy", - description: ROOT_DESCRIPTION, - children: [ - { - id: "auth.group", - command: "godaddy auth", - description: "Manage authentication with GoDaddy Developer Platform", - }, - { - id: "env.group", - command: "godaddy env", - description: "Manage GoDaddy environments (ote, prod)", - }, - { - 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", - command: "godaddy actions", - description: "Manage application actions", - }, - { - id: "webhook.group", - command: "godaddy webhook", - description: "Manage webhook integrations", - }, - { - id: "application.group", - command: "godaddy application", - description: "Manage applications", - children: [ - { - id: "application.info", - command: "godaddy application info ", - description: "Show application information", - }, - { - id: "application.list", - command: "godaddy application list", - description: "List all applications", - }, - { - id: "application.validate", - command: "godaddy application validate ", - description: "Validate application configuration", - }, - { - id: "application.update", - command: "godaddy application update ", - description: "Update application configuration", - }, - { - id: "application.enable", - command: "godaddy application enable --store-id ", - description: "Enable application on a store", - }, - { - id: "application.disable", - command: "godaddy application disable --store-id ", - description: "Disable application on a store", - }, - { - id: "application.archive", - command: "godaddy application archive ", - description: "Archive application", - }, - { - id: "application.init", - command: "godaddy application init", - description: "Initialize/create a new application", - }, - { - id: "application.add.group", - command: "godaddy application add", - description: "Add configurations to application", - }, - { - id: "application.release", - command: - "godaddy application release --release-version ", - description: "Create a new release", - }, - { - id: "application.deploy", - command: "godaddy application deploy [--follow]", - usage: "godaddy application deploy [--follow]", - description: "Deploy application", - }, - ], - }, - ], + id: "root", + command: "godaddy", + description: ROOT_DESCRIPTION, + children: [ + { + id: "auth.group", + command: "godaddy auth", + description: "Manage authentication with GoDaddy Developer Platform", + }, + { + id: "env.group", + command: "godaddy env", + description: "Manage GoDaddy environments (ote, prod)", + }, + { + 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", + command: "godaddy actions", + description: "Manage application actions", + }, + { + id: "webhook.group", + command: "godaddy webhook", + description: "Manage webhook integrations", + }, + { + id: "application.group", + command: "godaddy application", + description: "Manage applications", + children: [ + { + id: "application.info", + command: "godaddy application info ", + description: "Show application information", + }, + { + id: "application.list", + command: "godaddy application list", + description: "List all applications", + }, + { + id: "application.validate", + command: "godaddy application validate ", + description: "Validate application configuration", + }, + { + id: "application.update", + command: "godaddy application update ", + description: "Update application configuration", + }, + { + id: "application.enable", + command: "godaddy application enable --store-id ", + description: "Enable application on a store", + }, + { + id: "application.disable", + command: "godaddy application disable --store-id ", + description: "Disable application on a store", + }, + { + id: "application.archive", + command: "godaddy application archive ", + description: "Archive application", + }, + { + id: "application.init", + command: "godaddy application init", + description: "Initialize/create a new application", + }, + { + id: "application.add.group", + command: "godaddy application add", + description: "Add configurations to application", + }, + { + id: "application.release", + command: + "godaddy application release --release-version ", + description: "Create a new release", + }, + { + id: "application.deploy", + command: "godaddy application deploy [--follow]", + usage: "godaddy application deploy [--follow]", + description: "Deploy application", + }, + ], + }, + ], }; // --------------------------------------------------------------------------- @@ -187,96 +187,96 @@ const COMMAND_TREE: CommandNode = { // --------------------------------------------------------------------------- function isShortVerboseCluster(token: string): boolean { - return /^-v{2,}$/.test(token); + return /^-v{2,}$/.test(token); } function normalizeVerbosityArgs(argv: readonly string[]): string[] { - const retained: string[] = []; - let verbosity = 0; - for (const token of argv) { - if (token === "--debug") { - verbosity = Math.max(verbosity, 2); - continue; - } - if (token === "--info" || token === "--verbose") { - verbosity += 1; - continue; - } - if (token === "-v") { - verbosity += 1; - continue; - } - if (isShortVerboseCluster(token)) { - verbosity += token.length - 1; - continue; - } - retained.push(token); - } - const norm = Math.min(verbosity, 2); - if (norm >= 2) return ["--debug", ...retained]; - if (norm === 1) return ["--verbose", ...retained]; - return retained; + const retained: string[] = []; + let verbosity = 0; + for (const token of argv) { + if (token === "--debug") { + verbosity = Math.max(verbosity, 2); + continue; + } + if (token === "--info" || token === "--verbose") { + verbosity += 1; + continue; + } + if (token === "-v") { + verbosity += 1; + continue; + } + if (isShortVerboseCluster(token)) { + verbosity += token.length - 1; + continue; + } + retained.push(token); + } + const norm = Math.min(verbosity, 2); + if (norm >= 2) return ["--debug", ...retained]; + if (norm === 1) return ["--verbose", ...retained]; + return retained; } const API_SUBCOMMANDS = new Set(["list", "describe", "search", "call"]); const ROOT_FLAG_WITH_VALUE = new Set([ - "--env", - "-e", - "--log-level", - "--completions", + "--env", + "-e", + "--log-level", + "--completions", ]); const ROOT_BOOLEAN_FLAGS = new Set([ - "--pretty", - "--verbose", - "-v", - "--info", - "--debug", - "--help", - "-h", - "--version", - "--wizard", + "--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; + 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; } // --------------------------------------------------------------------------- @@ -284,83 +284,83 @@ function rewriteLegacyApiEndpointArgs(argv: readonly string[]): string[] { // --------------------------------------------------------------------------- const rootCommand = Command.make( - "godaddy", - { - pretty: Options.boolean("pretty").pipe( - Options.withDescription( - "Pretty-print JSON envelopes with 2-space indentation", - ), - ), - verbose: Options.boolean("verbose").pipe( - Options.withAlias("v"), - Options.withDescription( - "Enable basic verbose output for HTTP requests and responses", - ), - ), - info: Options.boolean("info").pipe( - Options.withDescription("Enable basic verbose output (same as -v)"), - ), - debug: Options.boolean("debug").pipe( - Options.withDescription("Enable full verbose output (same as -vv)"), - ), - env: Options.text("env").pipe( - Options.withAlias("e"), - Options.withDescription( - "Set the target environment for commands (ote, prod)", - ), - Options.optional, - ), - }, - (_config) => - Effect.gen(function* () { - const writer = yield* EnvelopeWriter; - // Reconstruct the command string from raw argv for traceability - const rawArgs = process.argv.slice(2); - const commandStr = - rawArgs.length > 0 ? `godaddy ${rawArgs.join(" ")}` : "godaddy"; - - const environment = yield* envGetEffect().pipe( - Effect.map((env) => ({ active: env })), - Effect.catchAll((error) => Effect.succeed({ error: error.message })), - ); - - const authSnapshot = yield* authStatusEffect().pipe( - Effect.map( - (status) => - ({ - authenticated: status.authenticated, - has_token: status.hasToken, - token_expiry: status.tokenExpiry?.toISOString(), - environment: status.environment, - }) as Record, - ), - Effect.catchAll((error) => - Effect.succeed({ error: error.message } as Record), - ), - ); - - yield* writer.emitSuccess( - commandStr, - { - description: COMMAND_TREE.description, - version: packageJson.version, - environment, - authentication: authSnapshot, - command_tree: COMMAND_TREE, - }, - rootNextActions, - ); - }), + "godaddy", + { + pretty: Options.boolean("pretty").pipe( + Options.withDescription( + "Pretty-print JSON envelopes with 2-space indentation", + ), + ), + verbose: Options.boolean("verbose").pipe( + Options.withAlias("v"), + Options.withDescription( + "Enable basic verbose output for HTTP requests and responses", + ), + ), + info: Options.boolean("info").pipe( + Options.withDescription("Enable basic verbose output (same as -v)"), + ), + debug: Options.boolean("debug").pipe( + Options.withDescription("Enable full verbose output (same as -vv)"), + ), + env: Options.text("env").pipe( + Options.withAlias("e"), + Options.withDescription( + "Set the target environment for commands (ote, prod)", + ), + Options.optional, + ), + }, + (_config) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + // Reconstruct the command string from raw argv for traceability + const rawArgs = process.argv.slice(2); + const commandStr = + rawArgs.length > 0 ? `godaddy ${rawArgs.join(" ")}` : "godaddy"; + + const environment = yield* envGetEffect().pipe( + Effect.map((env) => ({ active: env })), + Effect.catchAll((error) => Effect.succeed({ error: error.message })), + ); + + const authSnapshot = yield* authStatusEffect().pipe( + Effect.map( + (status) => + ({ + authenticated: status.authenticated, + has_token: status.hasToken, + token_expiry: status.tokenExpiry?.toISOString(), + environment: status.environment, + }) as Record, + ), + Effect.catchAll((error) => + Effect.succeed({ error: error.message } as Record), + ), + ); + + yield* writer.emitSuccess( + commandStr, + { + description: COMMAND_TREE.description, + version: packageJson.version, + environment, + authentication: authSnapshot, + command_tree: COMMAND_TREE, + }, + rootNextActions, + ); + }), ).pipe( - Command.withDescription(ROOT_DESCRIPTION), - Command.withSubcommands([ - envCommand, - authCommand, - apiCommand, - actionsCommand, - webhookCommand, - applicationCommand, - ]), + Command.withDescription(ROOT_DESCRIPTION), + Command.withSubcommands([ + envCommand, + authCommand, + apiCommand, + actionsCommand, + webhookCommand, + applicationCommand, + ]), ); // --------------------------------------------------------------------------- @@ -368,8 +368,8 @@ const rootCommand = Command.make( // --------------------------------------------------------------------------- const cliRunner = Command.run(rootCommand, { - name: "godaddy", - version: packageJson.version, + name: "godaddy", + version: packageJson.version, }); // --------------------------------------------------------------------------- @@ -377,155 +377,155 @@ const cliRunner = Command.run(rootCommand, { // --------------------------------------------------------------------------- export function runCli(rawArgv: ReadonlyArray): Promise { - // Normalize -vv, --info, --debug before the framework sees them - const normalized = normalizeVerbosityArgs(rawArgv); - - // Pre-parse global flags to build layers BEFORE Command.run, then strip - // them so @effect/cli doesn't reject them as unknown subcommand options. - let prettyPrint = false; - let verbosity = 0; - let envOverride: Environment | null = null; - - const stripIndices = new Set(); - for (let i = 0; i < normalized.length; i++) { - const token = normalized[i]; - if (token === "--pretty") { - prettyPrint = true; - stripIndices.add(i); - } - if (token === "--verbose" || token === "-v") - verbosity = Math.max(verbosity, 1); - if (token === "--debug") verbosity = 2; - if (token === "--info") verbosity = Math.max(verbosity, 1); - if ((token === "--env" || token === "-e") && i + 1 < normalized.length) { - envOverride = validateEnvironment(normalized[i + 1]); - stripIndices.add(i); - stripIndices.add(i + 1); - i++; // skip value - } - } - - 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"); - if (outputIdx !== -1) { - const outputValue = normalized[outputIdx + 1] ?? "unknown"; - const commandStr = - `godaddy ${rawArgv.join(" ").replace(/\s+/g, " ")}`.trim(); - const envelope = { - ok: false, - command: commandStr, - error: { - message: `Unsupported option: --output ${outputValue}. All output is JSON by default.`, - code: "UNSUPPORTED_OPTION", - }, - fix: "Remove --output; all godaddy CLI output is JSON envelopes by default.", - next_actions: rootNextActions, - }; - process.stdout.write( - `${prettyPrint ? JSON.stringify(envelope, null, 2) : JSON.stringify(envelope)}\n`, - ); - process.exitCode = 1; - return Promise.resolve(); - } - - // Side-effects for compatibility with existing core/ code that reads globals - if (verbosity > 0) { - setVerbosityLevel(verbosity); - if (verbosity === 1) process.stderr.write("(verbose output enabled)\n"); - else process.stderr.write("(verbose output enabled: full details)\n"); - } - const cliConfigLayer = makeCliConfigLayer({ - prettyPrint, - verbosity, - environmentOverride: envOverride, - }); - - const envelopeWriterLayer = EnvelopeWriterLive; - - // Full layer: platform (FileSystem, Path, Terminal) + custom services + CLI services - const fullLayer = Layer.mergeAll( - NodeContext.layer, - NodeLiveLayer, - cliConfigLayer, - ).pipe( - // EnvelopeWriter depends on CliConfig, so provide after merging - (base) => - Layer.merge(base, Layer.provide(envelopeWriterLayer, cliConfigLayer)), - ); - - const program = cliRunner( - // Command.run expects the full process.argv (node + script + args) - // 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", ...rewrittenFrameworkArgs], - ).pipe( - // Centralized error boundary: catch ALL errors, emit JSON envelope - Effect.catchAll((error) => - Effect.gen(function* () { - const writer = yield* EnvelopeWriter; - - // Check if it's an @effect/cli ValidationError (not a custom CliError) - const CLI_VALIDATION_TAGS = new Set([ - "CommandMismatch", - "CorrectedFlag", - "HelpRequested", - "InvalidArgument", - "InvalidValue", - "MissingFlag", - "MissingValue", - "MissingSubcommand", - "MultipleValuesDetected", - "NoBuiltInMatch", - "UnclusteredFlag", - ]); - const errorTag = - typeof error === "object" && - error !== null && - "_tag" in error && - typeof (error as { _tag: unknown })._tag === "string" - ? (error as { _tag: string })._tag - : undefined; - const isCliValidation = - errorTag !== undefined && CLI_VALIDATION_TAGS.has(errorTag); - - let details: { message: string; code: string; fix: string }; - - if (isCliValidation) { - // biome-ignore lint/suspicious/noExplicitAny: @effect/cli ValidationError is a union - details = mapValidationError(error as any); - } else { - details = mapRuntimeError(error); - } - - const cmdStr = `godaddy ${normalized.join(" ")}`.trim(); - - // If this is a streaming command (--follow), emit stream error - const isStreaming = normalized.includes("--follow"); - if (isStreaming) { - yield* writer.emitStreamError( - cmdStr, - { message: details.message, code: details.code }, - details.fix, - rootNextActions, - ); - } else { - yield* writer.emitError( - cmdStr, - { message: details.message, code: details.code }, - details.fix, - rootNextActions, - ); - } - }), - ), - Effect.provide(fullLayer), - ); - - return Effect.runPromise(program); + // Normalize -vv, --info, --debug before the framework sees them + const normalized = normalizeVerbosityArgs(rawArgv); + + // Pre-parse global flags to build layers BEFORE Command.run, then strip + // them so @effect/cli doesn't reject them as unknown subcommand options. + let prettyPrint = false; + let verbosity = 0; + let envOverride: Environment | null = null; + + const stripIndices = new Set(); + for (let i = 0; i < normalized.length; i++) { + const token = normalized[i]; + if (token === "--pretty") { + prettyPrint = true; + stripIndices.add(i); + } + if (token === "--verbose" || token === "-v") + verbosity = Math.max(verbosity, 1); + if (token === "--debug") verbosity = 2; + if (token === "--info") verbosity = Math.max(verbosity, 1); + if ((token === "--env" || token === "-e") && i + 1 < normalized.length) { + envOverride = validateEnvironment(normalized[i + 1]); + stripIndices.add(i); + stripIndices.add(i + 1); + i++; // skip value + } + } + + 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"); + if (outputIdx !== -1) { + const outputValue = normalized[outputIdx + 1] ?? "unknown"; + const commandStr = + `godaddy ${rawArgv.join(" ").replace(/\s+/g, " ")}`.trim(); + const envelope = { + ok: false, + command: commandStr, + error: { + message: `Unsupported option: --output ${outputValue}. All output is JSON by default.`, + code: "UNSUPPORTED_OPTION", + }, + fix: "Remove --output; all godaddy CLI output is JSON envelopes by default.", + next_actions: rootNextActions, + }; + process.stdout.write( + `${prettyPrint ? JSON.stringify(envelope, null, 2) : JSON.stringify(envelope)}\n`, + ); + process.exitCode = 1; + return Promise.resolve(); + } + + // Side-effects for compatibility with existing core/ code that reads globals + if (verbosity > 0) { + setVerbosityLevel(verbosity); + if (verbosity === 1) process.stderr.write("(verbose output enabled)\n"); + else process.stderr.write("(verbose output enabled: full details)\n"); + } + const cliConfigLayer = makeCliConfigLayer({ + prettyPrint, + verbosity, + environmentOverride: envOverride, + }); + + const envelopeWriterLayer = EnvelopeWriterLive; + + // Full layer: platform (FileSystem, Path, Terminal) + custom services + CLI services + const fullLayer = Layer.mergeAll( + NodeContext.layer, + NodeLiveLayer, + cliConfigLayer, + ).pipe( + // EnvelopeWriter depends on CliConfig, so provide after merging + (base) => + Layer.merge(base, Layer.provide(envelopeWriterLayer, cliConfigLayer)), + ); + + const program = cliRunner( + // Command.run expects the full process.argv (node + script + args) + // 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", ...rewrittenFrameworkArgs], + ).pipe( + // Centralized error boundary: catch ALL errors, emit JSON envelope + Effect.catchAll((error) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + + // Check if it's an @effect/cli ValidationError (not a custom CliError) + const CLI_VALIDATION_TAGS = new Set([ + "CommandMismatch", + "CorrectedFlag", + "HelpRequested", + "InvalidArgument", + "InvalidValue", + "MissingFlag", + "MissingValue", + "MissingSubcommand", + "MultipleValuesDetected", + "NoBuiltInMatch", + "UnclusteredFlag", + ]); + const errorTag = + typeof error === "object" && + error !== null && + "_tag" in error && + typeof (error as { _tag: unknown })._tag === "string" + ? (error as { _tag: string })._tag + : undefined; + const isCliValidation = + errorTag !== undefined && CLI_VALIDATION_TAGS.has(errorTag); + + let details: { message: string; code: string; fix: string }; + + if (isCliValidation) { + // biome-ignore lint/suspicious/noExplicitAny: @effect/cli ValidationError is a union + details = mapValidationError(error as any); + } else { + details = mapRuntimeError(error); + } + + const cmdStr = `godaddy ${normalized.join(" ")}`.trim(); + + // If this is a streaming command (--follow), emit stream error + const isStreaming = normalized.includes("--follow"); + if (isStreaming) { + yield* writer.emitStreamError( + cmdStr, + { message: details.message, code: details.code }, + details.fix, + rootNextActions, + ); + } else { + yield* writer.emitError( + cmdStr, + { message: details.message, code: details.code }, + details.fix, + rootNextActions, + ); + } + }), + ), + Effect.provide(fullLayer), + ); + + return Effect.runPromise(program); } // --------------------------------------------------------------------------- @@ -534,7 +534,7 @@ export function runCli(rawArgv: ReadonlyArray): Promise { const args = process.argv.slice(2); runCli(args).catch((error) => { - // Last-resort catch for truly unexpected failures - process.stderr.write(`Fatal: ${error}\n`); - process.exitCode = 1; + // Last-resort catch for truly unexpected failures + process.stderr.write(`Fatal: ${error}\n`); + process.exitCode = 1; }); diff --git a/src/cli/agent/errors.ts b/src/cli/agent/errors.ts index 3810bf8..48a9733 100644 --- a/src/cli/agent/errors.ts +++ b/src/cli/agent/errors.ts @@ -3,188 +3,188 @@ import type { ValidationError as EffectValidationError } from "@effect/cli/Valid import { type CliError, errorCode } from "../../effect/errors"; export interface AgentErrorDetails { - message: string; - code: string; - fix: string; + message: string; + code: string; + fix: string; } const ANSI_ESCAPE_PATTERN = new RegExp( - `${String.fromCharCode(27)}\\[[0-9;]*m`, - "g", + `${String.fromCharCode(27)}\\[[0-9;]*m`, + "g", ); function stripAnsi(value: string): string { - return value.replace(ANSI_ESCAPE_PATTERN, ""); + return value.replace(ANSI_ESCAPE_PATTERN, ""); } function formatValidationMessage(error: EffectValidationError): string { - if ("error" in error && error.error) { - const text = stripAnsi(HelpDoc.toAnsiText(error.error)).trim(); - if (text.length > 0) { - return text; - } - } - - return "Invalid command input"; + if ("error" in error && error.error) { + const text = stripAnsi(HelpDoc.toAnsiText(error.error)).trim(); + if (text.length > 0) { + return text; + } + } + + return "Invalid command input"; } function fromTaggedError(error: CliError): AgentErrorDetails { - const code = errorCode(error); - const message = error.userMessage || error.message; - - switch (error._tag) { - case "ValidationError": - return { - message, - code, - fix: "Review command arguments and try again with valid values.", - }; - case "AuthenticationError": - return { - message, - code: "AUTH_REQUIRED", - fix: "Run: godaddy auth login", - }; - case "ConfigurationError": - return { - message, - code, - fix: "Check your config with: godaddy env info [environment]", - }; - case "NetworkError": - return { - message, - code, - fix: "Verify environment connectivity with: godaddy env get and retry.", - }; - case "SecurityError": - return { - message, - code, - fix: "Resolve security findings and rerun: godaddy application deploy ", - }; - } + const code = errorCode(error); + const message = error.userMessage || error.message; + + switch (error._tag) { + case "ValidationError": + return { + message, + code, + fix: "Review command arguments and try again with valid values.", + }; + case "AuthenticationError": + return { + message, + code: "AUTH_REQUIRED", + fix: "Run: godaddy auth login", + }; + case "ConfigurationError": + return { + message, + code, + fix: "Check your config with: godaddy env info [environment]", + }; + case "NetworkError": + return { + message, + code, + fix: "Verify environment connectivity with: godaddy env get and retry.", + }; + case "SecurityError": + return { + message, + code, + fix: "Resolve security findings and rerun: godaddy application deploy ", + }; + } } function inferFromMessage(message: string): AgentErrorDetails { - const lower = message.toLowerCase(); - - if (lower.includes("--output")) { - return { - message, - code: "UNSUPPORTED_OPTION", - fix: "Remove --output; all commands now emit JSON envelopes.", - }; - } - - if (lower.includes("security") || lower.includes("blocked")) { - return { - message, - code: "SECURITY_BLOCKED", - fix: "Resolve security findings and rerun: godaddy application deploy ", - }; - } - - if (lower.includes("not found") || lower.includes("does not exist")) { - return { - message, - code: "NOT_FOUND", - fix: "Use discovery commands such as: godaddy application list or godaddy actions list.", - }; - } - - if (lower.includes("auth") || lower.includes("token")) { - return { - message, - code: "AUTH_REQUIRED", - fix: "Run: godaddy auth login", - }; - } - - return { - message, - code: "UNEXPECTED_ERROR", - fix: "Run: godaddy for command discovery and retry with corrected input.", - }; + const lower = message.toLowerCase(); + + if (lower.includes("--output")) { + return { + message, + code: "UNSUPPORTED_OPTION", + fix: "Remove --output; all commands now emit JSON envelopes.", + }; + } + + if (lower.includes("security") || lower.includes("blocked")) { + return { + message, + code: "SECURITY_BLOCKED", + fix: "Resolve security findings and rerun: godaddy application deploy ", + }; + } + + if (lower.includes("not found") || lower.includes("does not exist")) { + return { + message, + code: "NOT_FOUND", + fix: "Use discovery commands such as: godaddy application list or godaddy actions list.", + }; + } + + if (lower.includes("auth") || lower.includes("token")) { + return { + message, + code: "AUTH_REQUIRED", + fix: "Run: godaddy auth login", + }; + } + + return { + message, + code: "UNEXPECTED_ERROR", + fix: "Run: godaddy for command discovery and retry with corrected input.", + }; } function isTaggedError(error: unknown): error is CliError { - return ( - typeof error === "object" && - error !== null && - "_tag" in error && - typeof (error as { _tag: unknown })._tag === "string" && - [ - "ValidationError", - "NetworkError", - "AuthenticationError", - "ConfigurationError", - "SecurityError", - ].includes((error as { _tag: string })._tag) - ); + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + typeof (error as { _tag: unknown })._tag === "string" && + [ + "ValidationError", + "NetworkError", + "AuthenticationError", + "ConfigurationError", + "SecurityError", + ].includes((error as { _tag: string })._tag) + ); } export function mapRuntimeError(error: unknown): AgentErrorDetails { - if (isTaggedError(error)) { - return fromTaggedError(error); - } - - if (error instanceof Error) { - return inferFromMessage(error.message); - } - - return { - message: "Unknown error", - code: "UNEXPECTED_ERROR", - fix: "Run: godaddy for command discovery.", - }; + if (isTaggedError(error)) { + return fromTaggedError(error); + } + + if (error instanceof Error) { + return inferFromMessage(error.message); + } + + return { + message: "Unknown error", + code: "UNEXPECTED_ERROR", + fix: "Run: godaddy for command discovery.", + }; } export function mapValidationError( - error: EffectValidationError, + error: EffectValidationError, ): AgentErrorDetails { - const message = formatValidationMessage(error); - - if (message.includes("--output")) { - return { - message, - code: "UNSUPPORTED_OPTION", - fix: "Remove --output; all commands now emit JSON envelopes.", - }; - } - - switch (error._tag) { - case "CommandMismatch": - return { - message, - code: "COMMAND_NOT_FOUND", - fix: "Run: godaddy", - }; - case "MissingFlag": - case "MissingValue": - case "InvalidArgument": - case "InvalidValue": - case "MultipleValuesDetected": - case "NoBuiltInMatch": - case "UnclusteredFlag": - case "MissingSubcommand": - case "CorrectedFlag": - return { - message, - code: "VALIDATION_ERROR", - fix: "Provide valid arguments/options shown in --help and retry.", - }; - case "HelpRequested": - return { - message, - code: "VALIDATION_ERROR", - fix: "Use --help for command usage details.", - }; - default: - return { - message, - code: "VALIDATION_ERROR", - fix: "Check command usage with --help and retry.", - }; - } + const message = formatValidationMessage(error); + + if (message.includes("--output")) { + return { + message, + code: "UNSUPPORTED_OPTION", + fix: "Remove --output; all commands now emit JSON envelopes.", + }; + } + + switch (error._tag) { + case "CommandMismatch": + return { + message, + code: "COMMAND_NOT_FOUND", + fix: "Run: godaddy", + }; + case "MissingFlag": + case "MissingValue": + case "InvalidArgument": + case "InvalidValue": + case "MultipleValuesDetected": + case "NoBuiltInMatch": + case "UnclusteredFlag": + case "MissingSubcommand": + case "CorrectedFlag": + return { + message, + code: "VALIDATION_ERROR", + fix: "Provide valid arguments/options shown in --help and retry.", + }; + case "HelpRequested": + return { + message, + code: "VALIDATION_ERROR", + fix: "Use --help for command usage details.", + }; + default: + return { + message, + code: "VALIDATION_ERROR", + fix: "Check command usage with --help and retry.", + }; + } } diff --git a/src/cli/agent/stream.ts b/src/cli/agent/stream.ts index 8b30d02..15c678e 100644 --- a/src/cli/agent/stream.ts +++ b/src/cli/agent/stream.ts @@ -6,53 +6,53 @@ import type { NextAction } from "./types"; export interface StreamStartEvent { - type: "start"; - command: string; - ts: string; + type: "start"; + command: string; + ts: string; } export interface StreamStepEvent { - type: "step"; - name: string; - status: "started" | "completed" | "failed"; - message?: string; - extension_name?: string; - details?: Record; - ts: string; + type: "step"; + name: string; + status: "started" | "completed" | "failed"; + message?: string; + extension_name?: string; + details?: Record; + ts: string; } export interface StreamProgressEvent { - type: "progress"; - name: string; - percent?: number; - message?: string; - details?: Record; - ts: string; + type: "progress"; + name: string; + percent?: number; + message?: string; + details?: Record; + ts: string; } export interface StreamResultEvent { - type: "result"; - ok: true; - command: string; - result: T; - next_actions: NextAction[]; + type: "result"; + ok: true; + command: string; + result: T; + next_actions: NextAction[]; } export interface StreamErrorEvent { - type: "error"; - ok: false; - command: string; - error: { - message: string; - code: string; - }; - fix: string; - next_actions: NextAction[]; + type: "error"; + ok: false; + command: string; + error: { + message: string; + code: string; + }; + fix: string; + next_actions: NextAction[]; } export type StreamEvent = - | StreamStartEvent - | StreamStepEvent - | StreamProgressEvent - | StreamResultEvent - | StreamErrorEvent; + | StreamStartEvent + | StreamStepEvent + | StreamProgressEvent + | StreamResultEvent + | StreamErrorEvent; diff --git a/src/cli/agent/truncation.ts b/src/cli/agent/truncation.ts index dc4cd10..0019c2a 100644 --- a/src/cli/agent/truncation.ts +++ b/src/cli/agent/truncation.ts @@ -15,133 +15,133 @@ const MAX_STRING_LENGTH = 1000; const MAX_SERIALIZED_BYTES = 16 * 1024; export interface TruncationMetadata { - truncated: boolean; - total: number; - shown: number; - full_output?: string; + truncated: boolean; + total: number; + shown: number; + full_output?: string; } export interface ListTruncationResult { - items: T[]; - metadata: TruncationMetadata; + items: T[]; + metadata: TruncationMetadata; } export interface PayloadTruncationResult { - value: T; - metadata?: TruncationMetadata; + value: T; + metadata?: TruncationMetadata; } function estimateBytes(value: unknown): number { - return Buffer.byteLength(JSON.stringify(value), "utf8"); + return Buffer.byteLength(JSON.stringify(value), "utf8"); } function slugify(commandId: string): string { - return commandId.replace(/[^a-zA-Z0-9-_.]+/g, "-"); + return commandId.replace(/[^a-zA-Z0-9-_.]+/g, "-"); } function writeFullOutput(commandId: string, payload: unknown): string { - const dir = join(tmpdir(), "godaddy-cli"); - 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), { - encoding: "utf8", - mode: 0o600, - }); - try { - fs.chmodSync(fullPath, 0o600); - } catch { - // Best-effort on platforms that don't honor POSIX modes. - } - return fullPath; + const dir = join(tmpdir(), "godaddy-cli"); + 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), { + encoding: "utf8", + mode: 0o600, + }); + try { + fs.chmodSync(fullPath, 0o600); + } catch { + // Best-effort on platforms that don't honor POSIX modes. + } + return fullPath; } function truncateStrings(value: unknown): unknown { - if (typeof value === "string") { - if (value.length <= MAX_STRING_LENGTH) { - return value; - } - return `${value.slice(0, MAX_STRING_LENGTH)}...(truncated)`; - } - - if (Array.isArray(value)) { - return value.map((item) => truncateStrings(item)); - } - - if (value && typeof value === "object") { - const result: Record = {}; - for (const [key, nested] of Object.entries(value)) { - result[key] = truncateStrings(nested); - } - return result; - } - - return value; + if (typeof value === "string") { + if (value.length <= MAX_STRING_LENGTH) { + return value; + } + return `${value.slice(0, MAX_STRING_LENGTH)}...(truncated)`; + } + + if (Array.isArray(value)) { + return value.map((item) => truncateStrings(item)); + } + + if (value && typeof value === "object") { + const result: Record = {}; + for (const [key, nested] of Object.entries(value)) { + result[key] = truncateStrings(nested); + } + return result; + } + + return value; } export function truncateList( - items: T[], - commandId: string, + items: T[], + commandId: string, ): ListTruncationResult { - const total = items.length; - const shown = Math.min(total, MAX_LIST_ITEMS); - const truncated = total > MAX_LIST_ITEMS; - const sliced = truncated ? items.slice(0, MAX_LIST_ITEMS) : items; - - if (!truncated) { - return { - items: sliced, - metadata: { truncated: false, total, shown }, - }; - } - - const fullOutput = writeFullOutput(commandId, items); - return { - items: sliced, - metadata: { truncated: true, total, shown, full_output: fullOutput }, - }; + const total = items.length; + const shown = Math.min(total, MAX_LIST_ITEMS); + const truncated = total > MAX_LIST_ITEMS; + const sliced = truncated ? items.slice(0, MAX_LIST_ITEMS) : items; + + if (!truncated) { + return { + items: sliced, + metadata: { truncated: false, total, shown }, + }; + } + + const fullOutput = writeFullOutput(commandId, items); + return { + items: sliced, + metadata: { truncated: true, total, shown, full_output: fullOutput }, + }; } export function protectPayload( - value: T, - commandId: string, + value: T, + commandId: string, ): PayloadTruncationResult { - const totalBytes = estimateBytes(value); - let candidate = truncateStrings(value) as T; - let shownBytes = estimateBytes(candidate); - let truncated = totalBytes !== shownBytes; - let fullOutput: string | undefined; - - if (shownBytes > MAX_SERIALIZED_BYTES) { - truncated = true; - const limited = { - truncated: true, - summary: - typeof value === "object" && value !== null - ? "Output too large for inline payload" - : String(value), - }; - candidate = limited as T; - shownBytes = estimateBytes(candidate); - } - - if (truncated) { - fullOutput = writeFullOutput(commandId, value); - return { - value: candidate, - metadata: { - truncated: true, - total: totalBytes, - shown: shownBytes, - full_output: fullOutput, - }, - }; - } - - return { value: candidate }; + const totalBytes = estimateBytes(value); + let candidate = truncateStrings(value) as T; + let shownBytes = estimateBytes(candidate); + let truncated = totalBytes !== shownBytes; + let fullOutput: string | undefined; + + if (shownBytes > MAX_SERIALIZED_BYTES) { + truncated = true; + const limited = { + truncated: true, + summary: + typeof value === "object" && value !== null + ? "Output too large for inline payload" + : String(value), + }; + candidate = limited as T; + shownBytes = estimateBytes(candidate); + } + + if (truncated) { + fullOutput = writeFullOutput(commandId, value); + return { + value: candidate, + metadata: { + truncated: true, + total: totalBytes, + shown: shownBytes, + full_output: fullOutput, + }, + }; + } + + return { value: candidate }; } diff --git a/src/cli/agent/types.ts b/src/cli/agent/types.ts index 199d096..60bf5d3 100644 --- a/src/cli/agent/types.ts +++ b/src/cli/agent/types.ts @@ -1,37 +1,37 @@ export type NextActionParamValue = string | number | boolean; export interface NextActionParam { - description?: string; - value?: NextActionParamValue; - default?: NextActionParamValue; - enum?: Array; - required?: boolean; + description?: string; + value?: NextActionParamValue; + default?: NextActionParamValue; + enum?: Array; + required?: boolean; } export interface NextAction { - command: string; - description: string; - params?: Record; + command: string; + description: string; + params?: Record; } export interface AgentSuccessEnvelope { - ok: true; - command: string; - result: T; - next_actions: NextAction[]; + ok: true; + command: string; + result: T; + next_actions: NextAction[]; } export interface AgentErrorEnvelope { - ok: false; - command: string; - error: { - message: string; - code: string; - }; - fix: string; - next_actions: NextAction[]; + ok: false; + command: string; + error: { + message: string; + code: string; + }; + fix: string; + next_actions: NextAction[]; } export type AgentEnvelope = - | AgentSuccessEnvelope - | AgentErrorEnvelope; + | AgentSuccessEnvelope + | AgentErrorEnvelope; diff --git a/src/cli/commands/actions.ts b/src/cli/commands/actions.ts index 536894b..1f360df 100644 --- a/src/cli/commands/actions.ts +++ b/src/cli/commands/actions.ts @@ -5,8 +5,8 @@ import { ValidationError } from "../../effect/errors"; import { protectPayload, truncateList } from "../agent/truncation"; import type { NextAction } from "../agent/types"; import { - AVAILABLE_ACTIONS, - loadActionInterface, + AVAILABLE_ACTIONS, + loadActionInterface, } from "../schemas/actions/index"; import { EnvelopeWriter } from "../services/envelope-writer"; @@ -15,51 +15,51 @@ import { EnvelopeWriter } from "../services/envelope-writer"; // --------------------------------------------------------------------------- const actionsGroupActions: NextAction[] = [ - { command: "godaddy actions list", description: "List all actions" }, - { - command: "godaddy actions describe ", - description: "Describe an action contract", - params: { action: { description: "Action name", required: true } }, - }, + { command: "godaddy actions list", description: "List all actions" }, + { + command: "godaddy actions describe ", + description: "Describe an action contract", + params: { action: { description: "Action name", required: true } }, + }, ]; function actionsListActions(firstAction?: string): NextAction[] { - return [ - { - command: "godaddy actions describe ", - description: "Describe an action contract", - params: { - action: { - description: "Action name", - value: firstAction ?? "location.address.verify", - required: true, - }, - }, - }, - { - command: "godaddy application add action --name --url ", - description: "Add action configuration", - params: { name: { required: true }, url: { required: true } }, - }, - ]; + return [ + { + command: "godaddy actions describe ", + description: "Describe an action contract", + params: { + action: { + description: "Action name", + value: firstAction ?? "location.address.verify", + required: true, + }, + }, + }, + { + command: "godaddy application add action --name --url ", + description: "Add action configuration", + params: { name: { required: true }, url: { required: true } }, + }, + ]; } function actionsDescribeActions(actionName?: string): NextAction[] { - return [ - { command: "godaddy actions list", description: "List all actions" }, - { - command: "godaddy application add action --name --url ", - description: "Add action configuration", - params: { - name: { - description: "Action name", - value: actionName ?? "", - required: true, - }, - url: { required: true }, - }, - }, - ]; + return [ + { command: "godaddy actions list", description: "List all actions" }, + { + command: "godaddy application add action --name --url ", + description: "Add action configuration", + params: { + name: { + description: "Action name", + value: actionName ?? "", + required: true, + }, + url: { required: true }, + }, + }, + ]; } // --------------------------------------------------------------------------- @@ -67,78 +67,78 @@ function actionsDescribeActions(actionName?: string): NextAction[] { // --------------------------------------------------------------------------- const actionsList = Command.make("list", {}, () => - Effect.gen(function* () { - const writer = yield* EnvelopeWriter; - const truncated = truncateList( - AVAILABLE_ACTIONS.map((name) => ({ name })), - "actions-list", - ); + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + const truncated = truncateList( + AVAILABLE_ACTIONS.map((name) => ({ name })), + "actions-list", + ); - yield* writer.emitSuccess( - "godaddy actions list", - { - actions: truncated.items, - total: truncated.metadata.total, - shown: truncated.metadata.shown, - truncated: truncated.metadata.truncated, - full_output: truncated.metadata.full_output, - }, - actionsListActions(AVAILABLE_ACTIONS[0]), - ); - }), + yield* writer.emitSuccess( + "godaddy actions list", + { + actions: truncated.items, + total: truncated.metadata.total, + shown: truncated.metadata.shown, + truncated: truncated.metadata.truncated, + full_output: truncated.metadata.full_output, + }, + actionsListActions(AVAILABLE_ACTIONS[0]), + ); + }), ).pipe( - Command.withDescription( - "List all available actions that an application developer can hook into", - ), + Command.withDescription( + "List all available actions that an application developer can hook into", + ), ); const actionsDescribe = Command.make( - "describe", - { - action: Args.text({ name: "action" }).pipe( - Args.withDescription("Action name to describe"), - ), - }, - ({ action }) => - Effect.gen(function* () { - const writer = yield* EnvelopeWriter; - const iface = loadActionInterface(action); + "describe", + { + action: Args.text({ name: "action" }).pipe( + Args.withDescription("Action name to describe"), + ), + }, + ({ action }) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + const iface = loadActionInterface(action); - if (!iface) { - return yield* Effect.fail( - new ValidationError({ - message: `Action '${action}' not found`, - userMessage: `Action '${action}' does not exist. Run: godaddy actions list`, - }), - ); - } + if (!iface) { + return yield* Effect.fail( + new ValidationError({ + message: `Action '${action}' not found`, + userMessage: `Action '${action}' does not exist. Run: godaddy actions list`, + }), + ); + } - const payload = protectPayload( - { - name: iface.name, - description: iface.description, - request_schema: iface.requestSchema, - response_schema: iface.responseSchema, - }, - `actions-describe-${action}`, - ); + const payload = protectPayload( + { + name: iface.name, + description: iface.description, + request_schema: iface.requestSchema, + response_schema: iface.responseSchema, + }, + `actions-describe-${action}`, + ); - yield* writer.emitSuccess( - "godaddy actions describe", - { - ...payload.value, - truncated: payload.metadata?.truncated ?? false, - total: payload.metadata?.total, - shown: payload.metadata?.shown, - full_output: payload.metadata?.full_output, - }, - actionsDescribeActions(action), - ); - }), + yield* writer.emitSuccess( + "godaddy actions describe", + { + ...payload.value, + truncated: payload.metadata?.truncated ?? false, + total: payload.metadata?.total, + shown: payload.metadata?.shown, + full_output: payload.metadata?.full_output, + }, + actionsDescribeActions(action), + ); + }), ).pipe( - Command.withDescription( - "Show detailed interface information for a specific action", - ), + Command.withDescription( + "Show detailed interface information for a specific action", + ), ); // --------------------------------------------------------------------------- @@ -146,34 +146,34 @@ const actionsDescribe = Command.make( // --------------------------------------------------------------------------- const actionsParent = Command.make("actions", {}, () => - Effect.gen(function* () { - const writer = yield* EnvelopeWriter; - yield* writer.emitSuccess( - "godaddy actions", - { - command: "godaddy actions", - description: "Manage application actions", - commands: [ - { - command: "godaddy actions list", - description: - "List all available actions that an application developer can hook into", - usage: "godaddy actions list", - }, - { - command: "godaddy actions describe ", - description: - "Show detailed interface information for a specific action", - usage: "godaddy actions describe ", - }, - ], - }, - actionsGroupActions, - ); - }), + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + yield* writer.emitSuccess( + "godaddy actions", + { + command: "godaddy actions", + description: "Manage application actions", + commands: [ + { + command: "godaddy actions list", + description: + "List all available actions that an application developer can hook into", + usage: "godaddy actions list", + }, + { + command: "godaddy actions describe ", + description: + "Show detailed interface information for a specific action", + usage: "godaddy actions describe ", + }, + ], + }, + actionsGroupActions, + ); + }), ).pipe( - Command.withDescription("Manage application actions"), - Command.withSubcommands([actionsList, actionsDescribe]), + Command.withDescription("Manage application actions"), + Command.withSubcommands([actionsList, actionsDescribe]), ); export const actionsCommand = actionsParent; diff --git a/src/cli/commands/api.ts b/src/cli/commands/api.ts index 889a00f..550ab9e 100644 --- a/src/cli/commands/api.ts +++ b/src/cli/commands/api.ts @@ -4,35 +4,35 @@ import * as Options from "@effect/cli/Options"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { - type HttpMethod, - apiRequestEffect, - parseFieldsEffect, - parseHeadersEffect, - readBodyFromFileEffect, - sanitizeResponseHeaders, + type HttpMethod, + apiRequestEffect, + parseFieldsEffect, + parseHeadersEffect, + readBodyFromFileEffect, + sanitizeResponseHeaders, } 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, + 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"; const VALID_METHODS: readonly HttpMethod[] = [ - "GET", - "POST", - "PUT", - "PATCH", - "DELETE", + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", ]; // --------------------------------------------------------------------------- @@ -40,166 +40,166 @@ const VALID_METHODS: readonly HttpMethod[] = [ // --------------------------------------------------------------------------- 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 call ", - description: "Make an authenticated API request", - params: { - endpoint: { - description: - "Relative API endpoint (e.g. /v1/commerce/location/addresses)", - required: true, - }, - }, - }, + { + 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 call ", + description: "Make an authenticated API request", + params: { + endpoint: { + description: + "Relative API endpoint (e.g. /v1/commerce/location/addresses)", + required: true, + }, + }, + }, ]; function describeNextActions( - domain: CatalogDomain, - endpoint: CatalogEndpoint, + 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; + // 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 }, - }, - }, - ]; + 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; + 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", - }, - ]; + 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", + }, + ]; } // --------------------------------------------------------------------------- @@ -211,49 +211,49 @@ function callNextActions(): NextAction[] { * Supports: .key, .key.nested, .key[0], .key[0].nested */ export function extractPath(obj: unknown, path: string): unknown { - if (!path || path === ".") { - return obj; - } - - const normalizedPath = path.startsWith(".") ? path.slice(1) : path; - if (!normalizedPath) { - return obj; - } - - const segments: Array = []; - const regex = /([\w-]+)|\[(\d+)\]/g; - for (const match of normalizedPath.matchAll(regex)) { - const key = match[1]; - const index = match[2]; - if (key !== undefined) { - segments.push(key); - } else if (index !== undefined) { - segments.push(Number.parseInt(index, 10)); - } - } - - let current: unknown = obj; - for (const segment of segments) { - if (current === null || current === undefined) { - return undefined; - } - - if (typeof segment === "number") { - if (!Array.isArray(current)) { - throw new Error(`Cannot index non-array with [${segment}]`); - } - current = current[segment]; - continue; - } - - if (typeof current !== "object") { - throw new Error(`Cannot access property "${segment}" on non-object`); - } - - current = (current as Record)[segment]; - } - - return current; + if (!path || path === ".") { + return obj; + } + + const normalizedPath = path.startsWith(".") ? path.slice(1) : path; + if (!normalizedPath) { + return obj; + } + + const segments: Array = []; + const regex = /([\w-]+)|\[(\d+)\]/g; + for (const match of normalizedPath.matchAll(regex)) { + const key = match[1]; + const index = match[2]; + if (key !== undefined) { + segments.push(key); + } else if (index !== undefined) { + segments.push(Number.parseInt(index, 10)); + } + } + + let current: unknown = obj; + for (const segment of segments) { + if (current === null || current === undefined) { + return undefined; + } + + if (typeof segment === "number") { + if (!Array.isArray(current)) { + throw new Error(`Cannot index non-array with [${segment}]`); + } + current = current[segment]; + continue; + } + + if (typeof current !== "object") { + throw new Error(`Cannot access property "${segment}" on non-object`); + } + + current = (current as Record)[segment]; + } + + return current; } // --------------------------------------------------------------------------- @@ -261,28 +261,28 @@ export function extractPath(obj: unknown, path: string): unknown { // --------------------------------------------------------------------------- function normalizeStringArray(value: ReadonlyArray): string[] { - return value.filter((entry): entry is string => typeof entry === "string"); + return value.filter((entry): entry is string => typeof entry === "string"); } /** Decode a JWT payload without verification (we only need the claims). */ function decodeJwtPayload(token: string): Record | null { - try { - const parts = token.split("."); - if (parts.length !== 3) return null; - const payload = Buffer.from(parts[1], "base64url").toString("utf-8"); - return JSON.parse(payload) as Record; - } catch { - return null; - } + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const payload = Buffer.from(parts[1], "base64url").toString("utf-8"); + return JSON.parse(payload) as Record; + } catch { + return null; + } } /** Check whether a JWT already contains every scope in `required`. */ function tokenHasScopes(token: string, required: string[]): boolean { - if (required.length === 0) return true; - const claims = decodeJwtPayload(token); - if (!claims || typeof claims.scope !== "string") return false; - const granted = new Set(claims.scope.split(/\s+/)); - return required.every((s) => granted.has(s)); + if (required.length === 0) return true; + const claims = decodeJwtPayload(token); + if (!claims || typeof claims.scope !== "string") return false; + const granted = new Set(claims.scope.split(/\s+/)); + return required.every((s) => granted.has(s)); } // --------------------------------------------------------------------------- @@ -290,104 +290,104 @@ function tokenHasScopes(token: string, required: string[]): boolean { // --------------------------------------------------------------------------- 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), - ); - } - }), + "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")); // --------------------------------------------------------------------------- @@ -395,111 +395,111 @@ const apiList = Command.make( // --------------------------------------------------------------------------- const apiDescribe = Command.make( - "describe", - { - endpoint: Args.text({ name: "endpoint" }).pipe( - 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), - ); - }), + "describe", + { + endpoint: Args.text({ name: "endpoint" }).pipe( + 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", - ), + Command.withDescription( + "Show detailed schema information for an API endpoint", + ), ); // --------------------------------------------------------------------------- @@ -507,43 +507,43 @@ const apiDescribe = Command.make( // --------------------------------------------------------------------------- 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), - ); - }), + "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")); // --------------------------------------------------------------------------- @@ -551,196 +551,196 @@ const apiSearch = Command.make( // --------------------------------------------------------------------------- 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"), - Options.withDescription("HTTP method (GET, POST, PUT, PATCH, DELETE)"), - Options.withDefault("GET"), - ), - field: Options.text("field").pipe( - Options.withAlias("f"), - Options.withDescription("Add request body field (can be repeated)"), - Options.repeated, - ), - file: Options.text("file").pipe( - Options.withAlias("F"), - Options.withDescription("Read request body from JSON file"), - Options.optional, - ), - header: Options.text("header").pipe( - Options.withAlias("H"), - Options.withDescription("Add custom header (can be repeated)"), - Options.repeated, - ), - query: Options.text("query").pipe( - Options.withAlias("q"), - Options.withDescription( - "Extract a value from response JSON (for example: .data[0].id)", - ), - Options.optional, - ), - include: Options.boolean("include").pipe( - Options.withAlias("i"), - Options.withDescription("Include response headers in result"), - ), - scope: Options.text("scope").pipe( - Options.withAlias("s"), - Options.withDescription( - "Required OAuth scope. On 403, triggers auth and retries (can be repeated)", - ), - Options.repeated, - ), - }, - (config) => - Effect.gen(function* () { - const writer = yield* EnvelopeWriter; - const cliConfig = yield* CliConfig; - const methodInput = config.method.toUpperCase(); - - if (!VALID_METHODS.includes(methodInput as HttpMethod)) { - return yield* Effect.fail( - new ValidationError({ - message: `Invalid HTTP method: ${config.method}`, - userMessage: `Method must be one of: ${VALID_METHODS.join(", ")}`, - }), - ); - } - - const method = methodInput as HttpMethod; - const fields = yield* parseFieldsEffect( - normalizeStringArray(config.field), - ); - const headers = yield* parseHeadersEffect( - normalizeStringArray(config.header), - ); - - let body: string | undefined; - const filePath = Option.getOrUndefined(config.file); - if (typeof filePath === "string" && filePath.length > 0) { - body = yield* readBodyFromFileEffect(filePath); - } - - const requiredScopes = config.scope.flatMap((s) => - s - .split(/[\s,]+/) - .map((t) => t.trim()) - .filter((t) => t.length > 0), - ); - - const requestOpts = { - endpoint: config.endpoint, - method, - fields: Object.keys(fields).length > 0 ? fields : undefined, - body, - headers: Object.keys(headers).length > 0 ? headers : undefined, - debug: cliConfig.verbosity >= 2, - }; - - // First attempt - const response = yield* apiRequestEffect(requestOpts).pipe( - Effect.catchAll((error) => { - // On 403 with --scope: check if the token is missing the scope, - // trigger auth, and retry — once. - if ( - error._tag === "AuthenticationError" && - error.message.includes("403") && - requiredScopes.length > 0 - ) { - return Effect.gen(function* () { - // Get current token to inspect scopes - const tokenInfo = yield* getTokenInfoEffect().pipe( - Effect.catchAll(() => Effect.succeed(null)), - ); - - if ( - tokenInfo && - tokenHasScopes(tokenInfo.accessToken, requiredScopes) - ) { - // Token already has the scopes — the 403 is not a scope issue - return yield* Effect.fail(error); - } - - // Token is missing required scopes — re-auth and retry - if (cliConfig.verbosity >= 1) { - process.stderr.write( - `Token missing scope(s): ${requiredScopes.join(", ")}. Triggering auth flow...\n`, - ); - } - - const loginResult = yield* authLoginEffect({ - additionalScopes: requiredScopes, - }).pipe( - Effect.catchAll(() => - Effect.fail( - new AuthenticationError({ - message: "Re-authentication failed", - userMessage: - "Automatic re-authentication failed. Run 'godaddy auth login' manually.", - }), - ), - ), - ); - - if (!loginResult.success) { - return yield* Effect.fail( - new AuthenticationError({ - message: "Re-authentication did not succeed", - userMessage: - "Authentication did not complete. Run 'godaddy auth login' manually.", - }), - ); - } - - // Retry the request with the new token - return yield* apiRequestEffect(requestOpts); - }); - } - return Effect.fail(error); - }), - ); - - let output = response.data; - const queryPath = Option.getOrUndefined(config.query); - if (typeof queryPath === "string" && output !== undefined) { - try { - output = extractPath(output, queryPath); - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - return yield* Effect.fail( - new ValidationError({ - message: `Invalid query path: ${queryPath}`, - userMessage: `Query error: ${message}`, - }), - ); - } - } - - yield* writer.emitSuccess( - "godaddy api call", - { - endpoint: config.endpoint.startsWith("/") - ? config.endpoint - : `/${config.endpoint}`, - method, - status: response.status, - status_text: response.statusText, - headers: config.include - ? sanitizeResponseHeaders(response.headers) - : undefined, - data: output ?? null, - }, - callNextActions(), - ); - }), + "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"), + Options.withDescription("HTTP method (GET, POST, PUT, PATCH, DELETE)"), + Options.withDefault("GET"), + ), + field: Options.text("field").pipe( + Options.withAlias("f"), + Options.withDescription("Add request body field (can be repeated)"), + Options.repeated, + ), + file: Options.text("file").pipe( + Options.withAlias("F"), + Options.withDescription("Read request body from JSON file"), + Options.optional, + ), + header: Options.text("header").pipe( + Options.withAlias("H"), + Options.withDescription("Add custom header (can be repeated)"), + Options.repeated, + ), + query: Options.text("query").pipe( + Options.withAlias("q"), + Options.withDescription( + "Extract a value from response JSON (for example: .data[0].id)", + ), + Options.optional, + ), + include: Options.boolean("include").pipe( + Options.withAlias("i"), + Options.withDescription("Include response headers in result"), + ), + scope: Options.text("scope").pipe( + Options.withAlias("s"), + Options.withDescription( + "Required OAuth scope. On 403, triggers auth and retries (can be repeated)", + ), + Options.repeated, + ), + }, + (config) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + const cliConfig = yield* CliConfig; + const methodInput = config.method.toUpperCase(); + + if (!VALID_METHODS.includes(methodInput as HttpMethod)) { + return yield* Effect.fail( + new ValidationError({ + message: `Invalid HTTP method: ${config.method}`, + userMessage: `Method must be one of: ${VALID_METHODS.join(", ")}`, + }), + ); + } + + const method = methodInput as HttpMethod; + const fields = yield* parseFieldsEffect( + normalizeStringArray(config.field), + ); + const headers = yield* parseHeadersEffect( + normalizeStringArray(config.header), + ); + + let body: string | undefined; + const filePath = Option.getOrUndefined(config.file); + if (typeof filePath === "string" && filePath.length > 0) { + body = yield* readBodyFromFileEffect(filePath); + } + + const requiredScopes = config.scope.flatMap((s) => + s + .split(/[\s,]+/) + .map((t) => t.trim()) + .filter((t) => t.length > 0), + ); + + const requestOpts = { + endpoint: config.endpoint, + method, + fields: Object.keys(fields).length > 0 ? fields : undefined, + body, + headers: Object.keys(headers).length > 0 ? headers : undefined, + debug: cliConfig.verbosity >= 2, + }; + + // First attempt + const response = yield* apiRequestEffect(requestOpts).pipe( + Effect.catchAll((error) => { + // On 403 with --scope: check if the token is missing the scope, + // trigger auth, and retry — once. + if ( + error._tag === "AuthenticationError" && + error.message.includes("403") && + requiredScopes.length > 0 + ) { + return Effect.gen(function* () { + // Get current token to inspect scopes + const tokenInfo = yield* getTokenInfoEffect().pipe( + Effect.catchAll(() => Effect.succeed(null)), + ); + + if ( + tokenInfo && + tokenHasScopes(tokenInfo.accessToken, requiredScopes) + ) { + // Token already has the scopes — the 403 is not a scope issue + return yield* Effect.fail(error); + } + + // Token is missing required scopes — re-auth and retry + if (cliConfig.verbosity >= 1) { + process.stderr.write( + `Token missing scope(s): ${requiredScopes.join(", ")}. Triggering auth flow...\n`, + ); + } + + const loginResult = yield* authLoginEffect({ + additionalScopes: requiredScopes, + }).pipe( + Effect.catchAll(() => + Effect.fail( + new AuthenticationError({ + message: "Re-authentication failed", + userMessage: + "Automatic re-authentication failed. Run 'godaddy auth login' manually.", + }), + ), + ), + ); + + if (!loginResult.success) { + return yield* Effect.fail( + new AuthenticationError({ + message: "Re-authentication did not succeed", + userMessage: + "Authentication did not complete. Run 'godaddy auth login' manually.", + }), + ); + } + + // Retry the request with the new token + return yield* apiRequestEffect(requestOpts); + }); + } + return Effect.fail(error); + }), + ); + + let output = response.data; + const queryPath = Option.getOrUndefined(config.query); + if (typeof queryPath === "string" && output !== undefined) { + try { + output = extractPath(output, queryPath); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + return yield* Effect.fail( + new ValidationError({ + message: `Invalid query path: ${queryPath}`, + userMessage: `Query error: ${message}`, + }), + ); + } + } + + yield* writer.emitSuccess( + "godaddy api call", + { + endpoint: config.endpoint.startsWith("/") + ? config.endpoint + : `/${config.endpoint}`, + method, + status: response.status, + status_text: response.statusText, + headers: config.include + ? sanitizeResponseHeaders(response.headers) + : undefined, + data: output ?? null, + }, + callNextActions(), + ); + }), ).pipe( - Command.withDescription("Make authenticated requests to the GoDaddy API"), + Command.withDescription("Make authenticated requests to the GoDaddy API"), ); // --------------------------------------------------------------------------- @@ -748,53 +748,53 @@ const apiCall = Command.make( // --------------------------------------------------------------------------- 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, - ); - }), + 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]), + Command.withDescription("Explore and call GoDaddy API endpoints"), + Command.withSubcommands([apiList, apiDescribe, apiSearch, apiCall]), ); export { apiParent as apiCommand }; diff --git a/src/cli/commands/application.ts b/src/cli/commands/application.ts index bf572b2..bb0ab2c 100644 --- a/src/cli/commands/application.ts +++ b/src/cli/commands/application.ts @@ -6,36 +6,36 @@ import type { ArkErrors } from "arktype"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import type { - CreateApplicationInput, - DeployProgressEvent, - DeployResult, + CreateApplicationInput, + DeployProgressEvent, + DeployResult, } from "../../core/applications"; import { - applicationArchiveEffect, - applicationDeployEffect, - applicationDisableEffect, - applicationEnableEffect, - applicationInfoEffect, - applicationInitEffect, - applicationListEffect, - applicationReleaseEffect, - applicationUpdateEffect, - applicationValidateEffect, + applicationArchiveEffect, + applicationDeployEffect, + applicationDisableEffect, + applicationEnableEffect, + applicationInfoEffect, + applicationInitEffect, + applicationListEffect, + applicationReleaseEffect, + applicationUpdateEffect, + applicationValidateEffect, } from "../../core/applications"; import { type Environment, envGetEffect } from "../../core/environment"; import { ValidationError } from "../../effect/errors"; import { - type ActionConfig, - type BlocksExtensionConfig, - type CheckoutExtensionConfig, - type Config, - type EmbedExtensionConfig, - type SubscriptionConfig, - addActionToConfigEffect, - addExtensionToConfigEffect, - addSubscriptionToConfigEffect, - getConfigFile, - getConfigFilePath, + type ActionConfig, + type BlocksExtensionConfig, + type CheckoutExtensionConfig, + type Config, + type EmbedExtensionConfig, + type SubscriptionConfig, + addActionToConfigEffect, + addExtensionToConfigEffect, + addSubscriptionToConfigEffect, + getConfigFile, + getConfigFilePath, } from "../../services/config"; import { protectPayload, truncateList } from "../agent/truncation"; import type { NextAction } from "../agent/types"; @@ -48,86 +48,86 @@ import { EnvelopeWriter } from "../services/envelope-writer"; type ConfigReadResult = ReturnType; function resolveEnvironmentEffect(environment?: string) { - return envGetEffect(environment); + return envGetEffect(environment); } function resolveConfigPath( - configPath: string | undefined, - env: Environment, + configPath: string | undefined, + env: Environment, ): string { - if (configPath) return resolve(process.cwd(), configPath); - return getConfigFilePath(env); + if (configPath) return resolve(process.cwd(), configPath); + return getConfigFilePath(env); } function parseSpaceSeparated(value: string): string[] { - return value - .split(" ") - .map((s) => s.trim()) - .filter((s) => s.length > 0); + return value + .split(" ") + .map((s) => s.trim()) + .filter((s) => s.length > 0); } function parseCommaSeparated(value: string): string[] { - return value - .split(",") - .map((s) => s.trim()) - .filter((s) => s.length > 0); + return value + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); } function isConfigValidationErrorResult( - value: ConfigReadResult, + value: ConfigReadResult, ): value is ArkErrors { - return typeof value === "object" && value !== null && "summary" in value; + return typeof value === "object" && value !== null && "summary" in value; } function buildDeployPayload( - name: string, - deployResult: DeployResult, + name: string, + deployResult: DeployResult, ): Record { - const summarized = protectPayload( - { - total_extensions: deployResult.totalExtensions, - blocked_extensions: deployResult.blockedExtensions, - security_reports: deployResult.securityReports.map((r) => ({ - extension_name: r.extensionName, - extension_dir: r.extensionDir, - blocked: r.blocked, - total_findings: r.totalFindings, - blocked_findings: r.blockedFindings, - warnings: r.warnings, - pre_bundle: { - blocked: r.preBundleReport.blocked, - scanned_files: r.preBundleReport.scannedFiles, - summary: r.preBundleReport.summary, - findings: r.preBundleReport.findings, - }, - post_bundle: r.postBundleReport - ? { - blocked: r.postBundleReport.blocked, - scanned_files: r.postBundleReport.scannedFiles, - summary: r.postBundleReport.summary, - findings: r.postBundleReport.findings, - } - : undefined, - })), - bundle_reports: deployResult.bundleReports.map((r) => ({ - extension_name: r.extensionName, - artifact_name: r.artifactName, - size_bytes: r.size, - sha256: r.sha256, - targets: r.targets, - upload_ids: r.uploadIds, - uploaded: r.uploaded, - })), - }, - `application-deploy-${name}`, - ); - return { - ...summarized.value, - truncated: summarized.metadata?.truncated ?? false, - total: summarized.metadata?.total, - shown: summarized.metadata?.shown, - full_output: summarized.metadata?.full_output, - }; + const summarized = protectPayload( + { + total_extensions: deployResult.totalExtensions, + blocked_extensions: deployResult.blockedExtensions, + security_reports: deployResult.securityReports.map((r) => ({ + extension_name: r.extensionName, + extension_dir: r.extensionDir, + blocked: r.blocked, + total_findings: r.totalFindings, + blocked_findings: r.blockedFindings, + warnings: r.warnings, + pre_bundle: { + blocked: r.preBundleReport.blocked, + scanned_files: r.preBundleReport.scannedFiles, + summary: r.preBundleReport.summary, + findings: r.preBundleReport.findings, + }, + post_bundle: r.postBundleReport + ? { + blocked: r.postBundleReport.blocked, + scanned_files: r.postBundleReport.scannedFiles, + summary: r.postBundleReport.summary, + findings: r.postBundleReport.findings, + } + : undefined, + })), + bundle_reports: deployResult.bundleReports.map((r) => ({ + extension_name: r.extensionName, + artifact_name: r.artifactName, + size_bytes: r.size, + sha256: r.sha256, + targets: r.targets, + upload_ids: r.uploadIds, + uploaded: r.uploaded, + })), + }, + `application-deploy-${name}`, + ); + return { + ...summarized.value, + truncated: summarized.metadata?.truncated ?? false, + total: summarized.metadata?.total, + shown: summarized.metadata?.shown, + full_output: summarized.metadata?.full_output, + }; } // --------------------------------------------------------------------------- @@ -135,257 +135,257 @@ function buildDeployPayload( // --------------------------------------------------------------------------- const appGroupActions: NextAction[] = [ - { command: "godaddy application list", description: "List all applications" }, - { - command: - "godaddy application init --name --description --url --proxy-url --scopes ", - description: "Initialize a new application", - }, - { - command: "godaddy application add action --name --url ", - description: "Add action configuration", - }, + { command: "godaddy application list", description: "List all applications" }, + { + command: + "godaddy application init --name --description --url --proxy-url --scopes ", + description: "Initialize a new application", + }, + { + command: "godaddy application add action --name --url ", + description: "Add action configuration", + }, ]; function appInfoActions(name: string): NextAction[] { - return [ - { - command: "godaddy application validate ", - description: "Validate application configuration", - params: { name: { value: name, required: true } }, - }, - { - command: - "godaddy application update [--label