Skip to content

fix(storage): convention parity#120

Open
jamie-at-bunny wants to merge 3 commits into
mainfrom
fix/storage-convention-parity
Open

fix(storage): convention parity#120
jamie-at-bunny wants to merge 3 commits into
mainfrom
fix/storage-convention-parity

Conversation

@jamie-at-bunny

Copy link
Copy Markdown
Member

No description provided.

…tions

- Use the shared isInteractive() gate (stdin + stdout TTY, json-aware)
  instead of stdout-only checks in the zone resolver, link, and zone add
- zones update: require field flags whenever non-interactive (json,
  non-TTY, or --force) instead of only under --output json, and abort
  the whole interactive edit on a mid-flow cancel rather than applying
  partial answers
- zones update --force now matches zones add: skip prompts and
  confirmations, use flag values only
- storage zone domains commands now resolve the zone like every other
  storage command: explicit ref, then linked zone, then picker
- Honor --force when resolving the update target: pass it through to
  resolveStorageZoneInteractive so a missing zone errors instead of
  opening the picker
- Omit ReplicationZones from the interactive editor's settings when no
  new regions were picked, keeping replication out of the update body
- Clarify that flags take full precedence over the interactive editor
- zones remove: double confirmation (yes/no, then type the zone name)
  unless --force, matching db and scripts delete; also removes a stale
  .bunny/storage.json that pointed at the deleted zone
- New storage unlink command mirroring dns zones unlink
- resolveStorageZoneInteractive takes DNS-style opts (output, force,
  offerLink, ignoreManifest); the picker now offers to link the
  directory on non-destructive commands, and link reuses the resolver
  instead of duplicating it
- The zone-required error now hints at bunny storage link
- Replication confirmation defaults to no: adding a replica is
  permanent and adds cost
@bogdan-at-bunny

Copy link
Copy Markdown

@codex review

@changeset-bot

