From 6bb8a02b14fac171ca912fb8ddb06b3311bffae0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 13:48:43 +0000 Subject: [PATCH] fix(create/link): fall back to default workspace when the token can't list workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interactive workspace picker calls the account-level GET /api/workspace/workspaces endpoint to decide whether to prompt. A CLI credential scoped to a single workspace (e.g. a workspace API key sent via BASE44_API_KEY, which the client prefers over the device-login token) can't call that endpoint and the server returns 403 — which previously aborted the whole create/link flow. Treat a 403 from the workspace list as 'can't enumerate workspaces': warn, and fall back to the default workspace (send no organization_id, exactly the pre-feature behavior) instead of failing. Non-403 errors still propagate. Passing --workspace continues to bypass the list entirely. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0171Y6jogaAWB1suBYaEULp1 --- .../cli/commands/project/workspace-select.ts | 52 +++++++++- .../cli/tests/cli/workspace-select.spec.ts | 96 +++++++++++++++++++ 2 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 packages/cli/tests/cli/workspace-select.spec.ts 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(); + }); +});