Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
876663f
added initial pullzone commands
burstw0w Jun 1, 2026
337171d
changed pullzone to pz
burstw0w Jun 1, 2026
54b9cbc
changed pullzone to pz
burstw0w Jun 1, 2026
b2c3762
fix: reject numberic pull zone IDs not in account list
burstw0w Jun 2, 2026
c9dc23d
fix: changed pullzone to pz
burstw0w Jun 2, 2026
e020c26
fix: removed unused subcommands
burstw0w Jun 2, 2026
1be9bb4
fix: removeManifest only runs when manifests ID matches the deleted z…
burstw0w Jun 2, 2026
02e23ab
fix: define exact fields needed for cloning
burstw0w Jun 2, 2026
65878d1
pullzone commands fixes
burstw0w Jun 3, 2026
621118b
Merge branch 'main' into pullzone-commands
burstw0w Jun 3, 2026
5f42010
removed unused files
burstw0w Jun 3, 2026
e7c25ca
Merge branch 'main' into pullzone-commands
burstw0w Jun 5, 2026
cd96e9f
Error check
burstw0w Jun 5, 2026
74a48a4
Removed unused ARG_PULL_ZONE_ID
burstw0w Jun 5, 2026
674528e
Added spinner to show.ts
burstw0w Jun 5, 2026
b9a80f4
Switched exit for return
burstw0w Jun 5, 2026
34b5040
Merge branch 'main' into pullzone-commands
burstw0w Jun 5, 2026
f69b0f5
Added error catch
burstw0w Jun 5, 2026
cffc01a
fixed for CI
burstw0w Jun 9, 2026
daab3b7
Merge branch 'main' into pullzone-commands
burstw0w Jun 9, 2026
de22734
Merge branch 'main' into pullzone-commands
jamie-at-bunny Jun 15, 2026
3863532
Merge branch 'main' into pullzone-commands
jamie-at-bunny Jun 16, 2026
6db8a3a
Merge branch 'main' into pullzone-commands
jamie-at-bunny Jun 25, 2026
96a0b42
Merge branch 'main' into pullzone-commands
jamie-at-bunny Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { dbNamespace } from "./commands/db/index.ts";
import { dnsNamespace } from "./commands/dns/index.ts";
import { docsCommand } from "./commands/docs.ts";
import { openCommand } from "./commands/open.ts";
import { pzNamespace } from "./commands/pz/index.ts";
import { registriesNamespace } from "./commands/registries/index.ts";
import { scriptsNamespace } from "./commands/scripts/index.ts";
import { whoamiCommand } from "./commands/whoami.ts";
Expand All @@ -24,6 +25,7 @@ const commands: CommandModule[] = [
whoamiCommand,
dbNamespace,
scriptsNamespace,
pzNamespace,
configNamespace,
docsCommand,
openCommand,
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/commands/pz/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const PULL_ZONE_MANIFEST = "pullzone.json";

export interface PullZoneManifest {
id: number;
name?: string;
}
79 changes: 79 additions & 0 deletions packages/cli/src/commands/pz/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { createCoreClient } from "@bunny.net/openapi-client";
import { resolveConfig } from "../../config/index.ts";
import { clientOptions } from "../../core/client-options.ts";
import { defineCommand } from "../../core/define-command.ts";
import { UserError } from "../../core/errors.ts";
import { logger } from "../../core/logger.ts";
import { saveManifest } from "../../core/manifest.ts";
import { confirm, spinner } from "../../core/ui.ts";
import { PULL_ZONE_MANIFEST, type PullZoneManifest } from "./constants.ts";

interface CreateArgs {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

components["schemas"]["EdgeRuleV2Model"] should be used inline instead of declaring here, at worst, we can Pick from here, although I'd rather not limit scope and use the input type as is, which allows us to spread on more options.

name?: string;
origin?: string;
}

export const pzCreateCommand = defineCommand<CreateArgs>({
command: "create <name> <origin>",
describe: "Create a new pull zone.",
examples: [
["$0 pz create my-zone https://origin.example.com", "Create a pull zone"],
],

builder: (yargs) =>
yargs
.positional("name", { type: "string", describe: "Pull zone name" })
.positional("origin", {
type: "string",
describe: "Origin URL (https:// is prepended if missing)",
}),

handler: async ({ name, origin, profile, output, verbose, apiKey }) => {
if (!name || !origin) {
throw new UserError("Name and origin are required.");
}

const url = origin.match(/^https?:\/\//) ? origin : `https://${origin}`;

const config = resolveConfig(profile, apiKey, verbose);
const client = createCoreClient(clientOptions(config, verbose));

// Create
const createSpin = spinner("Creating pull zone...");
createSpin.start();

const { data, error } = await client.POST("/pullzone", {
body: { Name: name, OriginUrl: url } as any,
});

createSpin.stop();

if (error) {
throw new UserError(`Failed to create pull zone: ${error}`);
}

Comment on lines +45 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 as any cast and inline type instead of generated schema types

The POST body is cast with as any and the response is typed with an inline { Id?: number; Name?: string | null } rather than using Pick<components["schemas"]["PullZone"], ...> from @bunny.net/openapi-client/generated/core.d.ts. CLAUDE.md explicitly requires: "Prefer generated schema types over inline primitives. Only fall back to string, any, or number when no generated type exists." Both casts bypass compile-time safety, so a field rename in the spec would silently break this command.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

const created = data as { Id?: number; Name?: string | null } | undefined;
const createdId = created?.Id;

if (output === "json") {
logger.log(JSON.stringify(created, null, 2));
return;
}

logger.success(`Pull zone "${name}" created.`);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be a nice DX improvement to share the URL with some kind of arrow to the origin so it's clear the zone is for.

@burstw0w burstw0w Jun 3, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i am currently fixing stuff, and i am not sure what purpose would this serve as you already know which origin you use, as you have to define it in the command

pz create [pullzoneName] [origin]

I can print it out again, but devs probably know what they wrote second ago?


// Offer to select it
if (createdId) {
const shouldSelect = await confirm(
`Set "${name}" as the active context?`,
);
if (shouldSelect) {
saveManifest<PullZoneManifest>(PULL_ZONE_MANIFEST, {
id: createdId,
name: created?.Name ?? undefined,
});
logger.success(`Selected ${name}.`);
}
}
},
});
88 changes: 88 additions & 0 deletions packages/cli/src/commands/pz/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { createCoreClient } from "@bunny.net/openapi-client";
import { resolveConfig } from "../../config/index.ts";
import { clientOptions } from "../../core/client-options.ts";
import { defineCommand } from "../../core/define-command.ts";
import { UserError } from "../../core/errors.ts";
import { logger } from "../../core/logger.ts";
import { loadManifest, removeManifest } from "../../core/manifest.ts";
import { confirm, spinner } from "../../core/ui.ts";
import { PULL_ZONE_MANIFEST, type PullZoneManifest } from "./constants.ts";

interface DeleteArgs {
id?: number;
force?: boolean;
}

export const pzDeleteCommand = defineCommand<DeleteArgs>({
command: "delete [id]",
describe: "Delete a pull zone.",
examples: [
["$0 pz delete", "Delete selected pull zone"],
["$0 pz delete 12345", "Delete pull zone 12345"],
["$0 pz delete --force", "Skip confirmation"],
],

builder: (yargs) =>
yargs
.positional("id", {
type: "number",
describe: "Pull zone ID (uses selected one if omitted)",
})
.option("force", {
alias: "f",
type: "boolean",
default: false,
describe: "Skip confirmation",
}),

handler: async ({ id, force, profile, output, verbose, apiKey }) => {
const zoneId = id ?? loadManifest<PullZoneManifest>(PULL_ZONE_MANIFEST).id;
if (!zoneId) {
throw new UserError(
"No pull zone specified.",
'Pass a pull zone ID or run "bunny pz link" first.',
);
}

const config = resolveConfig(profile, apiKey, verbose);
const client = createCoreClient(clientOptions(config, verbose));

const { data: zone } = await client.GET("/pullzone/{id}", {
params: { path: { id: zoneId } },
});

const label = zone?.Name ? `${zone.Name} (${zoneId})` : String(zoneId);

const ok = await confirm(`Delete pull zone ${label}?`, { force });
if (!ok) {
logger.log("Delete cancelled.");
return;
}

const spin = spinner("Deleting pull zone...");
spin.start();

const { error } = await client.DELETE("/pullzone/{id}", {
params: { path: { id: zoneId } },
});

spin.stop();

if (error) {
throw new UserError(`Failed to delete pull zone: ${error}`);
}

// Remove manifest only if it pointed at the deleted zone
const manifest = loadManifest<PullZoneManifest>(PULL_ZONE_MANIFEST);
if (manifest.id === zoneId) {
removeManifest(PULL_ZONE_MANIFEST);
}

if (output === "json") {
logger.log(JSON.stringify({ id: zoneId, deleted: true }));
return;
}

logger.success(`Pull zone ${label} deleted.`);
},
});
22 changes: 22 additions & 0 deletions packages/cli/src/commands/pz/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { defineNamespace } from "../../core/define-namespace.ts";
import { pzCreateCommand } from "./create.ts";
import { pzDeleteCommand } from "./delete.ts";
import { pzLinkCommand } from "./link.ts";
import { pzListCommand } from "./list.ts";
import { pzPurgeCommand } from "./purge.ts";
import { pzShowCommand } from "./show.ts";
import { pzUnlinkCommand } from "./unlink.ts";

// TODO: implement rules and hostnames subcommands
// const rulesNamespace = defineNamespace("rules", "Manage pull zone edge rules.", [...]);
// const hostnamesNamespace = defineNamespace("hostnames", "Manage pull zone hostnames.", [...]);

export const pzNamespace = defineNamespace("pz", "Manage pull zones.", [
pzListCommand,
pzCreateCommand,
pzDeleteCommand,
pzLinkCommand,
pzPurgeCommand,
pzShowCommand,
pzUnlinkCommand,
]);
117 changes: 117 additions & 0 deletions packages/cli/src/commands/pz/link.ts
Comment thread
burstw0w marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { createCoreClient } from "@bunny.net/openapi-client";
import type { components } from "@bunny.net/openapi-client/generated/core.d.ts";
import prompts from "prompts";
import { resolveConfig } from "../../config/index.ts";
import { clientOptions } from "../../core/client-options.ts";
import { defineCommand } from "../../core/define-command.ts";
import { UserError } from "../../core/errors.ts";
import { logger } from "../../core/logger.ts";
import { saveManifest } from "../../core/manifest.ts";
import { spinner } from "../../core/ui.ts";
import { PULL_ZONE_MANIFEST, type PullZoneManifest } from "./constants.ts";

interface LinkArgs {
id?: number;
}

export const pzLinkCommand = defineCommand<LinkArgs>({
command: "link [id]",
describe: "Link the current directory to a pull zone.",
examples: [
["$0 pz link", "Interactive selection"],
["$0 pz link 12345", "Link by ID"],
],

builder: (yargs) =>
yargs.positional("id", {
type: "number",
describe: "Pull zone ID",
}),

handler: async ({ id, profile, output, verbose, apiKey }) => {
const config = resolveConfig(profile, apiKey, verbose);
const client = createCoreClient(clientOptions(config, verbose));

if (id) {
const spin = spinner("Fetching pull zone...");
spin.start();

let zone: components["schemas"]["PullZoneModel"] | undefined;

try {
const { data } = await client.GET("/pullzone/{id}", {
params: { path: { id } },
});
zone = data as components["schemas"]["PullZoneModel"] | undefined;
} catch (err: unknown) {
spin.stop();
const msg = err instanceof Error ? err.message : String(err);
throw new UserError(`Fetching failed: ${msg}`);
}

spin.stop();

if (!zone) {
throw new UserError(`Pull zone ${id} not found.`);
}

saveManifest<PullZoneManifest>(PULL_ZONE_MANIFEST, {
id: zone.Id ?? id,
});

if (output === "json") {
logger.log(JSON.stringify({ id: zone.Id ?? id }));
return;
}

logger.success(`Linked to ${zone.Name ?? zone.Id ?? id}.`);
return;
}

const spin = spinner("Fetching pull zones...");
spin.start();

const { data } = await client.GET("/pullzone");

spin.stop();

const zones = (data ?? []) as components["schemas"]["PullZoneModel"][];

if (zones.length === 0) {
throw new UserError(
"No pull zones found.",
'Run "bunny pz create" to create one.',
);
}

const sorted = zones.sort((a, b) =>
(a.Name ?? "").localeCompare(b.Name ?? ""),
);

const { selected } = await prompts({
type: "select",
name: "selected",
message: "Link to a pull zone:",
choices: sorted.map((zone) => ({
title: zone.Name ?? String(zone.Id),
value: zone,
})),
});

if (!selected) {
logger.log("Link cancelled.");
return;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

saveManifest<PullZoneManifest>(PULL_ZONE_MANIFEST, {
id: selected.Id,
});
Comment on lines +106 to +108

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 selected.Id saved without nullish fallback

In the interactive path, selected.Id (typed as number | null | undefined from PullZoneModel) is written directly to the manifest. If the API ever returns a zone without an Id, the manifest is saved as { id: undefined }. Every subsequent command that reads the manifest — pz show, pz purge, pz delete — resolves zoneId as undefined and throws "No pull zone specified", making the linked context appear broken for no clear reason.

The direct-ID path already has the right pattern: id: zone.Id ?? id. The interactive path should apply the same guard before writing the manifest. Compare with link.ts line 59.

Fix in Claude Code


if (output === "json") {
logger.log(JSON.stringify({ id: selected.Id }));
return;
}

logger.success(`Linked to ${selected.Name ?? selected.Id}.`);
},
});
59 changes: 59 additions & 0 deletions packages/cli/src/commands/pz/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { createCoreClient } from "@bunny.net/openapi-client";
import type { components } from "@bunny.net/openapi-client/generated/core.d.ts";
import { resolveConfig } from "../../config/index.ts";
import { clientOptions } from "../../core/client-options.ts";
import { defineCommand } from "../../core/define-command.ts";
import { formatTable } from "../../core/format.ts";
import { logger } from "../../core/logger.ts";
import { spinner } from "../../core/ui.ts";

type PullZone = components["schemas"]["PullZoneModel"];

export const pzListCommand = defineCommand({
command: "list",
aliases: ["ls"] as const,
describe: "List all pull zones.",
examples: [
["$0 pz list", "List all pull zones"],
["$0 pz list --output json", "JSON output"],
],

handler: async ({ profile, output, verbose, apiKey }) => {
const config = resolveConfig(profile, apiKey, verbose);
const client = createCoreClient(clientOptions(config, verbose));

const spin = spinner("Fetching pull zones...");
spin.start();

const { data } = await client.GET("/pullzone");

spin.stop();

const zones = ((data ?? []) as PullZone[]).sort(
(a: PullZone, b: PullZone) => (a.Name ?? "").localeCompare(b.Name ?? ""),
);

if (output === "json") {
logger.log(JSON.stringify(zones, null, 2));
return;
}

if (zones.length === 0) {
logger.info("No pull zones found.");
return;
}

logger.log(
formatTable(
["ID", "Name", "Origin", "Status"],
zones.map((zone: PullZone) => [
String(zone.Id ?? ""),
zone.Name ?? "",
zone.OriginUrl ?? "",
zone.Suspended ? "Suspended" : zone.Enabled ? "Active" : "Disabled",
]),
output,
),
);
},
});
Loading