changeset-bot Bot commented Jul 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9ca267c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@bunny.net/cli Patch
@bunny.net/cli-linux-x64 Patch
@bunny.net/cli-linux-arm64 Patch
@bunny.net/cli-darwin-x64 Patch
@bunny.net/cli-darwin-arm64 Patch
@bunny.net/cli-windows-x64 Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@jamie-at-bunny jamie-at-bunny changed the base branch from main to fix/storage-behavioral-consistency July 10, 2026 11:49

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9ca267cb30

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +36 to +39
if (!force) {
const confirmed = await confirm(
`Unlink from ${existing.name ?? existing.id}?`,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid prompting in JSON unlink mode

When --output json is used and .bunny/storage.json exists, this branch still opens a confirmation prompt unless --force is also supplied. In CI or other JSON consumers this can block or produce the plain-text Unlink cancelled. success path instead of a JSON payload, violating the command's JSON-output contract; require --force/return a JSON error before calling confirm in JSON mode.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown

Greptile Summary

This PR brings behavioral and convention parity across the storage command group: shared isInteractive TTY detection, a new unlink command, offer-to-link from the zone picker, double confirmation for zone deletion, manifest cleanup after zone removal, and replication confirmation defaulting to no.

  • resolveStorageZoneInteractive refactor — now accepts an options object with offerLink, force, and ignoreManifest flags; exports writeStorageManifest so link.ts and interactive.ts share the same write path; all file and zone commands updated to pass the new shape.
  • Zone removal hardening — adds a second type-to-confirm step (type the zone name), cleans up a stale .bunny/storage.json that pointed at the deleted zone, and skips both confirmations under --force.
  • New storage unlink command — removes .bunny/storage.json with a yes/no confirmation (skippable via --force); returns structured JSON output when --output json is set.

Confidence Score: 4/5

Safe to merge with minor follow-ups; no data loss or correctness issues on the happy path.

The core refactoring is solid and matches the documented conventions. The type-to-confirm check in zone removal has an edge case where a zone.Name of undefined combined with a Ctrl+C would bypass the second guard and proceed to deletion — practically unreachable today but structurally fragile. The new unlink command silently no-ops in non-TTY environments without --force rather than erroring like every other storage command, which could mislead automation. None of these block a merge, but the two non-interactive inconsistencies are worth a quick fix pass.

zone/remove.ts (type-to-confirm edge case) and unlink.ts (non-interactive guard) deserve a second look before the next release.

Important Files Changed

Filename Overview
packages/cli/src/commands/storage/interactive.ts Refactored to accept an options object; adds offerLink, force, and ignoreManifest opts, moves writeStorageManifest here, and adds maybeLinkZone post-picker. isInteractive now checks both stdin and stdout TTY.
packages/cli/src/commands/storage/zone/remove.ts Adds type-to-confirm step and post-deletion manifest cleanup; has an edge case where zone.Name === undefined + Ctrl+C bypasses the second guard.
packages/cli/src/commands/storage/unlink.ts New command removing .bunny/storage.json; missing isInteractive guard causes silent cancel in non-TTY mode without --force.
packages/cli/src/commands/storage/zone/update.ts Adds isInteractive guard, onCancel propagation, and omits ReplicationZones from PATCH body when nothing new was picked.
packages/cli/src/commands/storage/link.ts Simplified by delegating to resolveStorageZoneInteractive with ignoreManifest: true; minor UX change in cancel message.
packages/cli/src/commands/storage/zone/hostnames/index.ts Resolver now falls back to linked manifest and picker rather than requiring explicit zone arg; output is correctly threaded through.
packages/cli/src/commands/storage/constants.ts Replication confirmation default changed from yes to no, aligning with the convention for permanent/costly actions.
packages/cli/src/commands/storage/file/remove.ts Passes offerLink: true; the only destructive file command to do so, possibly inconsistent with the stated convention.
packages/cli/src/commands/storage/index.ts Registers storageUnlinkCommand in the storage namespace. Straightforward.
packages/cli/src/commands/storage/zone/add.ts Replaces inline TTY check with shared isInteractive(output); now also checks stdin.isTTY, which is stricter but correct.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["resolveStorageZoneInteractive(client, ref, opts)"] --> B{ref provided?}
    B -- yes --> C[resolveStorageZone via API]
    B -- no --> D{opts.ignoreManifest?}
    D -- no --> E{manifest.id exists?}
    E -- yes --> F[fetchStorageZone by manifest ID]
    E -- no --> G{isInteractive + !force?}
    D -- yes --> G
    G -- no --> H["throw UserError: zone required"]
    G -- yes --> I[Show interactive picker]
    I --> J{User cancels?}
    J -- yes --> K["throw UserError: zone required"]
    J -- no --> L[fetchStorageZone by picked ID]
    L --> M{opts.offerLink?}
    M -- yes --> N["confirm: Link this directory?"]
    N -- yes --> O[writeStorageManifest + log success]
    N -- no --> P[return zone]
    O --> P
    M -- no --> P
    C --> P
    F --> P
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["resolveStorageZoneInteractive(client, ref, opts)"] --> B{ref provided?}
    B -- yes --> C[resolveStorageZone via API]
    B -- no --> D{opts.ignoreManifest?}
    D -- no --> E{manifest.id exists?}
    E -- yes --> F[fetchStorageZone by manifest ID]
    E -- no --> G{isInteractive + !force?}
    D -- yes --> G
    G -- no --> H["throw UserError: zone required"]
    G -- yes --> I[Show interactive picker]
    I --> J{User cancels?}
    J -- yes --> K["throw UserError: zone required"]
    J -- no --> L[fetchStorageZone by picked ID]
    L --> M{opts.offerLink?}
    M -- yes --> N["confirm: Link this directory?"]
    N -- yes --> O[writeStorageManifest + log success]
    N -- no --> P[return zone]
    O --> P
    M -- no --> P
    C --> P
    F --> P
Loading

Comments Outside Diff (1)

  1. packages/cli/src/commands/storage/file/remove.ts, line 136-139 (link)

    P2 offerLink on a destructive command

    AGENTS.md (and the PR changeset description) document that the zone picker "offers to link the directory to the picked zone (except on destructive commands)". storage files remove deletes files, making it the destructive command in the file/ group, yet it passes offerLink: true. The practical effect is that a user who runs bunny storage files remove some/path without a zone, picks one from the picker, and is immediately offered to link the directory to that zone — all while a deletion is in progress. Every other file command (list, upload, download) passing offerLink: true is a read or write operation; only remove is a delete. Was this intentional, or should offerLink be omitted here as it is in zone/remove.ts? Was offerLink: true on file/remove.ts intentional? The stated convention is to not offer linking on destructive commands, but this is the only file-level destructive command that has it.

    Fix in Claude Code

Fix All in Claude Code

Reviews (1): Last reviewed commit: "feat(storage): close convention gaps wit..." | Re-trigger Greptile

Comment on lines +57 to +65
const { value } = await prompts({
type: "text",
name: "value",
message: `Type "${zone.Name}" to confirm:`,
});
if (value !== zone.Name) {
logger.log("Cancelled.");
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Type-to-confirm bypass when zone.Name is undefined

When the user presses Ctrl+C on the type-to-confirm prompt, prompts resolves with {}, so value is undefined. For every zone that has a real string name the check undefined !== zone.Name evaluates to true and correctly logs "Cancelled." — but if zone.Name is undefined (allowed by the OpenAPI type), the comparison is undefined !== undefinedfalse, which silently lets the deletion proceed without the user ever confirming the name. Even though real zones fetched from the API always carry a name today, this is an unintended bypass path in the most destructive command in the file. A simple null-coalescing guard on zone.Name (e.g. zone.Name ?? "") makes the second prompt produce a non-empty string that can never accidentally match an undefined typed value.

Fix in Claude Code

Comment on lines +36 to +47
if (!force) {
const confirmed = await confirm(
`Unlink from ${existing.name ?? existing.id}?`,
);
if (!confirmed) {
logger.log("Unlink cancelled.");
return;
}
}

removeManifest(STORAGE_MANIFEST);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Non-interactive execution silently cancels instead of erroring

Every other storage command that needs interactive input (e.g. zone update, zone remove) checks isInteractive(output) first and throws a UserError when running without a TTY and without --force. unlink skips that guard: when called in a non-TTY environment (CI, piped output) without --force, confirm() resolves with undefined, returns false, and the command logs "Unlink cancelled." and exits 0 — even though the manifest is still present. A script that expected the unlink to succeed would silently see the manifest left behind with no error to act on. Adding an isInteractive check (or accepting force in the confirm call) before the prompt would match the pattern used elsewhere.

Fix in Claude Code

Base automatically changed from fix/storage-behavioral-consistency to main July 10, 2026 14:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants