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
6 changes: 6 additions & 0 deletions .changeset/sandbox-list-status-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@vercel/sandbox": minor
"sandbox": minor
---

Add a `status` filter (`running`, `stopping`, `stopped`) to `Sandbox.list`, forwarded to the API for correct pagination. The `sandbox ls` CLI now filters by `running` at the API level by default, adds a `--status` option, and keeps returning every status with `--all`.
38 changes: 32 additions & 6 deletions packages/sandbox/src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ export const list = cmd.command({
short: "a",
description: "Show all sandboxes (default shows just running)",
}),
status: cmd.option({
long: "status",
description:
"Filter sandboxes by status. Options: running, stopping, stopped. Cannot be combined with --all.",
type: cmd.optional(
cmd.oneOf(["running", "stopping", "stopped"] as const),
),
}),
namePrefix: cmd.option({
long: "name-prefix",
description: "Filter sandboxes by name prefix",
Expand Down Expand Up @@ -52,7 +60,7 @@ export const list = cmd.command({
}),
scope,
},
async handler({ scope: { token, team, project }, all, namePrefix, sortBy, sortOrder, tags, limit, cursor }) {
async handler({ scope: { token, team, project }, all, status, namePrefix, sortBy, sortOrder, tags, limit, cursor }) {
if (namePrefix) {
if (sortBy && sortBy !== "name") {
console.error(chalk.red("Error: --sort-by must be 'name' when using --name-prefix"));
Expand All @@ -62,6 +70,27 @@ export const list = cmd.command({
sortBy = 'name';
}

if (all && status) {
console.error(chalk.red("Error: --status cannot be combined with --all"));
return;
}

// The API status filter is only valid with sortBy=createdAt and without
// tags. Passing --name-prefix forces sortBy=name (set above).
const hasStatusConflict =
Object.keys(tags).length > 0 ||
namePrefix !== undefined ||
(sortBy !== undefined && sortBy !== "createdAt");

if (status && hasStatusConflict) {
console.error(chalk.red("Error: --status cannot be combined with --tag, --name-prefix, or a --sort-by other than 'createdAt'"));
return;
}

const requestedStatus = status ?? "running";
const statusFilter = all ? undefined : requestedStatus;
const apiStatusApplied = statusFilter !== undefined && !hasStatusConflict;

const { sandboxes, pagination } = await (async () => {
using _spinner = acquireRelease(
() => ora("Fetching sandboxes...").start(),
Expand All @@ -78,13 +107,10 @@ export const list = cmd.command({
...(sortBy && { sortBy }),
...(sortOrder && { sortOrder }),
...(Object.keys(tags).length > 0 && { tags }),
...(apiStatusApplied && { status: statusFilter }),
});
})();

const displayedSandboxes = all
? sandboxes
: sandboxes.filter((x) => x.status === "running");

const memoryFormatter = new Intl.NumberFormat(undefined, {
style: "unit",
unit: "megabyte",
Expand Down Expand Up @@ -126,7 +152,7 @@ export const list = cmd.command({
};
}

console.log(table({ rows: displayedSandboxes, columns }));
console.log(table({ rows: sandboxes, columns }));

if (pagination.next !== null) {
console.log(formatNextCursorHint(pagination.next));
Expand Down
82 changes: 82 additions & 0 deletions packages/sandbox/test/commands/list.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import * as cmd from "cmd-ts";

const { mockList } = vi.hoisted(() => ({
mockList: vi.fn(),
}));

vi.mock("../../src/client", () => ({
sandboxClient: {
fork: vi.fn(),
create: vi.fn(),
get: vi.fn(),
list: mockList,
},
snapshotClient: { get: vi.fn(), list: vi.fn(), tree: vi.fn() },
}));

vi.mock("@vercel/oidc", () => ({
getVercelOidcToken: vi.fn(),
getVercelToken: vi.fn(),
}));

vi.mock("../../src/commands/login", () => ({
login: { handler: vi.fn() },
}));

const emptyPage = { sandboxes: [], pagination: { count: 0, next: null } };

describe("list command", () => {
let errorSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
vi.clearAllMocks();
mockList.mockResolvedValue(emptyPage);
process.env.VERCEL_AUTH_TOKEN = "tok";
vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
vi.restoreAllMocks();
});

const run = async (args: string[]) => {
const { list } = await import("../../src/commands/list.ts");
return cmd.run(list, ["--scope=team", "--project=proj", ...args]);
};

test("defaults to filtering running at the API level", async () => {
await run([]);
expect(mockList).toHaveBeenCalledTimes(1);
expect(mockList.mock.calls[0][0].status).toBe("running");
});

test("forwards an explicit --status to the API", async () => {
await run(["--status", "stopped"]);
expect(mockList.mock.calls[0][0].status).toBe("stopped");
});

test("--all sends no status filter", async () => {
await run(["--all"]);
expect(mockList.mock.calls[0][0].status).toBeUndefined();
});

test("does not send a status when --tag is used", async () => {
await run(["--tag", "env=staging"]);
expect(mockList.mock.calls[0][0].status).toBeUndefined();
expect(mockList.mock.calls[0][0].tags).toEqual({ env: "staging" });
});

test("errors when --status is combined with --tag", async () => {
await run(["--status", "running", "--tag", "env=staging"]);
expect(mockList).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalled();
});

test("errors when --status is combined with --all", async () => {
await run(["--status", "running", "--all"]);
expect(mockList).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalled();
});
});
37 changes: 37 additions & 0 deletions packages/vercel-sandbox/src/api-client/api-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,43 @@ describe("APIClient", () => {
expect(url).toContain("cursor=abc");
});

it("passes the status filter", async () => {
const body = {
sandboxes: [],
pagination: { count: 0, next: null },
};
mockFetch.mockResolvedValue(
new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
}),
);

await client.listSandboxes({
projectId: "proj_123",
status: "stopped",
});

const [url] = mockFetch.mock.calls[0];
expect(url).toContain("status=stopped");
});

it("omits the status filter when not provided", async () => {
const body = {
sandboxes: [],
pagination: { count: 0, next: null },
};
mockFetch.mockResolvedValue(
new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
}),
);

await client.listSandboxes({ projectId: "proj_123" });

const [url] = mockFetch.mock.calls[0];
expect(url).not.toContain("status=");
});

it("passes sortOrder and sortBy statusUpdatedAt", async () => {
const body = {
sandboxes: [makeSandboxMetadata("sb-1")],
Expand Down
4 changes: 4 additions & 0 deletions packages/vercel-sandbox/src/api-client/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import { toAPINetworkPolicy } from "../utils/network-policy.js";
import { getPrivateParams, WithPrivate } from "../utils/types.js";
import { RUNTIMES } from "../constants.js";

export type SandboxListStatus = "running" | "stopping" | "stopped";

interface Claims {
owner_id: string;
project_id?: string;
Expand Down Expand Up @@ -843,6 +845,7 @@ export class APIClient extends BaseClient {
namePrefix?: string;
cursor?: string;
tags?: Record<string, string>;
status?: SandboxListStatus;
signal?: AbortSignal;
}) {
return parseOrThrow(
Expand All @@ -856,6 +859,7 @@ export class APIClient extends BaseClient {
namePrefix: params.namePrefix,
cursor: params.cursor,
tags: toTagsFilter(params.tags),
status: params.status,
},
method: "GET",
signal: params.signal,
Expand Down
1 change: 1 addition & 0 deletions packages/vercel-sandbox/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type { ExecutionContext } from "./execution-context.js";
export { Snapshot } from "./snapshot.js";
export type { SerializedSnapshot } from "./snapshot.js";
export type { SnapshotTreeNodeData } from "./api-client/validators.js";
export type { SandboxListStatus } from "./api-client/api-client.js";
export { Command, CommandFinished } from "./command.js";
export type {
SerializedCommand,
Expand Down
Loading