Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ bun ny sites domains add example.com # attach a custom production domain
bun ny sites ssl --no-force-ssl # stop forcing HTTPS on the site's b-cdn.net system host
bun ny sites open # open the site's live URL in the browser
bun ny sites ci init # add a GitHub Actions workflow (push to main goes live)
bun ny stream library list # list Stream video libraries (videos, storage, traffic, replication regions)
bun ny stream library create my-library # create a video library (omit the name to be prompted)
bun ny stream library create my-library --replication-regions NY,SG # replicate the library's storage to New York and Singapore
bun ny stream library show my-library # show one library (accepts a name or ID; omit it to use the linked library, or to pick interactively when nothing is linked). API keys are never printed here, in any output format
bun ny stream library credentials my-library --show-secret # deliberately retrieve a library's Stream API key (--read-only for the read-only key; masked without --show-secret)
bun ny stream library delete my-library # delete a library and all of its videos (--force skips the confirmation, and is required non-interactively)
bun ny stream upload ./video.mp4 # upload a video to the linked library (--title sets the title; it defaults to the file name)
bun ny stream upload ./video.mp4 --lib 12345 # upload to a specific library by ID
bun ny stream upload https://example.com/video.mp4 --lib 12345 # let bunny.net fetch the video server side (--header "Name: value" for an origin that needs auth)
bun ny stream videos list # list the videos in the linked library (ID, title, status, size, length, views, upload date)
bun ny stream videos show 1a2b3c4d-... # show one video by GUID, including its Direct Play URL (also update and delete; new videos come from stream upload)
bun ny stream link my-library # link the directory to a library so upload can omit it (bun ny stream unlink removes the link)
```

Every deploy is published as the live site. Deploys are immutable under their own ID, so `bun ny sites deployments publish` rolls back to any earlier one without re-uploading. Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`) so a deploy needs no flags: `bun ny sites deploy --build`. `bun ny sites ci init` writes the same `build` and `dir` into the generated workflow. See [`examples/sites/`](examples/sites/) for ready-to-copy configs (Vite, Astro, Next.js static export, Hugo, plain HTML, and a combined app + site file).
Expand Down
57 changes: 57 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,63 @@ Every deploy publishes: the files land in an immutable `deploys/<id>/` directory
| `--keep-storage` | `delete` | Delete the pull zone but keep the storage zone and its deploy files |
| `--force`, `-f` | `deployments publish`, `prune`, `domains remove`, `delete` | Skip the confirmation prompts |

### `bunny stream`

> **Experimental**: hidden from `--help` and the landing page while it stabilizes.

Manage bunny.net Stream video libraries with **`bunny stream library`** (aliases `libraries`, `lib`), and upload videos into them with **`bunny stream upload`**. Library management uses the account API key on the core API; each library also carries its own Stream API key for the video-level API, which the CLI never prints unless you ask for it with `credentials`. Uploads and video commands use that per-library key, resolved automatically from the library, so there is nothing extra to configure.

The videos inside a library are managed with **`bunny stream videos`** (alias `video`): `list`, `show`, `update`, and `delete`, each taking the video's GUID and resolving the library the same way `upload` does. There is deliberately no `videos create`: `bunny stream upload` is how a video is added.

`library` commands take the library as an optional positional (name or numeric ID); `upload` and the `videos` commands take it as the `--lib`/`--library` flag (their positional is the local file path or the video GUID). When the library is omitted it resolves from the directory's linked library (`bunny stream link`, stored in `.bunny/stream.json`), then an interactive picker, which offers to link the directory to the picked library (destructive commands never offer it). Non-interactive runs (`--output json`, no TTY) error with a hint instead of prompting: pass a library or link the directory. A `videos` command with no GUID behaves the same way, offering a picker interactively and erroring otherwise.

