diff --git a/package.json b/package.json index b5242116..7536a64f 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,9 @@ }, "layout": { "description": "Manage email layouts." + }, + "mapi": { + "description": "Call any Knock Management API endpoint." } } }, diff --git a/src/commands/mapi/index.ts b/src/commands/mapi/index.ts new file mode 100644 index 00000000..7e27df1d --- /dev/null +++ b/src/commands/mapi/index.ts @@ -0,0 +1,321 @@ +import { Args, Flags, ux } from "@oclif/core"; +import enquirer from "enquirer"; + +import BaseCommand from "@/lib/base-command"; +import { generateCurl } from "@/lib/mapi/curl-generator"; +import { + formatEndpointsHelpLines, + listEndpoints, + resolveEndpoint, +} from "@/lib/mapi/endpoint-resolver"; +import { runInteractiveMapi } from "@/lib/mapi/interactive"; +import { + loadOpenApiDocument, + readCachedOpenApiForHelp, +} from "@/lib/mapi/openapi-loader"; +import { + buildRequest, + type FieldInput, + parseHeaderPair, + warnOnDuplicateFieldKeys, +} from "@/lib/mapi/request-builder"; +import { + renderResponse, + requestWithOptionalPagination, +} from "@/lib/mapi/response-renderer"; +import type { HttpMethod } from "@/lib/mapi/types"; + +const HTTP_METHODS: HttpMethod[] = ["get", "put", "post", "delete", "patch"]; + +const MAP_DESCRIPTION_BASE = `Execute HTTP requests against the Knock Management API, similar to Vercel's \`vercel api\`. + +Discover endpoints with \`knock mapi ls\` (from the live OpenAPI spec). Pass an OpenAPI path (e.g. \`/v1/whoami\`) or an \`operationId\` (e.g. \`getWhoami\`). Run \`knock mapi\` with no arguments for interactive mode. + +OpenAPI spec is cached under the CLI cache directory (24h TTL); use \`--refresh\` to fetch the latest. + +Only operations with HTTP GET, PUT, POST, DELETE, or PATCH appear in the spec (other methods are omitted). + +Power-user flags: \`-F key=@path\` reads a local file; \`-H\` can override headers including \`Authorization\` (you are responsible for auth).`; + +function parseHttpMethod(s: string | undefined): HttpMethod | undefined { + if (!s) return undefined; + const m = s.toLowerCase() as HttpMethod; + if ((HTTP_METHODS as string[]).includes(m)) return m; + return undefined; +} + +function parseKeyEqualsValue( + flag: string, + label: string, +): { key: string; value: string } { + const eq = flag.indexOf("="); + if (eq < 0) { + throw new Error(`${label} must be key=value, got: ${flag}`); + } + + return { key: flag.slice(0, eq), value: flag.slice(eq + 1) }; +} + +function fieldInputsFromFlags(flags: { + field?: string[]; + "raw-field"?: string[]; +}): FieldInput[] { + const out: FieldInput[] = []; + for (const f of flags.field ?? []) { + const { key, value } = parseKeyEqualsValue(f, "-F/--field"); + out.push({ key, value, raw: false }); + } + + for (const f of flags["raw-field"] ?? []) { + const { key, value } = parseKeyEqualsValue(f, "-f/--raw-field"); + out.push({ key, value, raw: true }); + } + + return out; +} + +export default class Mapi extends BaseCommand { + static summary = "Call any Knock Management API (mAPI) endpoint."; + + static description = MAP_DESCRIPTION_BASE; + + static enableJsonFlag = true; + + static strict = true; + + static args = { + endpoint: Args.string({ + required: false, + description: + "OpenAPI path (e.g. /v1/whoami) or operationId (e.g. getWhoami). Omit for interactive mode.", + }), + }; + + static flags = { + method: Flags.string({ + char: "X", + summary: "HTTP method (default from OpenAPI operation).", + options: [...HTTP_METHODS], + }), + field: Flags.string({ + char: "F", + summary: + "Body, path, or query field (typed: booleans, numbers, null, @file). Repeatable.", + multiple: true, + }), + "raw-field": Flags.string({ + char: "f", + summary: "Field as raw string (no type coercion). Repeatable.", + multiple: true, + }), + header: Flags.string({ + char: "H", + summary: 'Extra header in "Name: value" form. Repeatable.', + multiple: true, + }), + input: Flags.string({ + summary: "Read JSON request body from file path, or - for stdin.", + }), + paginate: Flags.boolean({ + summary: + "Follow page_info.after and merge entries (Knock pagination shape).", + }), + include: Flags.boolean({ + char: "i", + summary: "Print response status and headers before the body.", + }), + silent: Flags.boolean({ + summary: "Suppress response body; exit code reflects HTTP success.", + }), + verbose: Flags.boolean({ + summary: "Log request/response debug details to stderr.", + }), + raw: Flags.boolean({ + summary: "Print JSON without pretty-printing.", + }), + refresh: Flags.boolean({ + summary: "Force refresh of the cached OpenAPI specification.", + }), + generate: Flags.string({ + summary: "Print a command instead of executing (only: curl).", + options: ["curl"], + }), + "dangerously-skip-permissions": Flags.boolean({ + summary: "Skip confirmation prompt for DELETE requests.", + }), + }; + + static examples = [ + "knock mapi /v1/whoami", + "knock mapi getWhoami", + "knock mapi /v1/workflows/my-workflow/run -X put -F environment=development -F recipients='[\"user_1\"]'", + "knock mapi ls", + "knock mapi /v1/workflows --paginate", + "knock mapi /v1/whoami --generate curl", + ]; + + public async init(): Promise { + await super.init(); + const wantsHelp = this.argv.some((a) => a === "--help" || a === "-h"); + if (!wantsHelp) return; + try { + const spec = readCachedOpenApiForHelp( + this.config.cacheDir, + this.sessionContext.apiOrigin, + ); + Mapi.description = spec + ? `${MAP_DESCRIPTION_BASE}\n\n${formatEndpointsHelpLines( + listEndpoints(spec), + 35, + )}` + : MAP_DESCRIPTION_BASE; + } catch { + Mapi.description = MAP_DESCRIPTION_BASE; + } + } + + async run(): Promise { + const { args, flags } = this.props; + const methodOverride = parseHttpMethod(flags.method); + + const { spec } = await loadOpenApiDocument({ + apiOrigin: this.sessionContext.apiOrigin, + cacheDir: this.config.cacheDir, + refresh: flags.refresh, + onStaleCache: (m) => this.warn(m), + }); + + let endpointToken = args.endpoint?.trim(); + let extraFields: FieldInput[] = []; + + if (!endpointToken) { + const interactive = await runInteractiveMapi(spec); + if (!interactive) return; + endpointToken = + interactive.endpoint.operationId ?? + `${interactive.endpoint.method} ${interactive.endpoint.path}`; + extraFields = interactive.fields; + } + + const resolved = resolveEndpoint(spec, endpointToken, methodOverride); + if (!resolved.ok) { + if (resolved.reason === "ambiguous") { + this.error( + `Ambiguous endpoint "${endpointToken}". Candidates:\n${resolved.candidates + .map( + (c) => + ` ${c.method.toUpperCase()} ${c.path} (${c.operationId})`, + ) + .join("\n")}`, + ); + } + + this.error(resolved.message); + } + + const { endpoint, pathParamValues } = resolved; + + const fields: FieldInput[] = [ + ...extraFields, + ...fieldInputsFromFlags(flags), + ]; + warnOnDuplicateFieldKeys(fields, (m) => ux.logToStderr(`Warning: ${m}`)); + + const headerList = flags.header ?? []; + for (const h of headerList) { + parseHeaderPair(h); + } + + const built = await buildRequest({ + endpoint, + pathParamDefaults: pathParamValues, + fields, + headers: headerList, + inputPath: flags.input, + methodOverride, + }); + + if (built.method === "delete" && !flags["dangerously-skip-permissions"]) { + if (flags.silent) { + this.error( + "DELETE with --silent requires --dangerously-skip-permissions (non-interactive).", + ); + } + + try { + const ans = await enquirer.prompt<{ ok: boolean }>({ + type: "confirm", + name: "ok", + message: `About to call DELETE ${built.url}. Continue?`, + initial: false, + }); + if (!ans.ok) return; + } catch { + return; + } + } + + const apiOrigin = this.sessionContext.apiOrigin.replace(/\/$/, ""); + + if (flags.generate === "curl") { + const line = generateCurl({ + method: built.method, + url: built.url, + absoluteUrl: `${apiOrigin}${built.url}`, + params: built.params as Record, + data: built.data, + headers: built.headers, + }); + this.log(line); + return { curl: line }; + } + + const renderOpts = { + raw: flags.raw, + include: flags.include, + silent: flags.silent, + verbose: flags.verbose, + log: this.log.bind(this), + logToStderr: this.logToStderr.bind(this), + }; + + const resp = await requestWithOptionalPagination( + this.apiV1.client, + { + method: built.method, + url: built.url, + params: built.params as Record, + data: built.data, + headers: built.headers, + }, + flags.paginate, + renderOpts, + ); + + if (flags.json) { + const payload = { + status: resp.status, + statusText: resp.statusText, + headers: resp.headers, + data: resp.data, + }; + this.logJson(this.toSuccessJson(payload)); + if (resp.status >= 400) { + // With `enableJsonFlag`, `this.exit()` is handled inside oclif and emits a second JSON + // error object; set the exit code without throwing so only the response payload prints. + const prev = process.exitCode; + process.exitCode = prev !== undefined && prev !== 0 ? prev : 1; + } + + return; + } + + renderResponse(resp, renderOpts); + + if (resp.status >= 400) { + this.exit(1); + } + + return resp.data; + } +} diff --git a/src/commands/mapi/ls.ts b/src/commands/mapi/ls.ts new file mode 100644 index 00000000..0848e74e --- /dev/null +++ b/src/commands/mapi/ls.ts @@ -0,0 +1,105 @@ +import { Flags, ux } from "@oclif/core"; + +import BaseCommand from "@/lib/base-command"; +import { listEndpoints } from "@/lib/mapi/endpoint-resolver"; +import { loadOpenApiDocument } from "@/lib/mapi/openapi-loader"; +import type { Endpoint } from "@/lib/mapi/types"; + +export default class MapiLs extends BaseCommand { + static summary = "List Management API endpoints from the OpenAPI spec."; + + static description = `Fetches (or loads from cache) the live OpenAPI document and lists operations. + +Use oclif global \`--json\` for machine-readable output (array of operations). Use \`--format json\` for the same JSON on stdout without the global JSON wrapper used by other commands.`; + + static aliases = ["list"]; + + static enableJsonFlag = true; + + static flags = { + format: Flags.string({ + summary: "Output format.", + options: ["human", "json"], + default: "human", + }), + tag: Flags.string({ + summary: "Filter by OpenAPI tag name (exact match, case-sensitive).", + }), + search: Flags.string({ + summary: + "Filter by substring match on path, operationId, or summary (case-insensitive).", + }), + refresh: Flags.boolean({ + summary: "Force refresh of the cached OpenAPI specification.", + }), + }; + + async run(): Promise { + const { flags } = this.props; + + const { spec } = await loadOpenApiDocument({ + apiOrigin: this.sessionContext.apiOrigin, + cacheDir: this.config.cacheDir, + refresh: flags.refresh, + onStaleCache: (m) => this.warn(m), + }); + + let endpoints = listEndpoints(spec); + + if (flags.tag) { + endpoints = endpoints.filter((e) => e.tags.includes(flags.tag!)); + } + + if (flags.search) { + const q = flags.search.toLowerCase(); + endpoints = endpoints.filter( + (e) => + e.path.toLowerCase().includes(q) || + e.operationId.toLowerCase().includes(q) || + e.summary.toLowerCase().includes(q), + ); + } + + const payload = endpoints.map((e) => ({ + method: e.method, + path: e.path, + operationId: e.operationId, + summary: e.summary, + tags: e.tags, + })); + + if (flags.json) { + return payload; + } + + if (flags.format === "json") { + this.log(JSON.stringify(payload, null, 2)); + return; + } + + const byTag = new Map(); + for (const e of endpoints) { + const tag = e.tags[0] ?? "Other"; + if (!byTag.has(tag)) byTag.set(tag, []); + byTag.get(tag)!.push(e); + } + + for (const [tag, eps] of [...byTag.entries()].sort((a, b) => + a[0].localeCompare(b[0]), + )) { + this.log(`\n[${tag}]`); + ux.table( + eps.sort((a, b) => a.path.localeCompare(b.path)), + { + method: { + header: "METHOD", + get: (r) => r.method.toUpperCase(), + }, + path: { header: "PATH" }, + operationId: { header: "OPERATION" }, + summary: { header: "SUMMARY" }, + }, + ); + } + } +} diff --git a/test/commands/mapi/index.test.ts b/test/commands/mapi/index.test.ts new file mode 100644 index 00000000..5fcb31ae --- /dev/null +++ b/test/commands/mapi/index.test.ts @@ -0,0 +1,250 @@ +import * as path from "node:path"; + +import { expect, test } from "@oclif/test"; +import * as fs from "fs-extra"; +import nock from "nock"; + +const openApiFixturePath = path.resolve( + __dirname, + "../../support/fixtures/mapi-openapi.json", +); + +describe("commands/mapi/index", () => { + afterEach(() => { + nock.cleanAll(); + process.exitCode = undefined; + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stdout() + .do(async () => { + const spec = await fs.readJSON(openApiFixturePath); + nock("https://control.knock.app") + .get("/v1/openapi") + .reply(200, spec) + .get("/v1/whoami") + .reply(200, { account_name: "Collab" }); + }) + .command(["mapi", "/v1/whoami"]) + .it("prints whoami response", (ctx) => { + expect(ctx.stdout).to.contain("Collab"); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stdout() + .do(async () => { + const spec = await fs.readJSON(openApiFixturePath); + nock("https://control.knock.app") + .get("/v1/openapi") + .reply(200, spec) + .put("/v1/workflows/wf-1/run", { recipients: ["u1"] }) + .query({ environment: "development" }) + .reply(200, { ok: true }); + }) + .command([ + "mapi", + "/v1/workflows/wf-1/run", + "-F", + "environment=development", + "-F", + 'recipients=["u1"]', + ]) + .it("runs workflow with path template and body", (ctx) => { + expect(ctx.stdout).to.contain("ok"); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stdout() + .do(async () => { + const spec = await fs.readJSON(openApiFixturePath); + nock("https://control.knock.app") + .get("/v1/openapi") + .reply(200, spec) + .get("/v1/workflows") + .query({ environment: "development" }) + .reply(200, { + entries: [{ key: "a" }], + page_info: { after: "c1", page_size: 1 }, + total_count: 99, + }) + .get("/v1/workflows") + .query({ environment: "development", after: "c1" }) + .reply(200, { + entries: [{ key: "b" }], + page_info: {}, + }); + }) + .command([ + "mapi", + "/v1/workflows", + "--paginate", + "-F", + "environment=development", + ]) + .it("paginates list workflows", (ctx) => { + expect(ctx.stdout).to.contain("a"); + expect(ctx.stdout).to.contain("b"); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stdout() + .do(async () => { + const spec = await fs.readJSON(openApiFixturePath); + nock("https://control.knock.app") + .get("/v1/openapi") + .reply(200, spec) + .get("/v1/workflows") + .query({ environment: "development" }) + .reply(200, { + entries: [{ key: "a" }], + page_info: { after: "c1", page_size: 1 }, + total_count: 99, + }) + .get("/v1/workflows") + .query({ environment: "development", after: "c1" }) + .reply(200, { + entries: [{ key: "b" }], + page_info: {}, + }); + }) + .command([ + "mapi", + "/v1/workflows", + "--paginate", + "--json", + "-F", + "environment=development", + ]) + .it("paginate merge keeps first-page metadata in --json output", (ctx) => { + expect(ctx.stdout).to.contain('"total_count": 99'); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stdout() + .do(async () => { + const spec = await fs.readJSON(openApiFixturePath); + nock("https://control.knock.app").get("/v1/openapi").reply(200, spec); + }) + .command(["mapi", "/v1/whoami", "--generate", "curl"]) + .it("prints curl command with token placeholder", (ctx) => { + expect(ctx.stdout).to.contain("curl"); + expect(ctx.stdout).to.contain("Bearer $KNOCK_SERVICE_TOKEN"); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stdout() + .do(async () => { + const spec = await fs.readJSON(openApiFixturePath); + nock("https://control.knock.app").get("/v1/openapi").reply(200, spec); + }) + .command([ + "mapi", + "/v1/whoami", + "--generate", + "curl", + "-H", + "Authorization: Bearer custom", + ]) + .it( + "generate curl omits token placeholder when Authorization is set", + (ctx) => { + expect(ctx.stdout).to.contain("Bearer custom"); + expect(ctx.stdout).to.not.contain("$KNOCK_SERVICE_TOKEN"); + }, + ); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stderr() + .stdout() + .do(async () => { + const spec = await fs.readJSON(openApiFixturePath); + nock("https://control.knock.app") + .get("/v1/openapi") + .reply(200, spec) + .get("/v1/whoami") + .query({ a: "2" }) + .reply(200, { ok: true }); + }) + .command(["mapi", "/v1/whoami", "-F", "a=1", "-F", "a=2"]) + .it("warns on duplicate -F keys", (ctx) => { + expect(ctx.stderr).to.contain("Warning:"); + expect(ctx.stderr).to.contain('Duplicate field key "a"'); + expect(ctx.stdout).to.contain("ok"); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .do(async () => { + const spec = await fs.readJSON(openApiFixturePath); + nock("https://control.knock.app") + .get("/v1/openapi") + .reply(200, spec) + .delete("/v1/resource/r1") + .reply(204); + }) + .command([ + "mapi", + "deleteResource", + "-F", + "id=r1", + "--dangerously-skip-permissions", + ]) + .it("sends DELETE without prompt", () => { + expect(nock.isDone()).to.equal(true); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .do(async () => { + const spec = await fs.readJSON(openApiFixturePath); + nock("https://control.knock.app") + .get("/v1/openapi") + .reply(200, spec) + .get("/v1/whoami") + .reply(404, { error: "not found" }); + }) + .stdout() + .command(["mapi", "/v1/whoami", "--json"]) + .it("exits 1 when HTTP error with --json", (ctx) => { + // oclif swallows `ExitError` when --json is set and sets `process.exitCode` + // instead of rejecting `runCommand`, so `.exit(1)` from @oclif/test does not apply. + expect(process.exitCode).to.equal(1); + expect(ctx.stdout).to.contain("not found"); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .do(async () => { + const spec = await fs.readJSON(openApiFixturePath); + nock("https://control.knock.app") + .get("/v1/openapi") + .reply(200, spec) + .get("/v1/whoami") + .reply(404, { error: "not found" }); + }) + .stdout() + .command(["mapi", "/v1/whoami"]) + .exit(1) + .it("exits 1 when HTTP error without --json", (ctx) => { + expect(ctx.stdout).to.contain("not found"); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .do(async () => { + const spec = await fs.readJSON(openApiFixturePath); + nock("https://control.knock.app").get("/v1/openapi").reply(200, spec); + }) + .command(["mapi", "/v1/ambiguous-methods"]) + .catch((error: Error) => { + expect(error.message).to.match(/Ambiguous endpoint/); + }) + .it("errors when path matches multiple methods without -X"); +}); diff --git a/test/commands/mapi/ls.test.ts b/test/commands/mapi/ls.test.ts new file mode 100644 index 00000000..eac23fa7 --- /dev/null +++ b/test/commands/mapi/ls.test.ts @@ -0,0 +1,42 @@ +import * as path from "node:path"; + +import { expect, test } from "@oclif/test"; +import * as fs from "fs-extra"; +import nock from "nock"; + +const openApiFixturePath = path.resolve( + __dirname, + "../../support/fixtures/mapi-openapi.json", +); + +describe("commands/mapi/ls", () => { + afterEach(() => { + nock.cleanAll(); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stdout() + .do(async () => { + const spec = await fs.readJSON(openApiFixturePath); + nock("https://control.knock.app").get("/v1/openapi").reply(200, spec); + }) + .command(["mapi:ls", "--format", "json"]) + .it("prints operations as JSON", (ctx) => { + expect(ctx.stdout).to.contain("getWhoami"); + expect(ctx.stdout).to.contain("/v1/whoami"); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stdout() + .do(async () => { + const spec = await fs.readJSON(openApiFixturePath); + nock("https://control.knock.app").get("/v1/openapi").reply(200, spec); + }) + .command(["mapi:ls", "--tag", "Accounts"]) + .it("filters by tag", (ctx) => { + expect(ctx.stdout).to.contain("Accounts"); + expect(ctx.stdout).to.contain("whoami"); + }); +});