Skip to content

Commit 43601cb

Browse files
Merge pull request #32 from BunnyWay/api-command
feat(cli/api): add api command
2 parents 06aacca + 49dcf66 commit 43601cb

4 files changed

Lines changed: 208 additions & 0 deletions

File tree

.changeset/cute-bats-hunt.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@bunny.net/cli": patch
3+
---
4+
5+
Add raw API command for making authenticated HTTP requests to any bunny.net endpoint

packages/cli/README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,39 @@ bunny scripts show <script-id>
404404
bunny scripts show
405405
```
406406

407+
### `bunny api`
408+
409+
Make a raw authenticated HTTP request to any bunny.net API endpoint. Auth is handled automatically via your configured API key.
410+
411+
```bash
412+
# List pull zones
413+
bunny api GET /pullzone
414+
415+
# Get a specific pull zone
416+
bunny api GET /pullzone/12345
417+
418+
# List databases
419+
bunny api GET /database/v2/databases
420+
421+
# Create a database with a JSON body
422+
bunny api POST /database/v2/databases --body '{"name":"test","storage_region":"DE","primary_regions":["DE"]}'
423+
424+
# Delete a DNS zone
425+
bunny api DELETE /dnszone/12345
426+
427+
# Pipe body from stdin
428+
echo '{"name":"test"}' | bunny api POST /database/v2/databases
429+
430+
# Show request/response details
431+
bunny api GET /pullzone --verbose
432+
```
433+
434+
| Flag | Alias | Description |
435+
| -------- | ----- | ------------------ |
436+
| `--body` | `-b` | JSON request body |
437+
438+
The method is case-insensitive (`get` and `GET` both work). Paths are relative to `https://api.bunny.net` — use `/database/...` for the Database API and `/mc/...` for Magic Containers.
439+
407440
## Global Options
408441

409442
| Flag | Alias | Description | Default |

packages/cli/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { registriesNamespace } from "./commands/registries/index.ts";
1111
import { scriptsNamespace } from "./commands/scripts/index.ts";
1212
import { docsCommand } from "./commands/docs.ts";
1313
import { whoamiCommand } from "./commands/whoami.ts";
14+
import { apiCommand } from "./commands/api.ts";
1415
import { logger } from "./core/logger.ts";
1516
import { getLatestVersion } from "./core/update-check.ts";
1617
import { VERSION } from "./core/version.ts";
@@ -24,6 +25,7 @@ const commands: CommandModule[] = [
2425
registriesNamespace,
2526
configNamespace,
2627
docsCommand,
28+
apiCommand,
2729
];
2830

2931
// Experimental commands — registered but hidden from help and landing page

