diff --git a/.changeset/tame-pears-lick.md b/.changeset/tame-pears-lick.md new file mode 100644 index 0000000..6c46c1e --- /dev/null +++ b/.changeset/tame-pears-lick.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": minor +--- + +feat(storage): add storage zone and file commands with S3-compatible credentials diff --git a/AGENTS.md b/AGENTS.md index 8048223..3a6a977 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,11 +161,11 @@ bunny-cli/ │ │ ├── format.test.ts # Tests for format utilities │ │ ├── hostnames/ # Reusable pull-zone hostname feature (mounted by scripts; apps next) │ │ │ ├── index.ts # Re-exports client helpers, DNS/flow helpers + createHostnamesCommands -│ │ │ ├── client.ts # hostnameUrl(), normalizeHostname(), addHostname(), fetchPullZoneHostnames(), enableSsl() + Hostname/ResolvedPullZone types +│ │ │ ├── client.ts # hostnameUrl(), normalizeHostname(), addHostname(), fetchPullZoneHostnames(), enableSsl(), createPullZone() (storage-zone origin) + Hostname/ResolvedPullZone types │ │ │ ├── client.test.ts # Tests for hostnameUrl() scheme logic │ │ │ ├── dns.ts # dnsPointsAt()/anyResolverPointsAt(): DNS checks (CNAME or flattened A records) via system + public (1.1.1.1/8.8.8.8) resolvers, injectable for tests │ │ │ ├── dns.test.ts # Tests for DNS matching + multi-resolver checks with fake resolvers -│ │ │ ├── flow.ts # offerDnsWaitAndSsl(): poll DNS + opportunistically attempt SSL issuance (~30s) since bunny's resolvers decide validation; printSslHint(). dnsAlreadyLive skips the poll (Bunny DNS record already live). offerBunnyDnsThenSsl() takes an optional onBunnyDnsZone(zone) callback fired when the hostname is on Bunny DNS (lets the command layer link the directory) +│ │ │ ├── flow.ts # offerDnsWaitAndSsl(): poll DNS + opportunistically attempt SSL issuance (~30s) since bunny's resolvers decide validation; printSslHint(). dnsAlreadyLive skips the poll (Bunny DNS record already live). offerBunnyDnsThenSsl() takes an optional onBunnyDnsZone(zone) callback fired when the hostname is on Bunny DNS (lets the command layer link the directory). setupHostname(): resource-agnostic add-hostname -> DNS -> SSL orchestration (caller supplies sslHint/retryHint); used by scripts setupCustomDomain and storage zone add │ │ │ ├── bunny-dns.ts # findBunnyDnsZone()/offerBunnyDnsRecord(): detect a hostname inside an account Bunny DNS zone, then add/repoint a PullZone record (always confirmed) so SSL can issue immediately │ │ │ ├── bunny-dns.test.ts # Tests for longest-suffix zone matching + record-name derivation with a fake core client │ │ │ └── commands.ts # createHostnamesCommands(): add/ssl/list/remove factory parameterized by a pull-zone resolver @@ -304,6 +304,33 @@ bunny-cli/ │ │ │ ├── index.ts # defineNamespace("logging", ...) │ │ │ ├── enable.ts # Enable DNS query logging (optional IP anonymization) │ │ │ └── disable.ts # Disable DNS query logging (with confirmation) +│ │ ├── storage/ # Experimental (hidden from help and landing page) +│ │ │ ├── index.ts # defineNamespace("storage", ...): registers zone + file groups + link + regions + docs (+ hidden bucket aliases) +│ │ │ ├── api.ts # CoreClient type, fetchStorageZones/fetchStorageZone, resolveStorageZone (name-or-ID to zone, re-fetched by ID), toSafeStorageZone (strips Password/ReadOnlyPassword; used by every command that emits a raw zone as JSON: show/list/add) +│ │ │ ├── constants.ts # STORAGE_REGIONS (from SDK enum; /storagezone/regions API endpoint is not reliable) + replicationChoices/normalizeReplicationRegions (replication uses the same regions minus the primary; the SDK file ZoneSchema is the physical footprint, NOT the create input) + STORAGE_MANIFEST/StorageZoneManifest (.bunny/storage.json, written by storage link) +│ │ │ ├── interactive.ts # resolveStorageZoneInteractive: explicit name/ID arg → linked manifest (.bunny/storage.json, fetched by ID even when non-interactive) → zone picker +│ │ │ ├── link.ts # Link the current directory to a storage zone (.bunny/storage.json); bunny storage link [zone] +│ │ │ ├── files-api.ts # Adapter over @bunny.net/storage-sdk: connectStorageZone (zone → SDK connection, Region→StorageRegion enum + password), listFiles/uploadFile/downloadFile/deleteFile (deleteFile translates the SDK's boolean return into a UserError) +│ │ │ ├── files-api.test.ts # Tests for region mapping + delete error translation (NOT the SDK's URL building) +│ │ │ ├── s3.ts # S3 (closed preview): isS3Enabled (StorageZoneType===1), s3Endpoint (-s3.storage.bunnycdn.com), s3Credentials (name=access key, password=secret), renderS3ToolConfig (rclone/aws/s3cmd/env formatters) +│ │ │ ├── s3.test.ts # Tests for S3 derivation + tool-config formatters +│ │ │ ├── docs.ts # Open storage docs (bunny storage docs) +│ │ │ ├── regions.ts # List available storage regions (bunny storage regions) +│ │ │ ├── zone/ # `bunny storage zones` (canonical: zones; aliases: zone; hidden: bucket, buckets) +│ │ │ │ ├── index.ts # defineNamespace("zones", ...) + storageZoneHiddenAliases (bucket/buckets) +│ │ │ │ ├── list.ts # List all storage zones (alias: ls) +│ │ │ │ ├── add.ts # Create a storage zone (prompts for name + region when omitted; offers/--pull-zone creates a pull zone via core/hostnames createPullZone, then offers/--domain a custom domain via setupHostname). Under --output json it stays non-interactive: --domain is attached via addHostname (no DNS/SSL prompts) and reported in the CustomDomain field (with cnameTarget or error) +│ │ │ │ ├── show.ts # Show zone details (region, replication, hostname, usage; adds S3 endpoint rows when S3-enabled) +│ │ │ │ ├── credentials.ts # S3 credentials / tool config for the zone (alias: creds; --format, --read-only, --show-secret); table masks the secret unless --show-secret, JSON/--format always emit it in full +│ │ │ │ ├── update.ts # Update zone settings (custom 404, rewrite 404->200, replication); interactive pre-filled editor when no flags, non-interactive under --output json +│ │ │ │ ├── remove.ts # Delete a storage zone and its files (alias: rm) +│ │ │ │ └── hostnames/index.ts # Mounts core/hostnames createHostnamesCommands as "storage zone domains" (alias hostnames); resolver maps a storage zone (name/ID positional, --pull-zone) to its linked pull zone +│ │ │ └── file/ # `bunny storage files` (canonical: files; aliases: file); zone is the --zone/-z flag (defaults to linked zone), the positional is the file/path +│ │ │ ├── index.ts # defineNamespace("files", ...) +│ │ │ ├── list.ts # List files in a directory (alias: ls; directories first; [path] positional, --zone flag) +│ │ │ ├── upload.ts # Upload a local file ( positional, --zone, --to, --checksum streams a SHA256, --content-type) +│ │ │ ├── download.ts # Download a file to disk ( positional, --zone, --out) +│ │ │ └── remove.ts # Delete a file or directory (alias: rm; positional, --zone, trailing slash = recursive) │ │ ├── registries/ │ │ │ ├── index.ts # Manual CommandModule (not defineNamespace) — default handler runs list │ │ │ ├── list.ts # List container registries @@ -360,6 +387,7 @@ bunny-cli/ - **Config logic lives in `packages/cli/src/config/`** — schema, file resolution, and profile management. - **Error classes are split.** `UserError` and `ApiError` live in `@bunny.net/openapi-client` (the SDK needs them). `ConfigError` lives in the CLI and extends `UserError`. The CLI's `errors.ts` re-exports `UserError` and `ApiError` from `@bunny.net/openapi-client`. - **Import API clients from `@bunny.net/openapi-client`**, not relative paths. Import generated types from `@bunny.net/openapi-client/generated/.d.ts`. +- **Mask secrets in human output; reveal only behind an explicit flag.** Any sensitive value (API keys, passwords, S3 secret keys, auth tokens) must be masked in the default table/text output and shown in full only when the user opts in with a flag (e.g. `--show-secret`). Use `maskSecret()` from `core/format.ts` for the masked form (it keeps the last 4 characters for identification). Machine-readable output is the exception because it exists to be consumed by tools: `--output json` and tool-config `--format` emit full values. Never print a secret the user did not explicitly ask to see. Reference: `storage zones credentials` masks the S3 secret access key by default and reveals it with `--show-secret`, while never leaking it from inspect/list commands (see `toSafeStorageZone`). - **Pull-zone settings are exposed via "Hybrid D" across surfaces.** Scripts and apps are backed by a pull zone, which has a large settings surface (hostnames, caching, edge rules, origin, security, purge, CORS, optimizer, logging, …). To keep each owner's help legible: - **Flatten only first-class groups** directly into the owner — picked by user mental model, kept to one or two. `scripts domains` is the flattened group (a custom domain is "my site's address," not a CDN setting). - **Group the long tail** under a `pullzone` sub-namespace within the owner (e.g. `scripts pullzone `), so the owner's top-level help gains one line, not ten. Curate per owner — don't expose settings that don't apply (a script _is_ its pull zone's origin, so no origin-URL command under `scripts`). @@ -883,6 +911,25 @@ bunny │ ├── enable [domain] [--anonymize-ip] [--anonymization onedigit|drop] │ │ Enable DNS query logging │ └── disable [domain] [--force] Disable DNS query logging +├── storage (experimental, hidden from help and landing page) +│ │ Two resource groups: `zones` (the zone, via core API + account key) and `files` (zone contents, via @bunny.net/storage-sdk + the zone password/region host, resolved automatically). The zone is a name or numeric ID; `zones` commands take it as the `[zone]` positional, `files` commands as the `--zone`/`-z` flag (the positional is the file/path). When the zone is omitted it resolves from a linked zone (`bunny storage link`) then an interactive picker. +│ ├── zones (canonical; aliases: zone; hidden: bucket, buckets) +│ │ ├── list List all storage zones (alias: ls) +│ │ ├── add [name] [--region] [--replication] [--pull-zone] [--pull-zone-name] [--domain] Create a storage zone (prompts for name + region when omitted; offers/--pull-zone creates a pull zone to serve it on the web, then offers/--domain a custom domain via setupHostname; non-interactive under --output json) +│ │ ├── show [zone] Show zone details (region, replication, hostname, usage) +│ │ ├── update [zone] [--custom-404-path] [--rewrite-404-to-200] [--replication] Update zone settings (edits interactively pre-filled when no flags; non-interactive under --output json) +│ │ ├── remove [zone] [--force] Delete a storage zone and its files (alias: rm) +│ │ ├── credentials [zone] [--format rclone|aws|s3cmd|env] [--read-only] [--show-secret] (alias: creds) +│ │ │ S3 credentials for the zone (name = access key, password = secret); --format emits tool config, else table/--output json; table masks the secret unless --show-secret +│ │ └── domains (canonical; alias: hostnames) custom domains on the zone's pull zone; mounts core/hostnames createHostnamesCommands; resolver maps the storage zone to its linked pull zone +│ ├── files (canonical; aliases: file) [--zone|-z] defaults to the linked zone on every file command +│ │ ├── list [path] [--zone] (alias: ls) List files in a directory (trailing slash on path) +│ │ ├── upload [--zone] [--to] [--checksum] [--content-type] Upload a local file +│ │ ├── download [--zone] [--out] Download a file +│ │ └── remove [--zone] [--force] (alias: rm) Delete a file or directory (trailing slash = recursive) +│ ├── link [zone] Link the current directory to a storage zone (.bunny/storage.json); interactive picker when omitted +│ ├── regions List available storage regions (replication uses the same set minus the primary) +│ └── docs Open storage documentation in browser ├── db │ ├── create [--name] [--primary] [--replicas] [--storage-region] │ │ Create a new database diff --git a/bun.lock b/bun.lock index 10474be..c97877a 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,7 @@ }, "packages/app-config": { "name": "@bunny.net/app-config", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "@bunny.net/openapi-client": "workspace:*", "zod": "^4.3.6", @@ -24,7 +24,7 @@ }, "packages/cli": { "name": "@bunny.net/cli", - "version": "0.5.3", + "version": "0.7.0", "bin": { "bunny": "./bin/bunny.cjs", }, @@ -33,6 +33,7 @@ "@bunny.net/database-shell": "workspace:*", "@bunny.net/database-studio": "workspace:*", "@bunny.net/openapi-client": "workspace:*", + "@bunny.net/storage-sdk": "^0.3.1", "@libsql/client": "^0.17.0", "@types/prompts": "^2.4.9", "@types/yargs": "^17.0.35", @@ -46,32 +47,32 @@ "zod": "^4.3.6", }, "optionalDependencies": { - "@bunny.net/cli-darwin-arm64": "0.4.2", - "@bunny.net/cli-darwin-x64": "0.4.2", - "@bunny.net/cli-linux-arm64": "0.4.2", - "@bunny.net/cli-linux-x64": "0.4.2", - "@bunny.net/cli-windows-x64": "0.4.2", + "@bunny.net/cli-darwin-arm64": "0.7.0", + "@bunny.net/cli-darwin-x64": "0.7.0", + "@bunny.net/cli-linux-arm64": "0.7.0", + "@bunny.net/cli-linux-x64": "0.7.0", + "@bunny.net/cli-windows-x64": "0.7.0", }, }, "packages/cli-darwin-arm64": { "name": "@bunny.net/cli-darwin-arm64", - "version": "0.5.3", + "version": "0.7.0", }, "packages/cli-darwin-x64": { "name": "@bunny.net/cli-darwin-x64", - "version": "0.5.3", + "version": "0.7.0", }, "packages/cli-linux-arm64": { "name": "@bunny.net/cli-linux-arm64", - "version": "0.5.3", + "version": "0.7.0", }, "packages/cli-linux-x64": { "name": "@bunny.net/cli-linux-x64", - "version": "0.5.3", + "version": "0.7.0", }, "packages/cli-windows-x64": { "name": "@bunny.net/cli-windows-x64", - "version": "0.5.3", + "version": "0.7.0", }, "packages/database-adapter-libsql": { "name": "@bunny.net/database-adapter-libsql", @@ -172,7 +173,7 @@ }, "packages/openapi-client": { "name": "@bunny.net/openapi-client", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "openapi-fetch": "^0.17.0", }, @@ -277,6 +278,8 @@ "@bunny.net/openapi-client": ["@bunny.net/openapi-client@workspace:packages/openapi-client"], + "@bunny.net/storage-sdk": ["@bunny.net/storage-sdk@0.3.1", "", { "dependencies": { "dotenv": "^16.4.7", "zod": "^3.24.2" } }, "sha512-nHD+e+CxYBcSoZERkxI/7Qir/kZaCfBTT+ULgZNUitNSyZ4mWD3DbTy+sHwVfRyc6wQ0XYD//6LImi2PU9JoWg=="], + "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.0", "", { "dependencies": { "@changesets/config": "^3.1.3", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ=="], "@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.9", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ=="], @@ -1027,6 +1030,10 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@bunny.net/storage-sdk/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "@bunny.net/storage-sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@changesets/apply-release-plan/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], "@changesets/parse/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], diff --git a/packages/cli/README.md b/packages/cli/README.md index bb07432..68ce03b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -474,6 +474,71 @@ Positional value ordering for `record add` follows the record type: `A`/`AAAA`/` | `--anonymize-ip`, `--anonymization` | `zone logging enable` | Anonymize client IPs in logs (`onedigit` \| `drop`) | | `--force` | `record remove`, `zone remove`, `zone dnssec disable`, `zone logging disable` | Skip the confirmation prompt | +### `bunny storage` + +> **Experimental**: hidden from `--help` and the landing page while it stabilizes. + +Manage Edge Storage through two resource groups: **`bunny storage zone`** (the zone itself: create, list, inspect, update, delete) and **`bunny storage file`** (the files within a zone). Zone management uses the account API key; file operations use the zone's own password and a region-specific host, both resolved automatically from the zone. The `[zone]` argument accepts either the zone name or its numeric ID. `zone` aliases to `zones` (and `bucket`/`buckets`); `file` aliases to `files`. + +A storage zone only holds files; a **pull zone** is what serves them on the web. `zone add` offers to create one (origin set to the new storage zone) and then to add a custom domain, or pass `--pull-zone`/`--domain` to do it non-interactively. Custom domains live on the pull zone and are managed with `bunny storage zone domains`. + +```bash +# Zones (lifecycle) +bunny storage zone list +bunny storage zone add # interactive: prompts for name and region +bunny storage zone add my-zone --region DE +bunny storage zone add my-zone --region NY --replication LA,SG +bunny storage zone add my-zone --region DE --pull-zone # also create a pull zone to serve it on the web +bunny storage zone add my-zone --region DE --domain cdn.example.com # pull zone + custom domain +bunny storage zone show my-zone +bunny storage zone update my-zone # interactive: edit settings, pre-filled with current values +bunny storage zone update my-zone --custom-404-path /404.html +bunny storage zone remove my-zone + +# List the available storage regions +bunny storage regions + +# S3-compatible credentials (for zones with S3 preview access) +bunny storage zone credentials my-zone # show endpoint + access key (secret masked) +bunny storage zone credentials my-zone --show-secret # reveal the secret access key +bunny storage zone credentials my-zone --read-only # use the read-only password as the secret +bunny storage zone credentials my-zone --format rclone >> ~/.config/rclone/rclone.conf +eval "$(bunny storage zone credentials my-zone --format env)" # AWS-compatible env vars + +# Files: list, upload, download, delete (paths are relative to the zone root) +bunny storage file list my-zone +bunny storage file list my-zone images/ +bunny storage file upload my-zone ./photo.png --to images/ +bunny storage file upload my-zone ./photo.png --checksum --content-type image/png +bunny storage file download my-zone images/photo.png --out ./local.png +bunny storage file remove my-zone images/photo.png +bunny storage file remove my-zone images/ --force # trailing slash removes a directory + +# Custom domains on the zone's pull zone +bunny storage zone domains list my-zone +bunny storage zone domains add cdn.example.com my-zone +bunny storage zone domains ssl cdn.example.com my-zone +bunny storage zone domains remove cdn.example.com my-zone + +# Open the storage documentation +bunny storage docs +``` + +A trailing slash on a `file` path denotes a directory: `file list my-zone images/` lists that directory, and `file remove my-zone images/` deletes it and its contents recursively. Edge Storage file operations are powered by the [`@bunny.net/storage-sdk`](https://github.com/BunnyWay/edge-script-sdk/tree/main/libs/bunny-storage). + +bunny.net's S3-compatible API is in closed preview and is opt-in per zone (it cannot be enabled on an existing zone). When a zone has access, `bunny storage zone show` surfaces its S3 endpoint, and `bunny storage zone credentials` emits the endpoint, region, access key (the zone name), and secret (the zone password) as a table, as JSON (`--output json`), or as ready-to-use config for `rclone`, the AWS CLI, `s3cmd`, or your shell (`--format`). The table masks the secret by default; pass `--show-secret` to reveal it (`--output json` and `--format` always emit it in full, since they're meant to be consumed by tools). The access key and secret are the zone's existing name and password, so there's nothing new to rotate beyond the zone's own credentials. + +| Flag | Commands | Description | +| ---------------------------------------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `--region`, `--replication` | `zone add` | Primary region code, plus optional replication regions (any storage region except the primary; run `storage regions` to list them) | +| `--pull-zone`, `--pull-zone-name`, `--domain` | `zone add` | Also create a pull zone (what serves the stored files on the web) and optionally a custom domain; interactively, `add` offers both | +| `--custom-404-path`, `--rewrite-404-to-200`, `--replication` | `zone update` | Edit zone settings (see `bunny storage zone update --help`) | +| `--format` (`rclone` \| `aws` \| `s3cmd` \| `env`), `--read-only`, `--show-secret` | `zone credentials` | Emit S3 config for a tool; use the read-only password as the secret; reveal the masked secret in the table | +| `--to` | `file upload` | Remote path; a trailing slash uploads into that directory | +| `--checksum`, `--content-type` | `file upload` | Send a SHA256 checksum for server-side verification; set the stored content type | +| `--out` | `file download` | Local destination path (defaults to the file name) | +| `--force` | `zone remove`, `file remove` | Skip the confirmation prompt | + ### `bunny scripts` Manage Edge Scripts. diff --git a/packages/cli/package.json b/packages/cli/package.json index 0b3c363..9e350b4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -12,6 +12,7 @@ "devDependencies": { "@bunny.net/openapi-client": "workspace:*", "@bunny.net/app-config": "workspace:*", + "@bunny.net/storage-sdk": "^0.3.1", "@bunny.net/database-shell": "workspace:*", "@bunny.net/database-studio": "workspace:*", "@libsql/client": "^0.17.0", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 784bbdf..7c971cf 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -13,6 +13,7 @@ import { docsCommand } from "./commands/docs.ts"; import { openCommand } from "./commands/open.ts"; import { registriesNamespace } from "./commands/registries/index.ts"; import { scriptsNamespace } from "./commands/scripts/index.ts"; +import { storageNamespace } from "./commands/storage/index.ts"; import { whoamiCommand } from "./commands/whoami.ts"; import { bunny } from "./core/colors.ts"; import { logger } from "./core/logger.ts"; @@ -35,6 +36,7 @@ const experimentalCommands: CommandModule[] = [ appsNamespace, registriesNamespace, dnsNamespace, + storageNamespace, ]; let instance = yargs(hideBin(process.argv)) diff --git a/packages/cli/src/commands/scripts/create.ts b/packages/cli/src/commands/scripts/create.ts index be940da..80dbe05 100644 --- a/packages/cli/src/commands/scripts/create.ts +++ b/packages/cli/src/commands/scripts/create.ts @@ -12,9 +12,7 @@ import { formatKeyValue } from "../../core/format.ts"; import { addHostname, normalizeHostname, - offerBunnyDnsThenSsl, - offerDnsWaitAndSsl, - printSslHint, + setupHostname, } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; import { loadManifest, saveManifest } from "../../core/manifest.ts"; @@ -136,64 +134,18 @@ export async function setupCustomDomain(opts: { const config = resolveConfig(opts.profile, opts.apiKey, opts.verbose); const coreClient = createCoreClient(clientOptions(config, opts.verbose)); const idSuffix = opts.linked ? "" : ` --id ${opts.scriptId}`; - const sslHint = `bunny scripts domains ssl ${opts.domain}${idSuffix}`; - const spin = spinner(`Adding ${opts.domain}...`); - spin.start(); - - let cnameTarget: string | undefined; - try { - ({ cnameTarget } = await addHostname( - coreClient, - opts.pullZoneId, - opts.domain, - )); - } catch (err) { - spin.stop(); - const message = err instanceof Error ? err.message : String(err); - logger.warn(`Couldn't add ${opts.domain}: ${message}`); - logger.dim(` Retry: bunny scripts domains add ${opts.domain}${idSuffix}`); - return false; - } - - spin.stop(); - logger.success(`Added ${opts.domain} to pull zone ${opts.pullZoneId}.`); - - if (!cnameTarget) return false; - - // If the domain is on Bunny DNS, offer to add the record (prompted) so SSL can issue right away. - if (opts.interactive) { - const issued = await offerBunnyDnsThenSsl({ - coreClient, - hostname: opts.domain, - pullZoneId: opts.pullZoneId, - cnameTarget, - forceSsl: true, - sslHint, - verbose: opts.verbose, - // Only link the directory when this is a linked script project. - onBunnyDnsZone: opts.linked ? autoLinkDnsZone : undefined, - }); - if (issued !== null) return issued; - } - - logger.log(); - logger.log("Point your DNS at bunny.net to activate it:"); - logger.accent(` CNAME ${opts.domain} → ${cnameTarget}`); - logger.log(); - - if (!opts.interactive) { - printSslHint(sslHint); - return false; - } - - return offerDnsWaitAndSsl({ + return setupHostname({ coreClient, pullZoneId: opts.pullZoneId, - hostname: opts.domain, - cnameTarget, + domain: opts.domain, + sslHint: `bunny scripts domains ssl ${opts.domain}${idSuffix}`, + retryHint: `bunny scripts domains add ${opts.domain}${idSuffix}`, forceSsl: true, - sslHint, + interactive: opts.interactive, + verbose: opts.verbose, + // Only link the directory when this is a linked script project. + onBunnyDnsZone: opts.linked ? autoLinkDnsZone : undefined, }); } diff --git a/packages/cli/src/commands/storage/api.test.ts b/packages/cli/src/commands/storage/api.test.ts new file mode 100644 index 0000000..6069301 --- /dev/null +++ b/packages/cli/src/commands/storage/api.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { type StorageZoneModel, toSafeStorageZone } from "./api.ts"; + +describe("toSafeStorageZone", () => { + const zone = { + Id: 1, + Name: "my-zone", + Region: "DE", + Password: "rw-secret", + ReadOnlyPassword: "ro-secret", + StorageUsed: 1024, + } as StorageZoneModel; + + test("drops both passwords", () => { + const safe = toSafeStorageZone(zone); + expect("Password" in safe).toBe(false); + expect("ReadOnlyPassword" in safe).toBe(false); + expect(JSON.stringify(safe)).not.toContain("secret"); + }); + + test("preserves every non-secret field", () => { + expect(toSafeStorageZone(zone)).toEqual({ + Id: 1, + Name: "my-zone", + Region: "DE", + StorageUsed: 1024, + } as StorageZoneModel); + }); + + test("does not mutate the original zone", () => { + toSafeStorageZone(zone); + expect(zone.Password).toBe("rw-secret"); + }); +}); diff --git a/packages/cli/src/commands/storage/api.ts b/packages/cli/src/commands/storage/api.ts new file mode 100644 index 0000000..594b1bc --- /dev/null +++ b/packages/cli/src/commands/storage/api.ts @@ -0,0 +1,67 @@ +import type { createCoreClient } from "@bunny.net/openapi-client"; +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +import { UserError } from "../../core/errors.ts"; + +export type CoreClient = ReturnType; +export type StorageZoneModel = components["schemas"]["StorageZoneModel"]; +export type StorageZoneSettingsModel = + components["schemas"]["StorageZoneSettingsModel"]; + +export type SafeStorageZone = Omit< + StorageZoneModel, + "Password" | "ReadOnlyPassword" +>; + +// Strip the read-write/read-only passwords so inspect/list/create JSON never +// leaks credentials; use `storage zones credentials` to retrieve those on purpose. +export function toSafeStorageZone(zone: StorageZoneModel): SafeStorageZone { + const { Password: _p, ReadOnlyPassword: _r, ...safe } = zone; + return safe; +} + +export async function fetchStorageZones( + client: CoreClient, +): Promise { + // page=0 (the default) returns every zone as a plain array, no pagination. + const { data } = await client.GET("/storagezone"); + return (data ?? []).sort((a, b) => + (a.Name ?? "").localeCompare(b.Name ?? ""), + ); +} + +export async function fetchStorageZone( + client: CoreClient, + id: number, +): Promise { + const { data } = await client.GET("/storagezone/{id}", { + params: { path: { id } }, + }); + if (!data) throw new UserError(`Storage zone ${id} not found.`); + return data; +} + +// Resolve a numeric ID directly, or match a name and re-fetch by ID so the +// caller always gets the full record (including the zone password). +export async function resolveStorageZone( + client: CoreClient, + nameOrId: string, +): Promise { + const ref = nameOrId.trim(); + if (!ref) throw new UserError("A storage zone name or ID is required."); + + if (/^\d+$/.test(ref)) return fetchStorageZone(client, Number(ref)); + + const { data } = await client.GET("/storagezone", { + params: { query: { search: ref } }, + }); + const match = (data ?? []).find( + (zone) => (zone.Name ?? "").toLowerCase() === ref.toLowerCase(), + ); + if (!match?.Id) { + throw new UserError( + `No storage zone found for "${nameOrId}".`, + 'Run "bunny storage zones list" to see your zones.', + ); + } + return fetchStorageZone(client, match.Id); +} diff --git a/packages/cli/src/commands/storage/constants.test.ts b/packages/cli/src/commands/storage/constants.test.ts new file mode 100644 index 0000000..7bfc2b5 --- /dev/null +++ b/packages/cli/src/commands/storage/constants.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; +import { + normalizeReplicationRegions, + replicationChoices, + STORAGE_REGIONS, +} from "./constants.ts"; + +test("primary regions use uppercase codes and include DE", () => { + expect(STORAGE_REGIONS.length).toBeGreaterThan(0); + expect(STORAGE_REGIONS.map((r) => r.code)).toContain("DE"); + for (const region of STORAGE_REGIONS) { + expect(region.code).toBe(region.code.toUpperCase()); + } +}); + +test("replicationChoices excludes the primary region", () => { + const codes = replicationChoices("DE").map((r) => r.code); + expect(codes).not.toContain("DE"); + expect(codes).toContain("UK"); +}); + +test("normalizeReplicationRegions uppercases, trims, and drops the primary", () => { + expect(normalizeReplicationRegions(["ny", " sg "], "UK")).toEqual([ + "NY", + "SG", + ]); + expect(normalizeReplicationRegions(["UK", "NY"], "UK")).toEqual(["NY"]); +}); + +test("normalizeReplicationRegions splits comma-separated entries", () => { + // yargs array:true passes `--replication LA,SG` as a single element. + expect(normalizeReplicationRegions(["LA,SG"], "NY")).toEqual(["LA", "SG"]); + // Mixed comma + repeated flag, with stray whitespace. + expect(normalizeReplicationRegions(["LA, SG", "UK"], "NY")).toEqual([ + "LA", + "SG", + "UK", + ]); +}); + +test("normalizeReplicationRegions rejects codes that are not storage regions", () => { + // CZ is in the SDK file ZoneSchema but is not a valid create-time region. + expect(() => normalizeReplicationRegions(["NY", "CZ"])).toThrow( + /Unknown replication region/, + ); +}); diff --git a/packages/cli/src/commands/storage/constants.ts b/packages/cli/src/commands/storage/constants.ts new file mode 100644 index 0000000..42fc3f3 --- /dev/null +++ b/packages/cli/src/commands/storage/constants.ts @@ -0,0 +1,54 @@ +import * as BunnyStorage from "@bunny.net/storage-sdk"; +import { UserError } from "../../core/errors.ts"; + +export interface StorageRegion { + code: string; + name: string; +} + +// `.bunny/storage.json` is written by `bunny storage link` and resolved by storage commands. +export const STORAGE_MANIFEST = "storage.json"; + +export interface StorageZoneManifest { + id: number; + name?: string; +} + +// The create API expects uppercase codes (e.g. "DE"), matching what existing zones report. +export const STORAGE_REGIONS: StorageRegion[] = Object.entries( + BunnyStorage.regions.StorageRegion, +).map(([name, code]) => ({ + code: code.toUpperCase(), + name: name.replace(/([a-z])([A-Z])/g, "$1 $2"), +})); + +const REGION_CODES = new Set(STORAGE_REGIONS.map((region) => region.code)); + +// Replication uses the same storage regions as the primary, minus the primary itself. +// (The SDK file ZoneSchema is the physical replication footprint and includes internal +// POPs the create API rejects, so it must not be used as the input set.) +// TODO: Request API endpoint for these +export function replicationChoices(primaryCode?: string): StorageRegion[] { + const primary = primaryCode?.toUpperCase(); + return STORAGE_REGIONS.filter((region) => region.code !== primary); +} + +// Uppercase, validate, and drop the primary region from a list of replication codes. +export function normalizeReplicationRegions( + regions: string[], + primaryCode?: string, +): string[] { + const primary = primaryCode?.toUpperCase(); + const normalized = regions + .flatMap((region) => region.split(",")) + .map((region) => region.trim().toUpperCase()) + .filter(Boolean); + const unknown = normalized.filter((region) => !REGION_CODES.has(region)); + if (unknown.length > 0) { + throw new UserError( + `Unknown replication region(s): ${unknown.join(", ")}.`, + `Valid regions: ${[...REGION_CODES].join(", ")}.`, + ); + } + return normalized.filter((region) => region !== primary); +} diff --git a/packages/cli/src/commands/storage/docs.ts b/packages/cli/src/commands/storage/docs.ts new file mode 100644 index 0000000..6819960 --- /dev/null +++ b/packages/cli/src/commands/storage/docs.ts @@ -0,0 +1,19 @@ +import { defineCommand } from "../../core/define-command.ts"; +import { logger } from "../../core/logger.ts"; +import { openBrowser } from "../../core/ui.ts"; +import { DOCS_BASE_URL } from "../docs.ts"; + +const COMMAND = "docs"; +const DESCRIPTION = "Open storage documentation in the browser."; +const URL = `${DOCS_BASE_URL}/storage`; + +export const storageDocsCommand = defineCommand({ + command: COMMAND, + describe: DESCRIPTION, + examples: [["$0 storage docs", "Open storage documentation"]], + + handler: async () => { + logger.info(`Opening ${URL}`); + openBrowser(URL); + }, +}); diff --git a/packages/cli/src/commands/storage/file/download.ts b/packages/cli/src/commands/storage/file/download.ts new file mode 100644 index 0000000..2ffc1b3 --- /dev/null +++ b/packages/cli/src/commands/storage/file/download.ts @@ -0,0 +1,101 @@ +import { mkdir } from "node:fs/promises"; +import { basename, dirname } from "node:path"; +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 { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; +import { connectStorageZone, downloadFile } from "../files-api.ts"; +import { resolveStorageZoneInteractive } from "../interactive.ts"; + +interface DownloadArgs { + path: string; + zone?: string; + out?: string; +} + +export const storageFileDownloadCommand = defineCommand({ + command: "download ", + describe: "Download a file from a storage zone.", + examples: [ + [ + "$0 storage files download images/photo.png", + "Download from the linked zone to the working directory", + ], + [ + "$0 storage files download images/photo.png --out ./local.png", + "Download to a specific path", + ], + [ + "$0 storage files download images/photo.png --zone my-zone", + "Download from a specific zone", + ], + ], + + builder: (yargs) => + yargs + .positional("path", { + type: "string", + describe: "Path to the file within the zone", + demandOption: true, + }) + .option("zone", { + alias: "z", + type: "string", + describe: "Storage zone name or ID (defaults to the linked zone)", + }) + .option("out", { + type: "string", + describe: "Local destination path (defaults to the file name)", + }), + + handler: async ({ + path, + zone: ref, + out, + profile, + output, + verbose, + apiKey, + }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveStorageZoneInteractive(client, ref, output); + const connection = connectStorageZone(zone); + const dest = out ?? basename(path); + + const spin = spinner(`Downloading ${path}...`); + spin.start(); + try { + // Stream to disk so multi-GB objects don't have to fit in memory. + // FileSink, unlike Bun.write, won't create the parent dir for --out paths. + await mkdir(dirname(dest), { recursive: true }); + const { stream } = await downloadFile(connection, path); + const sink = Bun.file(dest).writer(); + try { + for await (const chunk of stream) { + sink.write(chunk); + } + await sink.end(); + } catch (err) { + // Don't leave a truncated file behind on a failed download. + await Promise.resolve(sink.end()).catch(() => {}); + await Bun.file(dest) + .unlink() + .catch(() => {}); + throw err; + } + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log(JSON.stringify({ zone: zone.Name, path, out: dest }, null, 2)); + return; + } + + logger.success(`Downloaded ${path} to ${dest}.`); + }, +}); diff --git a/packages/cli/src/commands/storage/file/index.ts b/packages/cli/src/commands/storage/file/index.ts new file mode 100644 index 0000000..d85364e --- /dev/null +++ b/packages/cli/src/commands/storage/file/index.ts @@ -0,0 +1,17 @@ +import { defineNamespace } from "../../../core/define-namespace.ts"; +import { storageFileDownloadCommand } from "./download.ts"; +import { storageFileListCommand } from "./list.ts"; +import { storageFileRemoveCommand } from "./remove.ts"; +import { storageFileUploadCommand } from "./upload.ts"; + +export const storageFileNamespace = defineNamespace( + "files", + "Manage files within a storage zone: list, upload, download, delete.", + [ + storageFileListCommand, + storageFileUploadCommand, + storageFileDownloadCommand, + storageFileRemoveCommand, + ], + ["file"], +); diff --git a/packages/cli/src/commands/storage/file/list.ts b/packages/cli/src/commands/storage/file/list.ts new file mode 100644 index 0000000..d6ae3b7 --- /dev/null +++ b/packages/cli/src/commands/storage/file/list.ts @@ -0,0 +1,94 @@ +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 { formatBytes, formatTable } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; +import { + connectStorageZone, + listFiles, + type StorageFile, +} from "../files-api.ts"; +import { resolveStorageZoneInteractive } from "../interactive.ts"; + +interface ListArgs { + path?: string; + zone?: string; +} + +export const storageFileListCommand = defineCommand({ + command: "list [path]", + aliases: ["ls"], + describe: "List files in a storage zone directory.", + examples: [ + ["$0 storage files list", "List the linked zone's root"], + ["$0 storage files list images/", "List files in a directory"], + ["$0 storage files list --zone my-zone", "List another zone's root"], + ], + + builder: (yargs) => + yargs + .positional("path", { + type: "string", + describe: "Directory path within the zone (defaults to the root)", + }) + .option("zone", { + alias: "z", + type: "string", + describe: "Storage zone name or ID (defaults to the linked zone)", + }), + + handler: async ({ path, zone: ref, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveStorageZoneInteractive(client, ref, output); + const connection = connectStorageZone(zone); + + const spin = spinner("Listing files..."); + spin.start(); + let files: StorageFile[]; + try { + files = await listFiles(connection, path ?? ""); + } finally { + spin.stop(); + } + + if (output === "json") { + // Drop the SDK-internal _tag and the lazy data() loader. + const plain = files.map((file) => ({ + ...file, + _tag: undefined, + data: undefined, + })); + logger.log(JSON.stringify(plain, null, 2)); + return; + } + + if (files.length === 0) { + const where = path ? `"${path}"` : "the zone root"; + logger.info( + `No files found at ${where}. The path may be empty or not exist.`, + ); + return; + } + + const sorted = files.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; + return a.objectName.localeCompare(b.objectName); + }); + + logger.log( + formatTable( + ["Name", "Type", "Size"], + sorted.map((file) => [ + file.isDirectory ? `${file.objectName}/` : file.objectName, + file.isDirectory ? "dir" : "file", + file.isDirectory ? "-" : formatBytes(file.length), + ]), + output, + ), + ); + }, +}); diff --git a/packages/cli/src/commands/storage/file/remove.ts b/packages/cli/src/commands/storage/file/remove.ts new file mode 100644 index 0000000..4d1df1e --- /dev/null +++ b/packages/cli/src/commands/storage/file/remove.ts @@ -0,0 +1,96 @@ +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 { logger } from "../../../core/logger.ts"; +import { confirm, spinner } from "../../../core/ui.ts"; +import { connectStorageZone, deleteFile } from "../files-api.ts"; +import { resolveStorageZoneInteractive } from "../interactive.ts"; + +interface RemoveArgs { + path: string; + zone?: string; + force?: boolean; +} + +export const storageFileRemoveCommand = defineCommand({ + command: "remove ", + aliases: ["rm"], + describe: "Delete a file or directory from a storage zone.", + examples: [ + ["$0 storage files remove images/photo.png", "Delete a file"], + [ + "$0 storage files remove images/ --force", + "Delete a directory without confirmation", + ], + [ + "$0 storage files remove images/photo.png --zone my-zone", + "Delete from a specific zone", + ], + ], + + builder: (yargs) => + yargs + .positional("path", { + type: "string", + describe: "Path to the file or directory within the zone", + demandOption: true, + }) + .option("zone", { + alias: "z", + type: "string", + describe: "Storage zone name or ID (defaults to the linked zone)", + }) + .option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip confirmation prompt", + }), + + handler: async ({ + path, + zone: ref, + force, + profile, + output, + verbose, + apiKey, + }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveStorageZoneInteractive(client, ref, output); + const connection = connectStorageZone(zone); + + // A trailing slash deletes a directory and everything under it, recursively. + const isDirectory = path.endsWith("/"); + const confirmed = await confirm( + isDirectory + ? `Delete directory ${path} and all of its contents from ${zone.Name}?` + : `Delete ${path} from ${zone.Name}?`, + { force }, + ); + if (!confirmed) { + logger.log("Cancelled."); + return; + } + + const spin = spinner("Deleting..."); + spin.start(); + try { + await deleteFile(connection, path); + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log( + JSON.stringify({ zone: zone.Name, path, removed: true }, null, 2), + ); + return; + } + + logger.success(`Deleted ${path} from ${zone.Name}.`); + }, +}); diff --git a/packages/cli/src/commands/storage/file/upload.ts b/packages/cli/src/commands/storage/file/upload.ts new file mode 100644 index 0000000..96ad10c --- /dev/null +++ b/packages/cli/src/commands/storage/file/upload.ts @@ -0,0 +1,120 @@ +import { basename } from "node:path"; +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 { spinner } from "../../../core/ui.ts"; +import { connectStorageZone, uploadFile } from "../files-api.ts"; +import { resolveStorageZoneInteractive } from "../interactive.ts"; + +interface UploadArgs { + file: string; + zone?: string; + to?: string; + contentType?: string; + checksum?: boolean; +} + +export const storageFileUploadCommand = defineCommand({ + command: "upload ", + describe: "Upload a local file to a storage zone.", + examples: [ + ["$0 storage files upload ./photo.png", "Upload to the linked zone's root"], + [ + "$0 storage files upload ./photo.png --to images/", + "Upload into a directory", + ], + [ + "$0 storage files upload ./photo.png --zone my-zone", + "Upload to a specific zone", + ], + ], + + builder: (yargs) => + yargs + .positional("file", { + type: "string", + describe: "Path to the local file to upload", + demandOption: true, + }) + .option("zone", { + alias: "z", + type: "string", + describe: "Storage zone name or ID (defaults to the linked zone)", + }) + .option("to", { + type: "string", + describe: + "Remote path; a trailing slash uploads into that directory under the file's name", + }) + .option("content-type", { + type: "string", + describe: "Override the stored content type", + }) + .option("checksum", { + type: "boolean", + default: false, + describe: "Send a SHA256 checksum so the server verifies the upload", + }), + + handler: async ({ + file, + zone: ref, + to, + contentType, + checksum, + profile, + output, + verbose, + apiKey, + }) => { + const source = Bun.file(file); + if (!(await source.exists())) { + throw new UserError(`File not found: ${file}`); + } + + // A bare path uses the file as-is; a trailing slash means "into this directory". + const remotePath = + !to || to.endsWith("/") ? `${to ?? ""}${basename(file)}` : to; + + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveStorageZoneInteractive(client, ref, output); + const connection = connectStorageZone(zone); + + const spin = spinner(`Uploading ${remotePath}...`); + spin.start(); + try { + const sha256Checksum = checksum ? await sha256(source) : undefined; + await uploadFile(connection, remotePath, source.stream(), { + contentType, + sha256Checksum, + }); + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log( + JSON.stringify( + { zone: zone.Name, path: remotePath, uploaded: true }, + null, + 2, + ), + ); + return; + } + + logger.success(`Uploaded ${remotePath} to ${zone.Name}.`); + }, +}); + +// Hash in a streaming pass to avoid buffering the whole file in memory. +async function sha256(source: ReturnType): Promise { + const hasher = new Bun.CryptoHasher("sha256"); + for await (const chunk of source.stream()) hasher.update(chunk); + return hasher.digest("hex").toUpperCase(); +} diff --git a/packages/cli/src/commands/storage/files-api.test.ts b/packages/cli/src/commands/storage/files-api.test.ts new file mode 100644 index 0000000..57a7efe --- /dev/null +++ b/packages/cli/src/commands/storage/files-api.test.ts @@ -0,0 +1,57 @@ +import { afterEach, expect, test } from "bun:test"; +import * as BunnyStorage from "@bunny.net/storage-sdk"; +import type { StorageZoneModel } from "./api.ts"; +import { connectStorageZone, deleteFile } from "./files-api.ts"; + +const ZONE: StorageZoneModel = { + Name: "my-zone", + Password: "zone-password", + Region: "NY", + StorageHostname: "ny.storage.bunnycdn.com", +}; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +test("connectStorageZone maps the region code and zone password", () => { + const connection = connectStorageZone(ZONE); + expect(connection.name).toBe("my-zone"); + expect(connection.accessKey).toBe("zone-password"); + expect(BunnyStorage.zone.addr(connection).toString()).toBe( + "https://ny.storage.bunnycdn.com/my-zone/", + ); +}); + +test("connectStorageZone requires a zone password", () => { + expect(() => connectStorageZone({ ...ZONE, Password: null })).toThrow( + /No password/, + ); +}); + +test("connectStorageZone rejects an unknown region", () => { + expect(() => connectStorageZone({ ...ZONE, Region: "MARS" })).toThrow( + /Unsupported storage region/, + ); +}); + +test("deleteFile throws when the SDK reports failure", async () => { + globalThis.fetch = (async () => + new Response(null, { status: 404 })) as unknown as typeof fetch; + const connection = connectStorageZone(ZONE); + await expect(deleteFile(connection, "missing.png")).rejects.toThrow( + /Failed to delete/, + ); +}); + +test("deleteFile uses the recursive endpoint for directories", async () => { + let method = ""; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + method = init?.method ?? "GET"; + return new Response(null, { status: 200 }); + }) as unknown as typeof fetch; + const connection = connectStorageZone(ZONE); + await deleteFile(connection, "images/"); + expect(method).toBe("DELETE"); +}); diff --git a/packages/cli/src/commands/storage/files-api.ts b/packages/cli/src/commands/storage/files-api.ts new file mode 100644 index 0000000..c112544 --- /dev/null +++ b/packages/cli/src/commands/storage/files-api.ts @@ -0,0 +1,62 @@ +import * as BunnyStorage from "@bunny.net/storage-sdk"; +import { UserError } from "../../core/errors.ts"; +import type { StorageZoneModel } from "./api.ts"; + +export type StorageFile = BunnyStorage.file.StorageFile; +export type StorageZone = BunnyStorage.zone.StorageZone; +export type UploadOptions = BunnyStorage.file.UploadOptions; + +const REGION_CODES = new Set( + Object.values(BunnyStorage.regions.StorageRegion), +); + +export function connectStorageZone(zone: StorageZoneModel): StorageZone { + if (!zone.Name) throw new UserError("Storage zone is missing a name."); + if (!zone.Password) { + throw new UserError( + `No password available for storage zone ${zone.Name}.`, + "Edge Storage file access requires the zone's read-write password.", + ); + } + + const code = (zone.Region ?? "").toLowerCase(); + if (!REGION_CODES.has(code)) { + throw new UserError(`Unsupported storage region "${zone.Region}".`); + } + + return BunnyStorage.zone.connect_with_accesskey( + code as BunnyStorage.regions.StorageRegion, + zone.Name, + zone.Password, + ); +} + +export function listFiles( + zone: StorageZone, + dir: string, +): Promise { + return BunnyStorage.file.list(zone, dir || "/"); +} + +export async function uploadFile( + zone: StorageZone, + remotePath: string, + contents: ReadableStream, + options?: UploadOptions, +): Promise { + await BunnyStorage.file.upload(zone, remotePath, contents, options); +} + +export function downloadFile(zone: StorageZone, remotePath: string) { + return BunnyStorage.file.download(zone, remotePath); +} + +export async function deleteFile( + zone: StorageZone, + path: string, +): Promise { + const deleted = path.endsWith("/") + ? await BunnyStorage.file.removeDirectory(zone, path) + : await BunnyStorage.file.remove(zone, path); + if (!deleted) throw new UserError(`Failed to delete ${path}.`); +} diff --git a/packages/cli/src/commands/storage/index.ts b/packages/cli/src/commands/storage/index.ts new file mode 100644 index 0000000..0a204cc --- /dev/null +++ b/packages/cli/src/commands/storage/index.ts @@ -0,0 +1,18 @@ +import { defineNamespace } from "../../core/define-namespace.ts"; +import { storageDocsCommand } from "./docs.ts"; +import { storageFileNamespace } from "./file/index.ts"; +import { storageLinkCommand } from "./link.ts"; +import { storageRegionsCommand } from "./regions.ts"; +import { + storageZoneHiddenAliases, + storageZoneNamespace, +} from "./zone/index.ts"; + +export const storageNamespace = defineNamespace("storage", false, [ + storageZoneNamespace, + storageFileNamespace, + storageLinkCommand, + storageRegionsCommand, + storageDocsCommand, + ...storageZoneHiddenAliases, +]); diff --git a/packages/cli/src/commands/storage/interactive.ts b/packages/cli/src/commands/storage/interactive.ts new file mode 100644 index 0000000..1cf9d56 --- /dev/null +++ b/packages/cli/src/commands/storage/interactive.ts @@ -0,0 +1,81 @@ +import prompts from "prompts"; +import { UserError } from "../../core/errors.ts"; +import { loadManifest } from "../../core/manifest.ts"; +import type { OutputFormat } from "../../core/types.ts"; +import { spinner } from "../../core/ui.ts"; +import { + type CoreClient, + fetchStorageZone, + fetchStorageZones, + resolveStorageZone, + type StorageZoneModel, +} from "./api.ts"; +import { STORAGE_MANIFEST, type StorageZoneManifest } from "./constants.ts"; + +export async function resolveStorageZoneInteractive( + client: CoreClient, + ref: string | undefined, + output?: OutputFormat, +): Promise { + if (ref) { + const spin = spinner("Resolving storage zone..."); + spin.start(); + try { + return await resolveStorageZone(client, ref); + } finally { + spin.stop(); + } + } + + // A zone linked via `bunny storage link` stands in for an explicit ref, even unattended. + const manifest = loadManifest(STORAGE_MANIFEST); + if (manifest.id) { + const spin = spinner("Loading linked storage zone..."); + spin.start(); + try { + return await fetchStorageZone(client, manifest.id); + } finally { + spin.stop(); + } + } + + // No zone given: only fall back to the picker when we can actually prompt. + if (output === "json" || !process.stdout.isTTY) { + throw new UserError( + "A storage zone is required.", + "Pass the zone name or ID.", + ); + } + + const spin = spinner("Fetching storage zones..."); + spin.start(); + let zones: StorageZoneModel[]; + try { + zones = await fetchStorageZones(client); + } finally { + spin.stop(); + } + + if (zones.length === 0) { + throw new UserError( + "No storage zones found.", + 'Create one with "bunny storage zones add ".', + ); + } + + const { id } = await prompts({ + type: "select", + name: "id", + message: "Storage zone:", + choices: zones.map((zone) => ({ title: zone.Name ?? "", value: zone.Id })), + }); + if (id === undefined) throw new UserError("A storage zone is required."); + + const loadSpin = spinner("Loading storage zone..."); + loadSpin.start(); + try { + return await fetchStorageZone(client, id); + } finally { + loadSpin.stop(); + } +} diff --git a/packages/cli/src/commands/storage/link.ts b/packages/cli/src/commands/storage/link.ts new file mode 100644 index 0000000..d6646fd --- /dev/null +++ b/packages/cli/src/commands/storage/link.ts @@ -0,0 +1,107 @@ +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 type { OutputFormat } from "../../core/types.ts"; +import { spinner } from "../../core/ui.ts"; +import { + fetchStorageZones, + resolveStorageZone, + type StorageZoneModel, +} from "./api.ts"; +import { STORAGE_MANIFEST, type StorageZoneManifest } from "./constants.ts"; + +interface LinkArgs { + zone?: string; +} + +export const storageLinkCommand = defineCommand({ + command: "link [zone]", + describe: "Link the current directory to a storage zone.", + examples: [ + ["$0 storage link", "Interactive selection"], + ["$0 storage link my-zone", "Direct link by name or ID"], + ], + + builder: (yargs) => + yargs.positional("zone", { + type: "string", + describe: "Storage zone name or ID", + }), + + handler: async ({ zone: ref, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + if (ref) { + const spin = spinner("Resolving storage zone..."); + spin.start(); + let zone: StorageZoneModel; + try { + zone = await resolveStorageZone(client, ref); + } finally { + spin.stop(); + } + linkZone(zone, output); + return; + } + + // Without a TTY (or in JSON mode) there is no one to answer the picker. + if (output === "json" || !process.stdout.isTTY) { + throw new UserError( + "A storage zone is required.", + "Pass the zone name or ID.", + ); + } + + const spin = spinner("Fetching storage zones..."); + spin.start(); + let zones: StorageZoneModel[]; + try { + zones = await fetchStorageZones(client); + } finally { + spin.stop(); + } + + if (zones.length === 0) { + throw new UserError( + "No storage zones found.", + 'Create one with "bunny storage zones add ".', + ); + } + + const { selected } = await prompts({ + type: "select", + name: "selected", + message: "Select a storage zone to link:", + choices: zones.map((zone) => ({ + title: `${zone.Name ?? ""} (${zone.Id})`, + value: zone, + })), + }); + + if (!selected) { + throw new UserError("Link cancelled."); + } + + linkZone(selected, output); + }, +}); + +function linkZone(zone: StorageZoneModel, output: OutputFormat): void { + saveManifest(STORAGE_MANIFEST, { + id: zone.Id ?? 0, + name: zone.Name ?? undefined, + }); + + if (output === "json") { + logger.log(JSON.stringify({ id: zone.Id, name: zone.Name })); + return; + } + + logger.success(`Linked to ${zone.Name} (${zone.Id}).`); +} diff --git a/packages/cli/src/commands/storage/regions.ts b/packages/cli/src/commands/storage/regions.ts new file mode 100644 index 0000000..963c307 --- /dev/null +++ b/packages/cli/src/commands/storage/regions.ts @@ -0,0 +1,26 @@ +import { defineCommand } from "../../core/define-command.ts"; +import { formatTable } from "../../core/format.ts"; +import { logger } from "../../core/logger.ts"; +import { STORAGE_REGIONS } from "./constants.ts"; + +export const storageRegionsCommand = defineCommand({ + command: "regions", + describe: "List available storage regions.", + examples: [["$0 storage regions", "List storage regions"]], + + handler: async ({ output }) => { + if (output === "json") { + logger.log(JSON.stringify(STORAGE_REGIONS, null, 2)); + return; + } + + logger.log( + formatTable( + ["Code", "Name"], + STORAGE_REGIONS.map((region) => [region.code, region.name]), + output, + ), + ); + logger.dim("Replication uses these same regions, minus the primary."); + }, +}); diff --git a/packages/cli/src/commands/storage/s3.test.ts b/packages/cli/src/commands/storage/s3.test.ts new file mode 100644 index 0000000..059b9f8 --- /dev/null +++ b/packages/cli/src/commands/storage/s3.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from "bun:test"; +import type { StorageZoneModel } from "./api.ts"; +import { + isS3Enabled, + renderS3ToolConfig, + s3Credentials, + s3Endpoint, +} from "./s3.ts"; + +const ZONE: StorageZoneModel = { + Name: "my-zone", + Password: "rw-pass", + ReadOnlyPassword: "ro-pass", + Region: "DE", + StorageZoneType: 1, +}; + +test("isS3Enabled reflects the StorageZoneType flag", () => { + expect(isS3Enabled(ZONE)).toBe(true); + expect(isS3Enabled({ ...ZONE, StorageZoneType: 0 })).toBe(false); +}); + +test("s3Endpoint derives a region-prefixed host", () => { + expect(s3Endpoint(ZONE)).toBe("https://de-s3.storage.bunnycdn.com"); +}); + +test("s3Credentials maps zone name + password and honours --read-only", () => { + expect(s3Credentials(ZONE, false)).toEqual({ + endpoint: "https://de-s3.storage.bunnycdn.com", + region: "de", + accessKeyId: "my-zone", + secretAccessKey: "rw-pass", + }); + expect(s3Credentials(ZONE, true).secretAccessKey).toBe("ro-pass"); +}); + +test("s3Credentials throws when the chosen password is missing", () => { + expect(() => + s3Credentials({ ...ZONE, ReadOnlyPassword: null }, true), + ).toThrow(/read-only password/); +}); + +test("rclone config is a usable remote block", () => { + const config = renderS3ToolConfig( + "rclone", + s3Credentials(ZONE, false), + "my-zone", + ); + expect(config).toContain("[my-zone]"); + expect(config).toContain("type = s3"); + expect(config).toContain("endpoint = https://de-s3.storage.bunnycdn.com"); + expect(config).toContain("secret_access_key = rw-pass"); +}); + +test("env format emits shell-quoted AWS-compatible variables", () => { + const env = renderS3ToolConfig("env", s3Credentials(ZONE, false), "my-zone"); + expect(env).toContain("AWS_ACCESS_KEY_ID='my-zone'"); + expect(env).toContain( + "AWS_ENDPOINT_URL='https://de-s3.storage.bunnycdn.com'", + ); +}); + +test("env format quotes secrets containing shell metacharacters", () => { + const zone: StorageZoneModel = { + ...ZONE, + Password: "a b$(rm -rf /);'\"\n#", + }; + const env = renderS3ToolConfig("env", s3Credentials(zone, false), "my-zone"); + // Each embedded single quote is closed, escaped, and reopened: '\'' + expect(env).toContain("AWS_SECRET_ACCESS_KEY='a b$(rm -rf /);'\\''\"\n#'"); + // The dangerous substitution must not appear unquoted. + expect(env).not.toMatch(/AWS_SECRET_ACCESS_KEY=a b/); +}); diff --git a/packages/cli/src/commands/storage/s3.ts b/packages/cli/src/commands/storage/s3.ts new file mode 100644 index 0000000..29fb20f --- /dev/null +++ b/packages/cli/src/commands/storage/s3.ts @@ -0,0 +1,99 @@ +import { UserError } from "../../core/errors.ts"; +import type { StorageZoneModel } from "./api.ts"; + +export interface S3Credentials { + endpoint: string; + region: string; + accessKeyId: string; + secretAccessKey: string; +} + +// Config formats for external S3 tools (default human/json output is handled by --output). +export const S3_TOOL_FORMATS = ["rclone", "aws", "s3cmd", "env"] as const; +export type S3ToolFormat = (typeof S3_TOOL_FORMATS)[number]; + +export function isS3Enabled(zone: StorageZoneModel): boolean { + return zone.StorageZoneType === 1; +} + +// Edge Storage S3 endpoint, e.g. https://de-s3.storage.bunnycdn.com +export function s3Endpoint(zone: StorageZoneModel): string { + return `https://${(zone.Region ?? "").toLowerCase()}-s3.storage.bunnycdn.com`; +} + +export function s3Credentials( + zone: StorageZoneModel, + readOnly: boolean, +): S3Credentials { + const secret = readOnly ? zone.ReadOnlyPassword : zone.Password; + if (!zone.Name || !secret) { + throw new UserError( + `No ${readOnly ? "read-only " : ""}password available for storage zone ${zone.Name ?? "?"}.`, + ); + } + return { + endpoint: s3Endpoint(zone), + region: (zone.Region ?? "").toLowerCase(), + accessKeyId: zone.Name, + secretAccessKey: secret, + }; +} + +// Single-quote a value for safe shell `eval`: wrap in '...' and escape any embedded quote. +function shSingleQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +const TOOL_FORMATTERS: Record< + S3ToolFormat, + (creds: S3Credentials, zoneName: string) => string +> = { + // env output is meant for `eval`, so every value must be shell-quoted. + env: (c) => + [ + `AWS_ACCESS_KEY_ID=${shSingleQuote(c.accessKeyId)}`, + `AWS_SECRET_ACCESS_KEY=${shSingleQuote(c.secretAccessKey)}`, + `AWS_ENDPOINT_URL=${shSingleQuote(c.endpoint)}`, + `AWS_REGION=${shSingleQuote(c.region)}`, + ].join("\n"), + + rclone: (c, zoneName) => + [ + `[${zoneName}]`, + "type = s3", + "provider = Other", + `access_key_id = ${c.accessKeyId}`, + `secret_access_key = ${c.secretAccessKey}`, + `endpoint = ${c.endpoint}`, + `region = ${c.region}`, + ].join("\n"), + + aws: (c, zoneName) => + [ + `[${zoneName}]`, + `aws_access_key_id = ${c.accessKeyId}`, + `aws_secret_access_key = ${c.secretAccessKey}`, + `region = ${c.region}`, + `endpoint_url = ${c.endpoint}`, + ].join("\n"), + + s3cmd: (c) => { + const host = c.endpoint.replace(/^https?:\/\//, ""); + return [ + "[default]", + `access_key = ${c.accessKeyId}`, + `secret_key = ${c.secretAccessKey}`, + `host_base = ${host}`, + `host_bucket = ${host}`, + "use_https = True", + ].join("\n"); + }, +}; + +export function renderS3ToolConfig( + format: S3ToolFormat, + creds: S3Credentials, + zoneName: string, +): string { + return TOOL_FORMATTERS[format](creds, zoneName); +} diff --git a/packages/cli/src/commands/storage/zone/add.ts b/packages/cli/src/commands/storage/zone/add.ts new file mode 100644 index 0000000..e60cdd5 --- /dev/null +++ b/packages/cli/src/commands/storage/zone/add.ts @@ -0,0 +1,290 @@ +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 { + addHostname, + createPullZone, + normalizeHostname, + setupHostname, +} from "../../../core/hostnames/index.ts"; +import { logger } from "../../../core/logger.ts"; +import { confirm, spinner } from "../../../core/ui.ts"; +import { type StorageZoneModel, toSafeStorageZone } from "../api.ts"; +import { + normalizeReplicationRegions, + replicationChoices, + STORAGE_REGIONS, +} from "../constants.ts"; + +interface ZoneAddArgs { + name?: string; + region?: string; + replication?: string[]; + pullZone?: boolean; + pullZoneName?: string; + domain?: string; +} + +export const storageZoneAddCommand = defineCommand({ + command: "add [name]", + describe: "Create a new storage zone.", + examples: [ + ["$0 storage zones add", "Interactive: prompts for name and region"], + [ + "$0 storage zones add my-zone --region DE", + "Create a zone in Falkenstein", + ], + [ + "$0 storage zones add my-zone --region NY --replication LA,SG", + "Create a zone with replication regions", + ], + ], + + builder: (yargs) => + yargs + .positional("name", { + type: "string", + describe: "Name for the new storage zone", + }) + .option("region", { + type: "string", + describe: "Main storage region code (e.g. DE, NY, LA, SG)", + }) + .option("replication", { + type: "string", + array: true, + describe: "Replication region codes (comma-separated or repeated)", + }) + .option("pull-zone", { + type: "boolean", + describe: "Create a pull zone to serve the storage zone over the web", + }) + .option("pull-zone-name", { + type: "string", + describe: "Name for the pull zone (defaults to the storage zone name)", + }) + .option("domain", { + type: "string", + describe: "Custom domain to add to the pull zone (implies --pull-zone)", + }), + + handler: async ({ + name, + region, + replication, + pullZone, + pullZoneName, + domain, + profile, + output, + verbose, + apiKey, + }) => { + // A custom domain needs a pull zone to attach to, so the flags can't conflict. + if (domain !== undefined && pullZone === false) { + throw new UserError( + "--domain requires a pull zone, but --no-pull-zone was given.", + "Drop --no-pull-zone, or remove --domain to create the zone without one.", + ); + } + + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + // JSON output and non-TTY runs stay non-interactive; values must come from flags. + const interactive = output !== "json" && process.stdout.isTTY === true; + + // The region and replication choices both drive storage pricing, so flag it up front. + if (interactive && (region === undefined || replication === undefined)) { + logger.dim("Region and replication regions both affect storage pricing."); + } + + let zoneName = name; + if (!zoneName && interactive) { + const { value } = await prompts({ + type: "text", + name: "value", + message: "Storage zone name:", + }); + zoneName = value; + } + if (!zoneName) throw new UserError("A storage zone name is required."); + + // The main region cannot be changed after creation, so prompt for it too. + let mainRegion = region; + if (!mainRegion && interactive) { + const { picked } = await prompts({ + type: "select", + name: "picked", + message: "Main region:", + choices: STORAGE_REGIONS.map((r) => ({ + title: `${r.name} (${r.code})`, + value: r.code, + })), + }); + mainRegion = picked; + } + if (!mainRegion) { + throw new UserError( + "A region is required.", + "Pass --region with a region code (e.g. DE, NY, LA, SG).", + ); + } + mainRegion = mainRegion.toUpperCase(); + + let replicationRegions = replication; + if (replicationRegions === undefined && interactive) { + const { picked } = await prompts({ + type: "multiselect", + name: "picked", + message: + "Replication regions (each adds storage cost; space to toggle):", + choices: replicationChoices(mainRegion).map((region) => ({ + title: `${region.name} (${region.code})`, + value: region.code, + })), + }); + // Cancelling (Ctrl+C) yields undefined; an empty array is a deliberate "no replication". + if (picked === undefined) throw new UserError("Creation cancelled."); + replicationRegions = picked; + } + const replicationCodes = replicationRegions + ? normalizeReplicationRegions(replicationRegions, mainRegion) + : []; + + const spin = spinner("Creating storage zone..."); + spin.start(); + let created: StorageZoneModel | undefined; + try { + const { data } = await client.POST("/storagezone", { + body: { + Name: zoneName, + Region: mainRegion, + ReplicationRegions: replicationCodes.length ? replicationCodes : null, + }, + }); + created = data; + } finally { + spin.stop(); + } + + const zoneId = created?.Id; + + // A storage zone only holds files; a pull zone serves them on the web. + // A custom domain attaches to that pull zone, so --domain implies one too. + let shouldCreatePullZone = pullZone ?? (domain !== undefined || undefined); + if (shouldCreatePullZone === undefined && interactive && zoneId) { + shouldCreatePullZone = await confirm( + `Make ${zoneName} available on the web? This creates a pull zone (bunny's CDN layer) in front of it.`, + ); + } + + let pullZoneResult: + | { id?: number; name?: string | null; url?: string } + | undefined; + if (shouldCreatePullZone && zoneId) { + const pzSpin = spinner("Creating pull zone..."); + pzSpin.start(); + let pz: Awaited> | undefined; + try { + pz = await createPullZone(client, pullZoneName ?? zoneName, zoneId); + } finally { + pzSpin.stop(); + } + const host = (pz?.Hostnames ?? []).find((h) => h.IsSystemHostname)?.Value; + pullZoneResult = { + id: pz?.Id, + name: pz?.Name, + url: host ? `https://${host}` : undefined, + }; + } + + if (output === "json") { + // Non-interactive: attach the requested domain to the pull zone (no DNS/SSL + // prompts; SSL is issued later via `domains ssl` once DNS points at bunny). + let customDomainResult: + | { domain: string; cnameTarget?: string; error?: string } + | undefined; + if (domain) { + const host = normalizeHostname(domain); + if (pullZoneResult?.id) { + try { + const { cnameTarget } = await addHostname( + client, + pullZoneResult.id, + host, + ); + customDomainResult = { domain: host, cnameTarget }; + } catch (err) { + customDomainResult = { + domain: host, + error: err instanceof Error ? err.message : String(err), + }; + } + } else { + customDomainResult = { + domain: host, + error: "Pull zone was not created; cannot attach the domain.", + }; + } + } + + logger.log( + JSON.stringify( + { + ...(created ? toSafeStorageZone(created) : { Name: zoneName }), + PullZone: pullZoneResult ?? null, + CustomDomain: customDomainResult ?? null, + }, + null, + 2, + ), + ); + return; + } + + logger.success( + zoneId + ? `Created storage zone ${zoneName} (ID: ${zoneId}).` + : `Created storage zone ${zoneName}.`, + ); + if (pullZoneResult) { + logger.success(`Created pull zone ${pullZoneResult.name}.`); + if (pullZoneResult.url) { + logger.info(`Files are now served at ${pullZoneResult.url}`); + } + } + + if (pullZoneResult?.id) { + let customDomain = domain; + if ( + customDomain === undefined && + interactive && + (await confirm("Add a custom domain?")) + ) { + const { value } = await prompts({ + type: "text", + name: "value", + message: "Domain (e.g. cdn.example.com):", + }); + customDomain = value; + } + if (customDomain) { + const host = normalizeHostname(customDomain); + await setupHostname({ + coreClient: client, + pullZoneId: pullZoneResult.id, + domain: host, + sslHint: `bunny storage zone domains ssl ${host} ${zoneName}`, + retryHint: `bunny storage zone domains add ${host} ${zoneName}`, + forceSsl: true, + interactive, + verbose, + }); + } + } + }, +}); diff --git a/packages/cli/src/commands/storage/zone/credentials.ts b/packages/cli/src/commands/storage/zone/credentials.ts new file mode 100644 index 0000000..106ab98 --- /dev/null +++ b/packages/cli/src/commands/storage/zone/credentials.ts @@ -0,0 +1,123 @@ +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 { formatKeyValue, maskSecret } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { resolveStorageZoneInteractive } from "../interactive.ts"; +import { + isS3Enabled, + renderS3ToolConfig, + S3_TOOL_FORMATS, + type S3ToolFormat, + s3Credentials, +} from "../s3.ts"; + +interface CredentialsArgs { + zone?: string; + format?: S3ToolFormat; + readOnly?: boolean; + showSecret?: boolean; +} + +export const storageZoneCredentialsCommand = defineCommand({ + command: "credentials [zone]", + aliases: ["creds"], + describe: "Show S3 credentials for a storage zone, or config for an S3 tool.", + examples: [ + [ + "$0 storage zones credentials my-zone", + "Show endpoint and keys (secret masked)", + ], + [ + "$0 storage zones credentials my-zone --show-secret", + "Reveal the secret access key", + ], + [ + "$0 storage zones credentials my-zone --format rclone >> ~/.config/rclone/rclone.conf", + "Append an rclone remote", + ], + [ + 'eval "$(bunny storage zones credentials my-zone --format env)"', + "Export AWS-compatible env vars", + ], + ], + + builder: (yargs) => + yargs + .positional("zone", { + type: "string", + describe: "Storage zone name or ID", + }) + .option("format", { + type: "string", + choices: S3_TOOL_FORMATS, + describe: "Emit config for an S3 tool instead of the default table", + }) + .option("read-only", { + type: "boolean", + default: false, + describe: "Use the zone's read-only password as the secret", + }) + .option("show-secret", { + type: "boolean", + default: false, + describe: + "Reveal the secret access key in the table (masked by default)", + }), + + handler: async ({ + zone: ref, + format, + readOnly, + showSecret, + profile, + output, + verbose, + apiKey, + }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveStorageZoneInteractive(client, ref, output); + const creds = s3Credentials(zone, readOnly ?? false); + + if (!isS3Enabled(zone)) { + logger.warn( + `S3 is not enabled on ${zone.Name}. These credentials only work once it has S3 preview access.`, + ); + } + + if (format) { + logger.log(renderS3ToolConfig(format, creds, zone.Name as string)); + return; + } + + if (output === "json") { + logger.log(JSON.stringify(creds, null, 2)); + return; + } + + logger.log( + formatKeyValue( + [ + { key: "Endpoint", value: creds.endpoint }, + { key: "Region", value: creds.region }, + { key: "Access Key ID", value: creds.accessKeyId }, + { + key: "Secret Access Key", + value: showSecret + ? creds.secretAccessKey + : maskSecret(creds.secretAccessKey), + }, + ], + output, + ), + ); + if (showSecret) { + logger.warn("Treat the secret access key like a password."); + } else { + logger.dim("Secret masked. Pass --show-secret to reveal it."); + } + }, +}); diff --git a/packages/cli/src/commands/storage/zone/hostnames/index.ts b/packages/cli/src/commands/storage/zone/hostnames/index.ts new file mode 100644 index 0000000..8628795 --- /dev/null +++ b/packages/cli/src/commands/storage/zone/hostnames/index.ts @@ -0,0 +1,90 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../../config/index.ts"; +import { clientOptions } from "../../../../core/client-options.ts"; +import { UserError } from "../../../../core/errors.ts"; +import { + createHostnamesCommands, + type ResolvedPullZone, +} from "../../../../core/hostnames/index.ts"; +import { resolveStorageZone, type StorageZoneModel } from "../../api.ts"; + +// Pick the storage zone's linked pull zone; require --pull-zone when several exist. +function resolvePullZoneId(zone: StorageZoneModel, flag?: number): number { + const zones = zone.PullZones ?? []; + + if (flag != null) { + const match = zones.find((z) => z.Id === flag); + if (!match) { + throw new UserError( + `Pull zone ${flag} is not linked to storage zone ${zone.Name}.`, + ); + } + return flag; + } + + if (zones.length === 0) { + throw new UserError( + `Storage zone ${zone.Name} has no pull zone.`, + 'Create one with "bunny storage zone add --pull-zone".', + ); + } + + if (zones.length > 1) { + const list = zones.map((z) => `${z.Id} (${z.Name})`).join(", "); + throw new UserError( + "Storage zone has multiple pull zones.", + `Pass --pull-zone to choose one: ${list}`, + ); + } + + const id = zones[0]?.Id; + if (id == null) throw new UserError("Linked pull zone has no ID."); + return id; +} + +async function resolveStorageZonePullZone(args: { + profile: string; + apiKey?: string; + verbose: boolean; + zone?: string; + "pull-zone"?: number; +}): Promise { + const config = resolveConfig(args.profile, args.apiKey, args.verbose); + const coreClient = createCoreClient(clientOptions(config, args.verbose)); + + if (!args.zone) { + throw new UserError( + "A storage zone is required.", + "Pass the zone name or ID.", + ); + } + const zone = await resolveStorageZone(coreClient, args.zone); + const pullZoneId = resolvePullZoneId(zone, args["pull-zone"]); + + return { pullZoneId, coreClient }; +} + +export const storageZoneHostnamesCommands = createHostnamesCommands({ + commandPath: "storage zone domains", + namespace: "domains", + describe: "Manage custom domains for a storage zone's pull zone.", + hiddenAliases: ["hostnames"], + targetPositional: { + name: "zone", + describe: "Storage zone name or ID", + type: "string", + }, + target: (yargs) => + yargs.option("pull-zone", { + type: "number", + describe: "Pull zone ID (required if the storage zone has multiple)", + }), + resolve: (args) => + resolveStorageZonePullZone({ + profile: args.profile, + apiKey: args.apiKey, + verbose: args.verbose, + zone: args.zone as string | undefined, + "pull-zone": args["pull-zone"] as number | undefined, + }), +}); diff --git a/packages/cli/src/commands/storage/zone/index.ts b/packages/cli/src/commands/storage/zone/index.ts new file mode 100644 index 0000000..e3dc848 --- /dev/null +++ b/packages/cli/src/commands/storage/zone/index.ts @@ -0,0 +1,32 @@ +import type { CommandModule } from "yargs"; +import { defineNamespace } from "../../../core/define-namespace.ts"; +import { storageZoneAddCommand } from "./add.ts"; +import { storageZoneCredentialsCommand } from "./credentials.ts"; +import { storageZoneHostnamesCommands } from "./hostnames/index.ts"; +import { storageZoneListCommand } from "./list.ts"; +import { storageZoneRemoveCommand } from "./remove.ts"; +import { storageZoneShowCommand } from "./show.ts"; +import { storageZoneUpdateCommand } from "./update.ts"; + +const subcommands: CommandModule[] = [ + storageZoneListCommand, + storageZoneAddCommand, + storageZoneShowCommand, + storageZoneUpdateCommand, + storageZoneRemoveCommand, + storageZoneCredentialsCommand, + ...storageZoneHostnamesCommands, +]; + +export const storageZoneNamespace = defineNamespace( + "zones", + "Manage storage zones: create, list, inspect, delete.", + subcommands, + ["zone"], +); + +// Hidden aliases so `bunny storage bucket …` works for S3/R2 muscle memory. +export const storageZoneHiddenAliases: CommandModule[] = [ + "bucket", + "buckets", +].map((name) => defineNamespace(name, false, subcommands)); diff --git a/packages/cli/src/commands/storage/zone/list.ts b/packages/cli/src/commands/storage/zone/list.ts new file mode 100644 index 0000000..1f61885 --- /dev/null +++ b/packages/cli/src/commands/storage/zone/list.ts @@ -0,0 +1,60 @@ +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 { formatBytes, formatTable } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; +import { + fetchStorageZones, + type StorageZoneModel, + toSafeStorageZone, +} from "../api.ts"; + +export const storageZoneListCommand = defineCommand({ + command: "list", + aliases: ["ls"], + describe: "List all storage zones.", + examples: [ + ["$0 storage zones list", "List all storage zones"], + ["$0 storage zones 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 storage zones..."); + spin.start(); + let zones: StorageZoneModel[]; + try { + zones = await fetchStorageZones(client); + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log(JSON.stringify(zones.map(toSafeStorageZone), null, 2)); + return; + } + + if (zones.length === 0) { + logger.info("No storage zones found."); + return; + } + + logger.log( + formatTable( + ["ID", "Name", "Region", "Files", "Used"], + zones.map((z) => [ + String(z.Id ?? ""), + z.Name ?? "", + z.Region ?? "", + String(z.FilesStored ?? 0), + formatBytes(z.StorageUsed ?? 0), + ]), + output, + ), + ); + }, +}); diff --git a/packages/cli/src/commands/storage/zone/remove.ts b/packages/cli/src/commands/storage/zone/remove.ts new file mode 100644 index 0000000..aacbbc7 --- /dev/null +++ b/packages/cli/src/commands/storage/zone/remove.ts @@ -0,0 +1,75 @@ +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 { logger } from "../../../core/logger.ts"; +import { confirm, spinner } from "../../../core/ui.ts"; +import { resolveStorageZoneInteractive } from "../interactive.ts"; + +interface ZoneRemoveArgs { + zone?: string; + force?: boolean; +} + +export const storageZoneRemoveCommand = defineCommand({ + command: "remove [zone]", + aliases: ["rm"], + describe: "Delete a storage zone and all of its files.", + examples: [ + ["$0 storage zones remove my-zone", "Delete a zone"], + ["$0 storage zones remove my-zone --force", "Skip confirmation"], + ["$0 storage zones remove", "Pick a zone interactively"], + ], + + builder: (yargs) => + yargs + .positional("zone", { + type: "string", + describe: "Storage zone name or ID", + }) + .option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip confirmation prompt", + }), + + handler: async ({ zone: ref, force, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveStorageZoneInteractive(client, ref, output); + + const confirmed = await confirm( + `Delete storage zone ${zone.Name} and all ${zone.FilesStored ?? 0} file(s)? This cannot be undone.`, + { force }, + ); + if (!confirmed) { + logger.log("Cancelled."); + return; + } + + const removeSpin = spinner("Deleting storage zone..."); + removeSpin.start(); + try { + await client.DELETE("/storagezone/{id}", { + params: { path: { id: zone.Id as number } }, + }); + } finally { + removeSpin.stop(); + } + + if (output === "json") { + logger.log( + JSON.stringify( + { id: zone.Id, name: zone.Name, removed: true }, + null, + 2, + ), + ); + return; + } + + logger.success(`Deleted storage zone ${zone.Name}.`); + }, +}); diff --git a/packages/cli/src/commands/storage/zone/show.ts b/packages/cli/src/commands/storage/zone/show.ts new file mode 100644 index 0000000..58980f4 --- /dev/null +++ b/packages/cli/src/commands/storage/zone/show.ts @@ -0,0 +1,72 @@ +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 { + formatBytes, + formatDateTime, + formatKeyValue, +} from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { toSafeStorageZone } from "../api.ts"; +import { resolveStorageZoneInteractive } from "../interactive.ts"; +import { isS3Enabled, s3Endpoint } from "../s3.ts"; + +interface ShowArgs { + zone?: string; +} + +export const storageZoneShowCommand = defineCommand({ + command: "show [zone]", + describe: "Show details for a storage zone.", + examples: [ + ["$0 storage zones show my-zone", "Show zone details"], + ["$0 storage zones show my-zone --output json", "JSON output"], + ], + + builder: (yargs) => + yargs.positional("zone", { + type: "string", + describe: "Storage zone name or ID", + }), + + handler: async ({ zone: ref, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveStorageZoneInteractive(client, ref, output); + + if (output === "json") { + logger.log(JSON.stringify(toSafeStorageZone(zone), null, 2)); + return; + } + + const replication = (zone.ReplicationRegions ?? []).join(", ") || "-"; + + const rows = [ + { key: "ID", value: String(zone.Id ?? "") }, + { key: "Name", value: zone.Name ?? "" }, + { key: "Region", value: zone.Region ?? "-" }, + { key: "Replication", value: replication }, + { key: "Hostname", value: zone.StorageHostname ?? "-" }, + { key: "Files", value: String(zone.FilesStored ?? 0) }, + { key: "Used", value: formatBytes(zone.StorageUsed ?? 0) }, + { key: "Modified", value: formatDateTime(zone.DateModified) }, + ]; + + if (isS3Enabled(zone)) { + rows.push( + { key: "S3 compatible", value: "Enabled" }, + { key: "S3 endpoint", value: s3Endpoint(zone) }, + ); + } + + logger.log(formatKeyValue(rows, output)); + + if (isS3Enabled(zone)) { + logger.dim( + `Run "bunny storage zones credentials ${zone.Name}" for S3 keys.`, + ); + } + }, +}); diff --git a/packages/cli/src/commands/storage/zone/update.ts b/packages/cli/src/commands/storage/zone/update.ts new file mode 100644 index 0000000..ffb76ff --- /dev/null +++ b/packages/cli/src/commands/storage/zone/update.ts @@ -0,0 +1,158 @@ +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 { spinner } from "../../../core/ui.ts"; +import type { StorageZoneModel, StorageZoneSettingsModel } from "../api.ts"; +import { + normalizeReplicationRegions, + replicationChoices, +} from "../constants.ts"; +import { resolveStorageZoneInteractive } from "../interactive.ts"; + +interface ZoneUpdateArgs { + zone?: string; + custom404Path?: string; + rewrite404To200?: boolean; + replication?: string[]; +} + +const FLAG_HINT = + "Pass at least one of --custom-404-path, --rewrite-404-to-200, --replication."; + +function hasAnyFlag(args: ZoneUpdateArgs): boolean { + return ( + args.custom404Path !== undefined || + args.rewrite404To200 !== undefined || + args.replication !== undefined + ); +} + +function settingsFromFlags( + args: ZoneUpdateArgs, + primaryCode?: string, +): StorageZoneSettingsModel { + const settings: StorageZoneSettingsModel = {}; + if (args.custom404Path !== undefined) + settings.Custom404FilePath = args.custom404Path; + if (args.rewrite404To200 !== undefined) + settings.Rewrite404To200 = args.rewrite404To200; + if (args.replication !== undefined) + settings.ReplicationZones = normalizeReplicationRegions( + args.replication, + primaryCode, + ); + return settings; +} + +async function promptSettings( + zone: StorageZoneModel, +): Promise { + const answers = await prompts([ + { + type: "text", + name: "custom404Path", + message: "Custom 404 file path (blank for none):", + initial: zone.Custom404FilePath ?? "", + }, + { + type: "toggle", + name: "rewrite404To200", + message: "Rewrite 404 to 200?", + initial: zone.Rewrite404To200 ?? false, + active: "yes", + inactive: "no", + }, + { + type: "multiselect", + name: "replication", + message: "Replication regions (space to toggle):", + choices: replicationChoices(zone.Region ?? undefined).map((region) => ({ + title: `${region.name} (${region.code})`, + value: region.code, + selected: (zone.ReplicationRegions ?? []).includes(region.code), + })), + }, + ]); + if (Object.keys(answers).length === 0) + throw new UserError("Update cancelled."); + + return { + Custom404FilePath: answers.custom404Path || null, + Rewrite404To200: answers.rewrite404To200, + ReplicationZones: answers.replication ?? [], + }; +} + +export const storageZoneUpdateCommand = defineCommand({ + command: "update [zone]", + describe: "Update a storage zone's settings.", + examples: [ + ["$0 storage zones update my-zone", "Edit settings interactively"], + [ + "$0 storage zones update my-zone --custom-404-path /404.html", + "Set the custom 404 file non-interactively", + ], + ], + + builder: (yargs) => + yargs + .positional("zone", { + type: "string", + describe: "Storage zone name or ID", + }) + .option("custom-404-path", { + type: "string", + describe: "Path to the file returned for missing files", + }) + .option("rewrite-404-to-200", { + type: "boolean", + describe: "Rewrite 404 responses to 200 for extensionless URLs", + }) + .option("replication", { + type: "string", + array: true, + describe: "Replication region codes (comma-separated or repeated)", + }), + + handler: async (args) => { + const { zone: ref, profile, output, verbose, apiKey } = args; + const hasFlags = hasAnyFlag(args); + + // JSON output stays non-interactive; settings must come from flags. + if (!hasFlags && output === "json") { + throw new UserError("Nothing to update.", FLAG_HINT); + } + + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveStorageZoneInteractive(client, ref, output); + const settings = hasFlags + ? settingsFromFlags(args, zone.Region ?? undefined) + : await promptSettings(zone); + + const spin = spinner("Updating storage zone..."); + spin.start(); + try { + await client.POST("/storagezone/{id}", { + params: { path: { id: zone.Id as number } }, + body: settings, + }); + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log( + JSON.stringify({ id: zone.Id, name: zone.Name, settings }, null, 2), + ); + return; + } + + logger.success(`Updated storage zone ${zone.Name}.`); + }, +}); diff --git a/packages/cli/src/core/format.test.ts b/packages/cli/src/core/format.test.ts index 20ef14d..dd62283 100644 --- a/packages/cli/src/core/format.test.ts +++ b/packages/cli/src/core/format.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { csvEscape, formatKeyValue, formatTable } from "./format.ts"; - -// --- csvEscape --- +import { + csvEscape, + formatKeyValue, + formatTable, + maskSecret, +} from "./format.ts"; describe("csvEscape", () => { test("plain string unchanged", () => { @@ -25,8 +28,6 @@ describe("csvEscape", () => { }); }); -// --- formatTable --- - describe("formatTable", () => { const headers = ["ID", "Name"]; const rows = [ @@ -92,8 +93,6 @@ describe("formatTable", () => { }); }); -// --- formatKeyValue --- - describe("formatKeyValue", () => { const entries = [ { key: "Name", value: "Alice" }, @@ -135,3 +134,19 @@ describe("formatKeyValue", () => { expect(result).toBe("Key,Value"); }); }); + +describe("maskSecret", () => { + test("keeps the last 4 chars for a long secret", () => { + expect(maskSecret("abcdef1234567890wxyz")).toBe("••••••••••••••••wxyz"); + }); + + test("keeps the last 4 chars once the secret is longer than 8", () => { + expect(maskSecret("abcdef123")).toBe("•••••f123"); + }); + + test("fully masks short secrets so the tail can't expose half or more", () => { + expect(maskSecret("short")).toBe("••••••••"); + expect(maskSecret("12345678")).toBe("••••••••"); + expect(maskSecret("ab")).toBe("••••••••"); + }); +}); diff --git a/packages/cli/src/core/format.ts b/packages/cli/src/core/format.ts index 74b18a4..eb2c7ee 100644 --- a/packages/cli/src/core/format.ts +++ b/packages/cli/src/core/format.ts @@ -1,5 +1,6 @@ import chalk from "chalk"; import Table from "cli-table3"; +import { bunny } from "./colors.ts"; import type { OutputFormat } from "./types.ts"; /** Resolve a date-like value to a `Date`, or `null` if invalid/missing. */ @@ -93,9 +94,9 @@ export function formatTable( return table.toString(); } - // text: borderless aligned columns with bold headers + // text: borderless aligned columns with bold bunny-colored headers const table = new Table({ - head: headers.map((h) => chalk.bold(h)), + head: headers.map((h) => bunny.bold(h)), chars: { top: "", "top-mid": "", @@ -161,3 +162,10 @@ export function formatBytes(bytes: number): string { const value = bytes / 1024 ** i; return `${value.toFixed(value < 10 ? 1 : 0)} ${units[i]}`; } + +export function maskSecret(secret: string): string { + // Revealing the last 4 chars of an 8-or-fewer-char secret exposes half or more, so fully mask it. + if (secret.length <= 8) return "•".repeat(8); + const tail = secret.slice(-4); + return `${"•".repeat(Math.max(secret.length - 4, 4))}${tail}`; +} diff --git a/packages/cli/src/core/hostnames/client.ts b/packages/cli/src/core/hostnames/client.ts index 6609abc..e1f2019 100644 --- a/packages/cli/src/core/hostnames/client.ts +++ b/packages/cli/src/core/hostnames/client.ts @@ -109,6 +109,31 @@ export function liveHostnames(hostnames: Hostname[]): { }; } +// PullZoneOriginType: 2 = StorageZone. +const ORIGIN_TYPE_STORAGE_ZONE = 2; + +/** Create a pull zone served from a storage zone, with delivery enabled in every geo region. */ +export async function createPullZone( + client: CoreClient, + name: string, + storageZoneId: number, +): Promise { + const { data } = await client.POST("/pullzone", { + body: { + Name: name, + StorageZoneId: storageZoneId, + OriginType: ORIGIN_TYPE_STORAGE_ZONE, + EnableGeoZoneUS: true, + EnableGeoZoneEU: true, + EnableGeoZoneASIA: true, + EnableGeoZoneSA: true, + EnableGeoZoneAF: true, + }, + }); + if (!data) throw new UserError(`Failed to create pull zone ${name}.`); + return data; +} + /** Add a hostname to a pull zone, returning the zone's hostnames and the CNAME target to point DNS at. */ export async function addHostname( client: CoreClient, diff --git a/packages/cli/src/core/hostnames/commands.test.ts b/packages/cli/src/core/hostnames/commands.test.ts new file mode 100644 index 0000000..e5ee66a --- /dev/null +++ b/packages/cli/src/core/hostnames/commands.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { targetSuffix } from "./commands.ts"; + +describe("targetSuffix", () => { + test("preserves the storage zone positional", () => { + // bunny storage zone domains add cdn.example.com my-zone + expect(targetSuffix({ zone: "my-zone" }, "zone")).toBe(" my-zone"); + }); + + test("preserves the script id positional without duplicating it as a flag", () => { + expect(targetSuffix({ id: 123 }, "id")).toBe(" 123"); + }); + + test("appends --pull-zone after the positional", () => { + expect(targetSuffix({ zone: "my-zone", "pull-zone": 678 }, "zone")).toBe( + " my-zone --pull-zone 678", + ); + }); + + test("emits nothing when the positional was omitted (resolved from manifest)", () => { + expect(targetSuffix({}, "id")).toBe(""); + }); + + test("emits --pull-zone alone when no positional name is given", () => { + expect(targetSuffix({ "pull-zone": 678 })).toBe(" --pull-zone 678"); + }); +}); diff --git a/packages/cli/src/core/hostnames/commands.ts b/packages/cli/src/core/hostnames/commands.ts index 5823a68..bf264e6 100644 --- a/packages/cli/src/core/hostnames/commands.ts +++ b/packages/cli/src/core/hostnames/commands.ts @@ -36,17 +36,27 @@ export interface HostnamesMountOptions { /** Adds resource-targeting flags (e.g. --id, --pull-zone) shared by every subcommand. */ target?: (yargs: Argv) => Argv; /** Optional trailing positional (e.g. `[id]`) appended to every subcommand for targeting the resource. */ - targetPositional?: { name: string; describe: string }; + targetPositional?: { + name: string; + describe: string; + type?: "string" | "number"; + }; /** Namespace description shown in help. */ describe?: string; /** Hidden namespace aliases (e.g. ["hostnames"]) — they work but stay out of help. */ hiddenAliases?: string[]; } -/** Echo back the targeting flags the user passed so copy-paste follow-up hints keep the same scope. */ -function targetSuffix(args: Record): string { +/** Echo back the targeting args the user passed so copy-paste follow-up hints keep the same scope. */ +export function targetSuffix( + args: Record, + positionalName?: string, +): string { const parts: string[] = []; - if (args.id != null) parts.push(`--id ${args.id}`); + // The trailing positional (storage zone, or script id) re-targets the resource. + if (positionalName && args[positionalName] != null) { + parts.push(String(args[positionalName])); + } if (args["pull-zone"] != null) parts.push(`--pull-zone ${args["pull-zone"]}`); return parts.length ? ` ${parts.join(" ")}` : ""; } @@ -71,7 +81,7 @@ export function createHostnamesCommands( const target = (yargs: Argv): Argv => { const withPositional = targetPositional ? yargs.positional(targetPositional.name, { - type: "number", + type: targetPositional.type ?? "number", describe: targetPositional.describe, }) : yargs; @@ -171,6 +181,7 @@ export function createHostnamesCommands( const sslHint = `bunny ${commandPath} ssl ${hostname}${targetSuffix( args as unknown as Record, + targetPositional?.name, )}`; // A requested certificate that failed to issue is a command error, like `ssl`. diff --git a/packages/cli/src/core/hostnames/flow.ts b/packages/cli/src/core/hostnames/flow.ts index fe43ed6..1f37b30 100644 --- a/packages/cli/src/core/hostnames/flow.ts +++ b/packages/cli/src/core/hostnames/flow.ts @@ -6,7 +6,12 @@ import { findBunnyDnsZone, offerBunnyDnsRecord, } from "./bunny-dns.ts"; -import { type CoreClient, enableSsl, hostnameUrl } from "./client.ts"; +import { + addHostname, + type CoreClient, + enableSsl, + hostnameUrl, +} from "./client.ts"; import { anyResolverPointsAt, defaultResolvers } from "./dns.ts"; const DNS_TIMEOUT_MS = 10 * 60 * 1000; @@ -178,6 +183,86 @@ export async function offerDnsWaitAndSsl( * resolve publicly (only bunny's nameservers serve it), so we point the user at * their registrar instead of starting a poll that would time out after 10 min. */ +/** + * Add a hostname to a pull zone, then wire up DNS and a free SSL certificate. + * Tries the Bunny DNS auto-record path first (when the domain is on Bunny DNS), + * otherwise prints the CNAME to set and offers to wait for propagation. + * + * Returns `true` when a certificate was issued. Resource-agnostic: the caller + * supplies the copy-paste `sslHint`/`retryHint` for its own command surface. + */ +export async function setupHostname(opts: { + coreClient: CoreClient; + pullZoneId: number; + domain: string; + /** Copy-paste command to issue SSL later (shown when declining or on manual DNS). */ + sslHint: string; + /** Copy-paste command to retry adding the hostname (shown if the add fails). */ + retryHint?: string; + forceSsl: boolean; + interactive: boolean; + verbose: boolean; + onBunnyDnsZone?: (zone: { + id: number; + domain: string; + }) => void | Promise; +}): Promise { + const spin = spinner(`Adding ${opts.domain}...`); + spin.start(); + + let cnameTarget: string | undefined; + try { + ({ cnameTarget } = await addHostname( + opts.coreClient, + opts.pullZoneId, + opts.domain, + )); + } catch (err) { + spin.stop(); + const message = err instanceof Error ? err.message : String(err); + logger.warn(`Couldn't add ${opts.domain}: ${message}`); + if (opts.retryHint) logger.dim(` Retry: ${opts.retryHint}`); + return false; + } + + spin.stop(); + logger.success(`Added ${opts.domain} to pull zone ${opts.pullZoneId}.`); + if (!cnameTarget) return false; + + if (opts.interactive) { + const issued = await offerBunnyDnsThenSsl({ + coreClient: opts.coreClient, + hostname: opts.domain, + pullZoneId: opts.pullZoneId, + cnameTarget, + forceSsl: opts.forceSsl, + sslHint: opts.sslHint, + verbose: opts.verbose, + onBunnyDnsZone: opts.onBunnyDnsZone, + }); + if (issued !== null) return issued; + } + + logger.log(); + logger.log("Point your DNS at bunny.net to activate it:"); + logger.accent(` CNAME ${opts.domain} -> ${cnameTarget}`); + logger.log(); + + if (!opts.interactive) { + printSslHint(opts.sslHint); + return false; + } + + return offerDnsWaitAndSsl({ + coreClient: opts.coreClient, + pullZoneId: opts.pullZoneId, + hostname: opts.domain, + cnameTarget, + forceSsl: opts.forceSsl, + sslHint: opts.sslHint, + }); +} + export async function offerBunnyDnsThenSsl(opts: { coreClient: CoreClient; hostname: string; diff --git a/packages/cli/src/core/hostnames/index.ts b/packages/cli/src/core/hostnames/index.ts index 9d50dac..fe2629f 100644 --- a/packages/cli/src/core/hostnames/index.ts +++ b/packages/cli/src/core/hostnames/index.ts @@ -7,6 +7,7 @@ export { export { addHostname, type CoreClient, + createPullZone, enableSsl, fetchHostnamesForZones, fetchPullZoneHostnames, @@ -34,4 +35,5 @@ export { offerBunnyDnsThenSsl, offerDnsWaitAndSsl, printSslHint, + setupHostname, } from "./flow.ts"; diff --git a/packages/cli/src/core/stats.test.ts b/packages/cli/src/core/stats.test.ts index 2123101..af1227d 100644 --- a/packages/cli/src/core/stats.test.ts +++ b/packages/cli/src/core/stats.test.ts @@ -6,8 +6,6 @@ import { sumChart, } from "./stats.ts"; -// --- sumChart --- - describe("sumChart", () => { test("sums chart values", () => { expect(sumChart({ "2026-05-01": 3, "2026-05-02": 7 })).toBe(10); @@ -23,8 +21,6 @@ describe("sumChart", () => { }); }); -// --- formatBucketLabel --- - describe("formatBucketLabel", () => { test("formats a daily UTC bucket as a friendly date", () => { expect(formatBucketLabel("2026-05-19T00:00:00Z")).toBe("May 19, 2026"); @@ -52,8 +48,6 @@ describe("formatBucketLabel", () => { }); }); -// --- renderBarChart --- - describe("renderBarChart", () => { test("renders one line per row with label and value", () => { const lines = renderBarChart([ diff --git a/packages/database-shell/src/shell.test.ts b/packages/database-shell/src/shell.test.ts index 1c9c58b..8d81277 100644 --- a/packages/database-shell/src/shell.test.ts +++ b/packages/database-shell/src/shell.test.ts @@ -30,8 +30,6 @@ import { splitStatements, } from "./index.ts"; -// --- helpers --- - function makeResultSet( columns: string[], rows: unknown[][], @@ -58,8 +56,6 @@ function captureLogger(lines: string[]): ShellLogger { }; } -// --- formatValue --- - describe("formatValue", () => { test("returns styled NULL for null", () => { const result = formatValue(null); @@ -79,8 +75,6 @@ describe("formatValue", () => { }); }); -// --- formatValueRaw --- - describe("formatValueRaw", () => { test("returns plain NULL for null", () => { expect(formatValueRaw(null)).toBe("NULL"); @@ -95,8 +89,6 @@ describe("formatValueRaw", () => { }); }); -// --- csvEscape --- - describe("csvEscape", () => { test("returns plain value when no special characters", () => { expect(csvEscape("hello")).toBe("hello"); @@ -119,8 +111,6 @@ describe("csvEscape", () => { }); }); -// --- printResultSet --- - describe("printResultSet", () => { test("json mode outputs JSON array of objects", () => { const lines: string[] = []; @@ -221,8 +211,6 @@ describe("printResultSet", () => { }); }); -// --- history --- - describe("history", () => { let tmpDir: string; let originalEnv: string | undefined; @@ -288,8 +276,6 @@ describe("history", () => { }); }); -// --- isSensitiveColumn --- - describe("isSensitiveColumn", () => { test("matches password variants", () => { expect(isSensitiveColumn("password")).toBe(true); @@ -351,8 +337,6 @@ describe("isSensitiveColumn", () => { }); }); -// --- columnMaskType --- - describe("columnMaskType", () => { test("returns full for password columns", () => { expect(columnMaskType("password")).toBe("full"); @@ -370,8 +354,6 @@ describe("columnMaskType", () => { }); }); -// --- maskEmail --- - describe("maskEmail", () => { test("masks middle of local part", () => { expect(maskEmail("alice@example.com")).toBe("a••••e@example.com"); @@ -398,8 +380,6 @@ describe("maskEmail", () => { }); }); -// --- printResultSet masking --- - describe("printResultSet masking", () => { test("json mode masks sensitive columns", () => { const lines: string[] = []; @@ -517,8 +497,6 @@ describe("printResultSet masking", () => { }); }); -// --- splitStatements --- - describe("splitStatements", () => { test("splits multiple statements", () => { expect(splitStatements("SELECT 1; SELECT 2;")).toEqual([ @@ -595,8 +573,6 @@ describe("splitStatements", () => { }); }); -// --- views --- - describe("views", () => { let tmpDir: string;