diff --git a/packages/cli/src/cli/commands/project/workspace-select.ts b/packages/cli/src/cli/commands/project/workspace-select.ts index 2997f405..7bcd8e46 100644 --- a/packages/cli/src/cli/commands/project/workspace-select.ts +++ b/packages/cli/src/cli/commands/project/workspace-select.ts @@ -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 { - 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 { + 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 { @@ -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 ` still works. * - otherwise: return `undefined` so the server defaults to the personal * workspace (no extra API call in the common single-workspace case). */ @@ -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 to target a specific one.", + ); + return undefined; + } if (workspaces.length <= 1) { // Only the personal workspace — nothing to choose. return undefined; diff --git a/packages/cli/tests/cli/workspace-select.spec.ts b/packages/cli/tests/cli/workspace-select.spec.ts new file mode 100644 index 00000000..e6868c5d --- /dev/null +++ b/packages/cli/tests/cli/workspace-select.spec.ts @@ -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 } { + 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>[number], + ]); + + const result = await resolveWorkspaceId(ctx, undefined, true); + + expect(result).toBeUndefined(); + }); +});