packages/cli/src/commands/api.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { defineCommand } from "../core/define-command.ts";
2+
import { resolveConfig } from "../config/index.ts";
3+
import { logger } from "../core/logger.ts";
4+
import { UserError } from "../core/errors.ts";
5+
import { VERSION } from "../core/version.ts";
6+
7+
const BASE_URL = "https://api.bunny.net";
8+
9+
const COMMAND = "api <method> [path]";
10+
const DESCRIPTION = "Make a raw API request to bunny.net.";
11+
12+
const VALID_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
13+
14+
interface ApiArgs {
15+
method: string;
16+
path?: string;
17+
body?: string;
18+
}
19+
20+
/**
21+
* Make a raw authenticated HTTP request to the bunny.net API.
22+
*
23+
* A convenient low-level way to call any bunny.net API endpoint.
24+
* Auth is handled automatically via your configured API key.
25+
*
26+
* @example
27+
* ```bash
28+
* # List pull zones
29+
* bunny api GET /pullzone
30+
*
31+
* # List databases
32+
* bunny api GET /database/v2/databases
33+
*
34+
* # Create a database
35+
* bunny api POST /database/v2/databases --body '{"name":"test","storage_region":"DE","primary_regions":["DE"]}'
36+
*
37+
* # Delete a resource
38+
* bunny api DELETE /dnszone/12345
39+
*
40+
* # Pipe body from stdin
41+
* echo '{"name":"test"}' | bunny api POST /database/v2/databases
42+
* ```
43+
*/
44+
export const apiCommand = defineCommand<ApiArgs>({
45+
command: COMMAND,
46+
describe: DESCRIPTION,
47+
examples: [
48+
["$0 api GET /pullzone", "List pull zones"],
49+
["$0 api GET /database/v2/databases", "List databases"],
50+
["$0 api POST /database/v2/databases --body '{\"name\":\"test\"}'", "Create with JSON body"],
51+
],
52+
53+
builder: (yargs) =>
54+
yargs
55+
.positional("method", {
56+
type: "string",
57+
describe: "HTTP method (GET, POST, PUT, PATCH, DELETE)",
58+
demandOption: true,
59+
})
60+
.positional("path", {
61+
type: "string",
62+
describe: "API endpoint path (e.g. /pullzone)",
63+
})
64+
.option("body", {
65+
alias: "b",
66+
type: "string",
67+
describe: "JSON request body",
68+
}),
69+
70+
handler: async ({ method: rawMethod, path, body: bodyFlag, profile, output, verbose, apiKey }) => {
71+
const method = rawMethod.toUpperCase();
72+
if (!VALID_METHODS.includes(method as any)) {
73+
throw new UserError(
74+
`Invalid method: ${rawMethod}`,
75+
`Use one of: ${VALID_METHODS.join(", ")}`,
76+
);
77+
}
78+
79+
if (!path) {
80+
throw new UserError(
81+
"Path is required.",
82+
"Example: bunny api GET /pullzone",
83+
);
84+
}
85+
86+
const config = resolveConfig(profile, apiKey);
87+
if (!config.apiKey) {
88+
throw new UserError("Not logged in.", 'Run "bunny login" to authenticate.');
89+
}
90+
91+
const baseUrl = config.apiUrl ?? BASE_URL;
92+
const url = `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
93+
94+
// Body: --body flag, or read from stdin if not a TTY
95+
let requestBody: string | undefined = bodyFlag;
96+
if (!requestBody && !process.stdin.isTTY && method !== "GET") {
97+
const chunks: Buffer[] = [];
98+
for await (const chunk of process.stdin) {
99+
chunks.push(chunk);
100+
}
101+
const stdin = Buffer.concat(chunks).toString("utf-8").trim();
102+
if (stdin) requestBody = stdin;
103+
}
104+
105+
if (requestBody) {
106+
// Validate JSON
107+
try {
108+
JSON.parse(requestBody);
109+
} catch {
110+
throw new UserError("Invalid JSON body.", "Ensure --body contains valid JSON.");
111+
}
112+
}
113+
114+
const headers: Record<string, string> = {
115+
AccessKey: config.apiKey,
116+
"User-Agent": `bunny-cli/${VERSION}`,
117+
Accept: "application/json",
118+
};
119+
120+
if (requestBody) {
121+
headers["Content-Type"] = "application/json";
122+
}
123+
124+
if (verbose) {
125+
logger.debug(`→ ${method} ${url}`, true);
126+
if (requestBody) logger.debug(`→ Body: ${requestBody}`, true);
127+
}
128+
129+
const res = await fetch(url, {
130+
method,
131+
headers,
132+
body: requestBody,
133+
});
134+
135+
if (verbose) {
136+
logger.debug(`← ${res.status} ${res.statusText}`, true);
137+
}
138+
139+
const text = await res.text();
140+
141+
// Try to parse and pretty-print JSON
142+
let parsed: unknown;
143+
try {
144+
parsed = JSON.parse(text);
145+
} catch {
146+
// Not JSON — output raw
147+
if (!res.ok) {
148+
throw new UserError(`${res.status} ${res.statusText}`, text || undefined);
149+
}
150+
if (text) logger.log(text);
151+
return;
152+
}
153+
154+
if (!res.ok) {
155+
if (output === "json") {
156+
logger.log(JSON.stringify({ error: parsed, status: res.status }, null, 2));
157+
} else {
158+
const msg = typeof parsed === "object" && parsed !== null
159+
? (parsed as any).detail ?? (parsed as any).Message ?? (parsed as any).title ?? `${res.status} ${res.statusText}`
160+
: `${res.status} ${res.statusText}`;
161+
throw new UserError(msg);
162+
}
163+
process.exit(1);
164+
}
165+
166+
logger.log(JSON.stringify(parsed, null, 2));
167+
},
168+
});

0 commit comments

Comments
 (0)