```bash
bunny stream library list # ID, name, videos, storage, traffic, replication regions
bunny stream library create my-library # interactive: prompts for the name when omitted
bunny stream library create my-library --replication-regions NY,SG # replicate the underlying storage (create-time only)
bunny stream library show my-library # details; API keys are never printed here, in any output format
bunny stream library credentials my-library # library ID + API key, masked by default
bunny stream library credentials my-library --show-secret --read-only # reveal the read-only key
bunny stream library delete my-library # confirms with the video count; --force skips it

# Upload a video (creates the video, streams the file, then reports its encoding status)
bunny stream upload ./video.mp4 # linked library
bunny stream upload ./video.mp4 --lib 12345 # a specific library
bunny stream upload ./video.mp4 --title "Launch demo" # defaults to the file name

# Or hand bunny.net a URL and let it fetch the video server side
bunny stream upload https://example.com/video.mp4 --lib 12345
bunny stream upload https://example.com/video.mp4 --header "Authorization: Bearer abc"

# Videos within a library (GUIDs come from `videos list`)
bunny stream videos list # ID, title, status, size, length, views, upload date
bunny stream videos list --lib 12345 --search launch # a specific library, filtered by title
bunny stream videos show 1a2b3c4d-... # details, including the Direct Play URL
bunny stream videos update 1a2b3c4d-... --title "Launch demo" # prompts for the title when omitted
bunny stream videos delete 1a2b3c4d-... # confirms first; --force skips it

# Link the working directory to a library so commands can omit it
bunny stream link my-library
bunny stream unlink
```

Deleting a library deletes all of its videos. `--force` is required for non-interactive deletes and unlinks; without it, a run that cannot prompt exits with an error instead of hanging.

A local upload happens in two steps: the video entry is created first, then the file's bytes are sent to it. If the byte upload fails, the CLI checks the video's status: an entry that never received bytes is deleted so a retry does not leave orphans behind, while anything that may already hold the upload (including a status the CLI could not read) is kept, with a warning naming its GUID, since a lost response does not mean the bytes were rejected. Encoding continues on bunny.net after the command returns, so the reported status is usually `Uploaded` or `Processing` rather than `Finished`.

Passing an `http://` or `https://` URL instead of a path switches to bunny.net's server-side fetch: the origin is downloaded by bunny.net, not by the CLI, so nothing is transferred through your machine and the command returns as soon as the fetch is queued. Use `--header` (repeatable, `"Name: value"`) for an origin that needs authentication; it applies to URL uploads only. That endpoint answers with a status rather than a video, so a URL upload reports no video ID or Direct Play URL: the video appears in `bunny stream videos list` once it has been fetched and encoded, titled after the remote file name unless `--title` says otherwise.

| Flag | Commands | Description |
| ----------------------- | ----------------------------------- | ----------------------------------------------------------------------------------- |
| `--replication-regions` | `create` | Storage replication region codes, comma-separated or repeated; fixed after creation |
| `--read-only` | `credentials` | Show the read-only API key instead of the read-write one |
| `--show-secret` | `credentials` | Reveal the API key (masked by default in both table and JSON output) |
| `--lib`, `--library` | `upload`, all `videos` commands | Video library ID (defaults to the linked library) |
| `--search` | `videos list` | Only list videos matching this search term |
| `--title` | `upload`, `videos update` | Video title; defaults to the file name on `upload`, prompts on `update` |
| `--header` | `upload` (URL only) | Header to send with a URL fetch as `"Name: value"`; repeatable |
| `--force`, `-f` | `delete`, `videos delete`, `unlink` | Skip the confirmation prompt (required when there is no TTY to answer it) |

### `bunny sandbox`

Manage on-demand cloud sandbox environments backed by Bunny Magic Containers. Each sandbox is a fully isolated Ubuntu container with Node.js, Bun, Python (plus `uv`), the bunny CLI, and Claude Code pre-installed, alongside the tooling agents reach for: `git`, `gh`, `ripgrep`, `fd`, `jq`, `tmux`, `sqlite3`, `tree`, and `fzf`. A 10 GB persistent volume is mounted at `/workplace`, your default working directory.
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { scriptsNamespace } from "./commands/scripts/index.ts";
import { sitesNamespace } from "./commands/sites/index.ts";
import { skillsNamespace } from "./commands/skills/index.ts";
import { storageNamespace } from "./commands/storage/index.ts";
import { streamNamespace } from "./commands/stream/index.ts";
import { whoamiCommand } from "./commands/whoami.ts";
import { bunny } from "./core/colors.ts";
import { logger } from "./core/logger.ts";
Expand Down Expand Up @@ -45,6 +46,7 @@ const experimentalCommands: CommandModule[] = [
registryNamespace,
sitesNamespace,
storageNamespace,
streamNamespace,
];

let instance = yargs(hideBin(process.argv))
Expand Down
192 changes: 192 additions & 0 deletions packages/cli/src/commands/stream/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { describe, expect, test } from "bun:test";
import {
type CoreClient,
fetchLibraries,
fetchLibrary,
resolveLibrary,
toSafeVideoLibrary,
type VideoLibraryModel,
} from "./api.ts";

