Skip to content
Open
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
52 changes: 47 additions & 5 deletions packages/cli/src/cli/commands/project/workspace-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,45 @@ import type { Option as PromptOption } from "@clack/prompts";
import { isCancel, select } from "@clack/prompts";
import type { CLIContext } from "@/cli/types.js";
import { onPromptCancel } from "@/cli/utils/index.js";
import { ApiError } from "@/core/errors.js";
import { listWorkspaces, type WorkspaceListEntry } from "@/core/index.js";

function fetchWorkspaces(ctx: CLIContext): Promise<WorkspaceListEntry[]> {
return ctx.runTask("Fetching workspaces...", () => listWorkspaces(), {
successMessage: "Workspaces fetched",
errorMessage: "Failed to fetch workspaces",
});
/**
* A CLI credential that is scoped to a single workspace (e.g. a workspace API
* key, or a login completed inside a workspace) can't call the account-level
* `GET /api/workspace/workspaces` endpoint — the server rejects it with a 403.
* The workspace picker is only a convenience, so we treat this as "can't list"
* and fall back to the default workspace rather than failing the command.
*/
function isWorkspaceListForbidden(error: unknown): boolean {
return error instanceof ApiError && error.statusCode === 403;
}

/**
* Fetch the workspaces the user belongs to, or `null` when the current
* credential isn't allowed to list them (see {@link isWorkspaceListForbidden}).
*/
function fetchWorkspaces(
ctx: CLIContext,
): Promise<WorkspaceListEntry[] | null> {
return ctx.runTask(
"Fetching workspaces...",
async (updateMessage) => {
try {
return await listWorkspaces();
} catch (error) {
if (isWorkspaceListForbidden(error)) {
updateMessage("Using your default workspace");
return null;
}
throw error;
}
},
{
successMessage: "Workspaces checked",
errorMessage: "Failed to fetch workspaces",
},
);
}

function workspaceLabel(workspace: WorkspaceListEntry): string {
Expand All @@ -25,6 +57,8 @@ function workspaceLabel(workspace: WorkspaceListEntry): string {
* creation in that workspace and returns a clear error if you can't.
* - interactive with more than one workspace: prompt to pick (no role filter;
* personal first). The server rejects a workspace you can't create in.
* - interactive but the credential can't list workspaces (workspace-scoped):
* warn and fall back to the default workspace; `--workspace <id>` still works.
* - otherwise: return `undefined` so the server defaults to the personal
* workspace (no extra API call in the common single-workspace case).
*/
Expand All @@ -42,6 +76,14 @@ export async function resolveWorkspaceId(
}

const workspaces = await fetchWorkspaces(ctx);
if (workspaces === null) {
// Credential can't list workspaces (scoped to one). Use the default
// workspace, and point the user at --workspace for a specific one.
ctx.log.warn(
"Couldn't list your workspaces with this login, so the default workspace will be used. Pass --workspace <id> to target a specific one.",
);
return undefined;
}
if (workspaces.length <= 1) {
// Only the personal workspace — nothing to choose.
return undefined;
Expand Down
96 changes: 96 additions & 0 deletions packages/cli/tests/cli/workspace-select.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveWorkspaceId } from "../../src/cli/commands/project/workspace-select.js";
import type { CLIContext } from "../../src/cli/types.js";
import { ApiError } from "../../src/core/errors.js";
import { listWorkspaces } from "../../src/core/workspace/api.js";

vi.mock("../../src/core/workspace/api.js");

const listWorkspacesMock = vi.mocked(listWorkspaces);

function makeContext(): { ctx: CLIContext; warn: ReturnType<typeof vi.fn> } {
const warn = vi.fn();
const ctx = {
isNonInteractive: false,
jsonMode: false,
distribution: "npm",
log: {
info: vi.fn(),
success: vi.fn(),
warn,
error: vi.fn(),
step: vi.fn(),
message: vi.fn(),
},
// Execute the task body directly with a no-op message updater.
runTask: (async (_start, op) => op(() => {})) as CLIContext["runTask"],
} as unknown as CLIContext;
return { ctx, warn };
}

describe("resolveWorkspaceId", () => {
beforeEach(() => {
vi.resetAllMocks();
});

it("passes an explicit --workspace id straight through without an API call", async () => {
const { ctx } = makeContext();

const result = await resolveWorkspaceId(ctx, "ws-123", true);

expect(result).toBe("ws-123");
expect(listWorkspacesMock).not.toHaveBeenCalled();
});

it("returns undefined without listing workspaces in non-interactive mode", async () => {
const { ctx } = makeContext();

const result = await resolveWorkspaceId(ctx, undefined, false);

expect(result).toBeUndefined();
expect(listWorkspacesMock).not.toHaveBeenCalled();
});

it("falls back to the default workspace when the token can't list workspaces (403)", async () => {
const { ctx, warn } = makeContext();
listWorkspacesMock.mockRejectedValue(
new ApiError("Error listing workspaces: forbidden", {
statusCode: 403,
}),
);

const result = await resolveWorkspaceId(ctx, undefined, true);

expect(result).toBeUndefined();
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain("--workspace");
});

it("rethrows non-403 errors instead of silently falling back", async () => {
const { ctx } = makeContext();
listWorkspacesMock.mockRejectedValue(
new ApiError("Error listing workspaces: server error", {
statusCode: 500,
}),
);

await expect(resolveWorkspaceId(ctx, undefined, true)).rejects.toThrow(
/server error/,
);
});

it("returns undefined when the user has only their personal workspace", async () => {
const { ctx } = makeContext();
listWorkspacesMock.mockResolvedValue([
{
id: "personal",
name: "Personal",
isPersonal: true,
} as Awaited<ReturnType<typeof listWorkspaces>>[number],
]);

const result = await resolveWorkspaceId(ctx, undefined, true);

expect(result).toBeUndefined();
});
});
Loading