Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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;
}
82 changes: 82 additions & 0 deletions packages/cli/src/commands/pz/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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 +48 to +57
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.

Copy link
Copy Markdown
Author

@burstw0w burstw0w Jun 3, 2026

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}.`);
}
}
},
});
99 changes: 99 additions & 0 deletions packages/cli/src/commands/pz/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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, error: getError } = await client.GET("/pullzone/{id}", {
params: { path: { id: zoneId } },
});

if (getError) {
throw new UserError(
`Failed to fetch pull zone ${zoneId}: ${getError.message ?? getError}`,
);
}

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.`);
},
});
26 changes: 26 additions & 0 deletions packages/cli/src/commands/pz/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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,
],
Comment thread
burstw0w marked this conversation as resolved.
Comment on lines +1 to +25
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 README.md and AGENTS.md not updated

CLAUDE.md explicitly requires: "When adding, changing, or removing commands or flags, update the corresponding sections in README.md — user-facing command docs and examples. AGENTS.md — architecture docs, command reference tree, and file listing." The new pz namespace and its seven sub-commands are absent from both documents.

Context Used: CLAUDE.md (source)

Fix in Claude Code

);
120 changes: 120 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,120 @@
import type { components } from "@bunny.net/openapi-client/generated/core.d.ts";
import { createCoreClient } from "@bunny.net/openapi-client";
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,
});

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

logger.success(`Linked to ${selected.Name ?? selected.Id}.`);
},
});
Loading