interface Call {
method: string;
path: string;
params?: Record<string, unknown>;
}

/**
* Path-branching fake core client (same shape as sites/api.test.ts): only the
* /videolibrary endpoints the stream commands touch are implemented.
*
* GET /videolibrary is modelled on the spec: the { Items, ... } envelope only
* appears when `page` is greater than 0; without it the endpoint answers with
* a plain array, which is what made an omitted `page` silently match nothing.
*/
function fakeCoreClient(opts: {
calls: Call[];
libraries?: VideoLibraryModel[];
/** Split the listing across pages so HasMoreItems paging is exercised. */
pageSize?: number;
}): CoreClient {
const libraries = opts.libraries ?? [];
return {
GET: async (
path: string,
options?: {
params?: {
path?: { id?: number };
query?: { page?: number; perPage?: number; search?: string };
};
},
) => {
opts.calls.push({ method: "GET", path, params: options?.params });
if (path === "/videolibrary/{id}") {
return {
data: libraries.find((lib) => lib.Id === options?.params?.path?.id),
};
}
if (path === "/videolibrary") {
const search = options?.params?.query?.search;
const matched = search
? libraries.filter((lib) =>
(lib.Name ?? "").toLowerCase().includes(search.toLowerCase()),
)
: libraries;
const page = options?.params?.query?.page ?? 0;
// page 0 (or omitted) → plain array, no pagination envelope.
if (page < 1) return { data: matched };
const pageSize = opts.pageSize ?? Math.max(matched.length, 1);
const start = (page - 1) * pageSize;
return {
data: {
Items: matched.slice(start, start + pageSize),
CurrentPage: page,
TotalItems: matched.length,
HasMoreItems: start + pageSize < matched.length,
},
};
}
throw new Error(`unexpected GET ${path}`);
},
} as unknown as CoreClient;
}

const LIBRARIES: VideoLibraryModel[] = [
{ Id: 2, Name: "zebra", VideoCount: 1 },
{ Id: 1, Name: "Alpha", VideoCount: 3 },
{ Id: 3, Name: "marketing", VideoCount: 0 },
];

test("fetchLibraries pages through the listing and sorts by name", async () => {
const calls: Call[] = [];
const client = fakeCoreClient({
calls,
libraries: LIBRARIES,
pageSize: 2, // force a second page
});

const libraries = await fetchLibraries(client);

expect(libraries.map((lib) => lib.Name)).toEqual([
"Alpha",
"marketing",
"zebra",
]);
const pages = calls
.filter((c) => c.path === "/videolibrary")
.map((c) => (c.params as { query: { page: number } }).query.page);
expect(pages).toEqual([1, 2]);
});

test("fetchLibraries returns an empty list when the account has none", async () => {
expect(await fetchLibraries(fakeCoreClient({ calls: [] }))).toEqual([]);
});

test("fetchLibrary throws a UserError when the ID does not exist", async () => {
const client = fakeCoreClient({ calls: [], libraries: LIBRARIES });
await expect(fetchLibrary(client, 99)).rejects.toThrow(
"Video library 99 not found.",
);
});

test("resolveLibrary treats numeric input as an ID", async () => {
const calls: Call[] = [];
const client = fakeCoreClient({ calls, libraries: LIBRARIES });

const lib = await resolveLibrary(client, "3");

expect(lib.Name).toBe("marketing");
// Straight to the by-ID endpoint: no search listing.
expect(calls.map((c) => c.path)).toEqual(["/videolibrary/{id}"]);
});

test("resolveLibrary matches a name case-insensitively and re-fetches by ID", async () => {
const calls: Call[] = [];
const client = fakeCoreClient({ calls, libraries: LIBRARIES });

const lib = await resolveLibrary(client, "ALPHA");

expect(lib.Id).toBe(1);
expect(calls.map((c) => c.path)).toEqual([
"/videolibrary",
"/videolibrary/{id}",
]);
const search = calls[0]?.params as {
query: { search: string; page: number };
};
expect(search.query.search).toBe("ALPHA");
// Regression: without page >= 1 the endpoint answers with a plain array,
// data.Items is undefined, and every name lookup "finds" nothing.
expect(search.query.page).toBeGreaterThanOrEqual(1);
});

// A search is a substring match server-side, so a partial hit must not be
// mistaken for the requested library.
test("resolveLibrary rejects a partial name match", async () => {
const client = fakeCoreClient({ calls: [], libraries: LIBRARIES });
await expect(resolveLibrary(client, "market")).rejects.toThrow(
'No video library found for "market".',
);
});

test("resolveLibrary requires a non-empty reference", async () => {
const client = fakeCoreClient({ calls: [], libraries: LIBRARIES });
await expect(resolveLibrary(client, " ")).rejects.toThrow(
"A library name or ID is required.",
);
});

describe("toSafeVideoLibrary", () => {
const library = {
Id: 1,
Name: "my-library",
VideoCount: 3,
ApiKey: "rw-secret",
ReadOnlyApiKey: "ro-secret",
// Deprecated, but the API still returns it and its value equals ApiKey.
ApiAccessKey: "rw-secret",
StorageUsage: 1024,
} as VideoLibraryModel;

test("drops every API key, including the deprecated ApiAccessKey", () => {
const safe = toSafeVideoLibrary(library);
expect("ApiKey" in safe).toBe(false);
expect("ReadOnlyApiKey" in safe).toBe(false);
expect("ApiAccessKey" in safe).toBe(false);
expect(JSON.stringify(safe)).not.toContain("secret");
});

test("preserves every non-secret field", () => {
expect(toSafeVideoLibrary(library)).toEqual({
Id: 1,
Name: "my-library",
VideoCount: 3,
StorageUsage: 1024,
} as VideoLibraryModel);
});

test("does not mutate the original library", () => {
toSafeVideoLibrary(library);
expect(library.ApiKey).toBe("rw-secret");
});
});
85 changes: 85 additions & 0 deletions packages/cli/src/commands/stream/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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<typeof createCoreClient>;
export type VideoLibraryModel = components["schemas"]["VideoLibraryModel"];
export type VideoLibraryCreateModel =
components["schemas"]["VideoLibraryCreateModel"];

export type SafeVideoLibrary = Omit<
VideoLibraryModel,
"ApiKey" | "ReadOnlyApiKey" | "ApiAccessKey"
>;

// Strip the read-write/read-only library keys so list/show/create JSON never
// leaks credentials; use `stream library credentials` to retrieve those on purpose.
// ApiAccessKey is deprecated but carries the same value as ApiKey, so leaving it
// in would leak the write-capable key right back out.
export function toSafeVideoLibrary(
library: VideoLibraryModel,
): SafeVideoLibrary {
const { ApiKey: _k, ReadOnlyApiKey: _r, ApiAccessKey: _a, ...safe } = library;
return safe;
}

/** Fetch all Stream video libraries on the account, paginated and sorted by name. */
export async function fetchLibraries(
client: CoreClient,
): Promise<VideoLibraryModel[]> {
const libraries: VideoLibraryModel[] = [];
let page = 1;
for (;;) {
const { data } = await client.GET("/videolibrary", {
params: { query: { page, perPage: 1000 } },
});
libraries.push(...(data?.Items ?? []));
if (!data?.HasMoreItems) break;
page++;
}
return libraries.sort((a, b) => (a.Name ?? "").localeCompare(b.Name ?? ""));
}

/** Fetch a single video library by ID. */
export async function fetchLibrary(
client: CoreClient,
id: number,
): Promise<VideoLibraryModel> {
const { data } = await client.GET("/videolibrary/{id}", {
params: { path: { id } },
});
if (!data) throw new UserError(`Video library ${id} not found.`);
return data;
}

/**
* Resolve a library reference (numeric ID or name) to a full library.
*
* Numeric input is treated as a library ID; anything else is matched against
* the account's libraries by name.
*/
export async function resolveLibrary(
client: CoreClient,
nameOrId: string,
): Promise<VideoLibraryModel> {
const ref = nameOrId.trim();
if (!ref) throw new UserError("A library name or ID is required.");

if (/^\d+$/.test(ref)) return fetchLibrary(client, Number(ref));

// page must be >= 1: at page 0 the endpoint returns a plain array instead of
// the { Items, ... } envelope, and the match below would never find anything.
const { data } = await client.GET("/videolibrary", {
params: { query: { page: 1, search: ref, perPage: 1000 } },
});
const match = (data?.Items ?? []).find(
(lib) => (lib.Name ?? "").toLowerCase() === ref.toLowerCase(),
);
if (!match?.Id) {
throw new UserError(
`No video library found for "${nameOrId}".`,
'Run "bunny stream library list" to see your libraries.',
);
}
return fetchLibrary(client, match.Id);
}
Loading