diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 784bbdf..f23899b 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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"; @@ -24,6 +25,7 @@ const commands: CommandModule[] = [ whoamiCommand, dbNamespace, scriptsNamespace, + pzNamespace, configNamespace, docsCommand, openCommand, diff --git a/packages/cli/src/commands/pz/constants.ts b/packages/cli/src/commands/pz/constants.ts new file mode 100644 index 0000000..30d3aca --- /dev/null +++ b/packages/cli/src/commands/pz/constants.ts @@ -0,0 +1,6 @@ +export const PULL_ZONE_MANIFEST = "pullzone.json"; + +export interface PullZoneManifest { + id: number; + name?: string; +} diff --git a/packages/cli/src/commands/pz/create.ts b/packages/cli/src/commands/pz/create.ts new file mode 100644 index 0000000..bb77b86 --- /dev/null +++ b/packages/cli/src/commands/pz/create.ts @@ -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 { + name?: string; + origin?: string; +} + +export const pzCreateCommand = defineCommand({ + command: "create ", + 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}`); + } + + 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.`); + + // Offer to select it + if (createdId) { + const shouldSelect = await confirm( + `Set "${name}" as the active context?`, + ); + if (shouldSelect) { + saveManifest(PULL_ZONE_MANIFEST, { + id: createdId, + name: created?.Name ?? undefined, + }); + logger.success(`Selected ${name}.`); + } + } + }, +}); diff --git a/packages/cli/src/commands/pz/delete.ts b/packages/cli/src/commands/pz/delete.ts new file mode 100644 index 0000000..d86b76b --- /dev/null +++ b/packages/cli/src/commands/pz/delete.ts @@ -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({ + 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(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(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.`); + }, +}); diff --git a/packages/cli/src/commands/pz/index.ts b/packages/cli/src/commands/pz/index.ts new file mode 100644 index 0000000..07d6661 --- /dev/null +++ b/packages/cli/src/commands/pz/index.ts @@ -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, + ], +); diff --git a/packages/cli/src/commands/pz/link.ts b/packages/cli/src/commands/pz/link.ts new file mode 100644 index 0000000..49ad57b --- /dev/null +++ b/packages/cli/src/commands/pz/link.ts @@ -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({ + 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(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; + } + + saveManifest(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}.`); + }, +}); diff --git a/packages/cli/src/commands/pz/list.ts b/packages/cli/src/commands/pz/list.ts new file mode 100644 index 0000000..912012b --- /dev/null +++ b/packages/cli/src/commands/pz/list.ts @@ -0,0 +1,59 @@ +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +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 { 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, + ), + ); + }, +}); diff --git a/packages/cli/src/commands/pz/purge.ts b/packages/cli/src/commands/pz/purge.ts new file mode 100644 index 0000000..c5cc4cc --- /dev/null +++ b/packages/cli/src/commands/pz/purge.ts @@ -0,0 +1,61 @@ +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 } from "../../core/manifest.ts"; +import { spinner } from "../../core/ui.ts"; +import { PULL_ZONE_MANIFEST, type PullZoneManifest } from "./constants.ts"; + +interface PurgeArgs { + id?: number; +} + +export const pzPurgeCommand = defineCommand({ + command: "purge [id]", + describe: "Purge cached files for a pull zone.", + examples: [ + ["$0 pz purge", "Purge cache for selected pull zone"], + ["$0 pz purge 12345", "Purge cache for pull zone 12345"], + ], + + builder: (yargs) => + yargs.positional("id", { + type: "number", + describe: "Pull zone ID (uses selected one if omitted)", + }), + + handler: async ({ id, profile, output, verbose, apiKey }) => { + const zoneId = id ?? loadManifest(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 spin = spinner("Purging cache..."); + spin.start(); + + const { error } = await client.POST("/pullzone/{id}/purgeCache", { + params: { path: { id: zoneId } }, + }); + + spin.stop(); + + if (error) { + throw new UserError(`Failed to purge cache: ${error}`); + } + + if (output === "json") { + logger.log(JSON.stringify({ id: zoneId, purged: true })); + return; + } + + logger.success(`Cache purged for pull zone ${zoneId}.`); + }, +}); diff --git a/packages/cli/src/commands/pz/show.ts b/packages/cli/src/commands/pz/show.ts new file mode 100644 index 0000000..3be1d4b --- /dev/null +++ b/packages/cli/src/commands/pz/show.ts @@ -0,0 +1,99 @@ +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +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 { formatKeyValue } from "../../core/format.ts"; +import { logger } from "../../core/logger.ts"; +import { loadManifest } from "../../core/manifest.ts"; +import { spinner } from "../../core/ui.ts"; +import { PULL_ZONE_MANIFEST, type PullZoneManifest } from "./constants.ts"; + +interface ShowArgs { + id?: number; +} + +export const pzShowCommand = defineCommand({ + command: "show [id]", + describe: "Show pull zone details.", + examples: [ + ["$0 pz show", "Show selected pull zone"], + ["$0 pz show 12345", "Show pull zone 12345"], + ], + + builder: (yargs) => + yargs.positional("id", { + type: "number", + describe: "Pull zone ID (uses selected one if omitted)", + }), + + handler: async ({ id, profile, output, verbose, apiKey }) => { + const zoneId = id ?? loadManifest(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 spin = spinner("Fetching pull zone..."); + spin.start(); + + let zone: components["schemas"]["PullZoneModel"] | undefined; + + try { + const { data } = await client.GET("/pullzone/{id}", { + params: { path: { id: zoneId } }, + }); + zone = data as components["schemas"]["PullZoneModel"] | undefined; + } catch (err: unknown) { + spin.stop(); + const msg = err instanceof Error ? err.message : String(err); + throw new UserError(`Failed to fetch pull zone ${zoneId}: ${msg}`); + } + + spin.stop(); + + if (!zone) { + throw new UserError(`Pull zone ${zoneId} not found.`); + } + + if (output === "json") { + logger.log(JSON.stringify(zone, null, 2)); + return; + } + + const hostnames = zone.Hostnames + ?.map((h) => h.Value) + .filter(Boolean) + .join(", ") ?? "none"; + + const status = zone.Suspended + ? "Suspended" + : zone.Enabled + ? "Active" + : "Disabled"; + + const security = zone.ZoneSecurityEnabled ? "Enabled" : "Disabled"; + + logger.log( + formatKeyValue( + [ + { key: "ID", value: String(zone.Id ?? "") }, + { key: "Name", value: zone.Name ?? "" }, + { key: "Origin", value: zone.OriginUrl ?? "" }, + { key: "Status", value: status }, + { key: "Hostnames", value: hostnames }, + { key: "Storage Zone ID", value: String(zone.StorageZoneId ?? "") }, + { key: "Edge Script ID", value: String(zone.EdgeScriptId ?? "") }, + { key: "Security", value: security }, + ], + output, + ), + ); + }, +}); diff --git a/packages/cli/src/commands/pz/unlink.ts b/packages/cli/src/commands/pz/unlink.ts new file mode 100644 index 0000000..e9a192a --- /dev/null +++ b/packages/cli/src/commands/pz/unlink.ts @@ -0,0 +1,23 @@ +import { defineCommand } from "../../core/define-command.ts"; +import { logger } from "../../core/logger.ts"; +import { removeManifest } from "../../core/manifest.ts"; +import { PULL_ZONE_MANIFEST } from "./constants.ts"; + +export const pzUnlinkCommand = defineCommand({ + command: "unlink", + describe: "Unlink the current directory from its pull zone.", + examples: [ + ["$0 pz unlink", "Unlink the current pull zone"], + ], + + handler: async ({ output }) => { + removeManifest(PULL_ZONE_MANIFEST); + + if (output === "json") { + logger.log(JSON.stringify({ unlinked: true })); + return; + } + + logger.success("Pull zone unlinked."); + }, +});