Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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: 34 additions & 4 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. Ignored with --all.",
Comment thread
marc-vercel marked this conversation as resolved.
Outdated
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,26 @@ 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 statusFilter = all ? undefined : status ?? "running";
const apiStatusApplied = statusFilter !== undefined && !hasStatusConflict;

const { sandboxes, pagination } = await (async () => {
using _spinner = acquireRelease(
() => ora("Fetching sandboxes...").start(),
Expand All @@ -78,12 +106,14 @@ 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 displayedSandboxes =
statusFilter !== undefined && !apiStatusApplied
? sandboxes.filter((x) => x.status === statusFilter)
: sandboxes;

const memoryFormatter = new Intl.NumberFormat(undefined, {
style: "unit",
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, filtering client-side instead", 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