From 9beb9a4a7eedff56d00cb7291fdb895537623552 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 14:01:35 +0000 Subject: [PATCH] link: two-step workspace picker + cross-workspace --app-id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `link` existing-app picker was scoped to the caller's personal workspace (the server defaults app listing to the active workspace), so apps in other workspaces were unreachable — and `--app-id` for such an app was rejected by the personal-scoped pre-validation. - Interactive "link existing" now picks a workspace first (skipped when you belong to only one), then lists apps scoped to that workspace via the new listProjects({ workspaceId }) (workspace_id query param). - `--workspace ` scopes that picker (in addition to its --create meaning). - `--app-id` is now validated by fetching the app directly (getApp), so it links any app you can access regardless of workspace; managed-source apps are rejected with a clear message, and unknown/inaccessible ids get a friendly "not found" error. Adds resolveListingWorkspaceId (all memberships, since linking only reads), is_managed_source_code on getApp/AppDetail, and link specs for the cross-workspace, managed-source, and not-found paths. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0171Y6jogaAWB1suBYaEULp1 --- packages/cli/src/cli/commands/project/link.ts | 128 ++++++++++++------ .../cli/commands/project/workspace-select.ts | 18 ++- packages/cli/src/core/project/api.ts | 11 +- packages/cli/src/core/project/schema.ts | 2 + packages/cli/tests/cli/link.spec.ts | 79 ++++++++--- .../cli/tests/cli/testkit/TestAPIServer.ts | 11 ++ 6 files changed, 180 insertions(+), 69 deletions(-) diff --git a/packages/cli/src/cli/commands/project/link.ts b/packages/cli/src/cli/commands/project/link.ts index ecf7594d6..869ba3daa 100644 --- a/packages/cli/src/cli/commands/project/link.ts +++ b/packages/cli/src/cli/commands/project/link.ts @@ -10,6 +10,7 @@ import { theme, } from "@/cli/utils/index.js"; import { + ApiError, ConfigExistsError, ConfigNotFoundError, InvalidInputError, @@ -19,6 +20,7 @@ import { appConfigExists, createProject, findProjectRoot, + getApp, listProjects, setAppContext, writeAppConfig, @@ -138,6 +140,86 @@ async function promptForExistingProject( return selectedProject; } +/** + * Validate an explicit `--app-id` by fetching the app directly, so linking + * works for any app the user can access — not just ones in their personal + * workspace. Rejects managed-source apps (not linkable via the CLI). + */ +async function resolveExplicitAppId( + ctx: CLIContext, + appId: string, +): Promise { + const app = await ctx + .runTask("Validating app...", () => getApp(appId), { + errorMessage: "Could not validate app", + }) + .catch((error) => { + if ( + error instanceof ApiError && + (error.statusCode === 404 || error.statusCode === 403) + ) { + throw new InvalidInputError( + `App "${appId}" not found, or you don't have access to it.`, + { + hints: [ + { message: "Check the app ID is correct" }, + { + message: + "Run 'base44 link' without --app-id to browse apps by workspace", + }, + ], + }, + ); + } + throw error; + }); + + if (app.isManagedSourceCode) { + throw new InvalidInputError( + `App "${appId}" is a managed-source app and can't be linked with the CLI.`, + ); + } + + return appId; +} + +/** + * Interactive existing-app selection: pick a workspace (skipped when you're in + * only one), then pick an app scoped to that workspace. Returns undefined when + * the chosen workspace has no linkable apps. + */ +async function chooseProjectInteractively( + ctx: CLIContext, + options: LinkOptions, +): Promise { + const workspaceId = await resolveWorkspaceId( + ctx, + options.workspace ?? options.org, + !ctx.isNonInteractive, + { promptMessage: "Which workspace is the app in?" }, + ); + + const projects = await ctx.runTask( + "Fetching projects...", + () => listProjects({ workspaceId }), + { + successMessage: "Projects fetched", + errorMessage: "Failed to fetch projects", + }, + ); + + const linkableProjects = projects.filter( + (p) => p.isManagedSourceCode !== true, + ); + + if (!linkableProjects.length) { + return undefined; + } + + const selectedProject = await promptForExistingProject(linkableProjects); + return selectedProject.id; +} + async function link( ctx: CLIContext, options: LinkOptions, @@ -183,48 +265,16 @@ async function link( : await promptForLinkAction(); if (action === "choose") { - const projects = await runTask( - "Fetching projects...", - async () => listProjects(), - { - successMessage: "Projects fetched", - errorMessage: "Failed to fetch projects", - }, - ); + // Explicit --app-id links any app you can access (validated directly), + // regardless of workspace. Otherwise pick a workspace, then an app in it. + const linkedAppId = appId + ? await resolveExplicitAppId(ctx, appId) + : await chooseProjectInteractively(ctx, options); - const linkableProjects = projects.filter( - (p) => p.isManagedSourceCode !== true, - ); - - if (!linkableProjects.length) { + if (!linkedAppId) { return { outroMessage: "No projects available for linking" }; } - let linkedAppId: string; - - if (appId) { - // Validate that the provided app ID exists and is linkable - const project = linkableProjects.find((p) => p.id === appId); - if (!project) { - throw new InvalidInputError( - `App with ID "${appId}" not found or not available for linking.`, - { - hints: [ - { message: "Check the app ID is correct" }, - { - message: - "Use 'base44 link' without --app-id to see available projects", - }, - ], - }, - ); - } - linkedAppId = appId; - } else { - const selectedProject = await promptForExistingProject(linkableProjects); - linkedAppId = selectedProject.id; - } - await runTask( "Linking project...", async () => { @@ -293,7 +343,7 @@ export function getLinkCommand(): Command { .option("-d, --description ", "Project description") .option( "-w, --workspace ", - "Workspace (organization) ID to create the app in when using --create (defaults to your personal workspace)", + "Workspace (organization) ID to scope the app picker to (or create the app in, with --create). Defaults to your personal workspace", ) .addOption( new CommanderOption("--org ", "Alias for --workspace").hideHelp(), diff --git a/packages/cli/src/cli/commands/project/workspace-select.ts b/packages/cli/src/cli/commands/project/workspace-select.ts index 2997f4058..adaf59340 100644 --- a/packages/cli/src/cli/commands/project/workspace-select.ts +++ b/packages/cli/src/cli/commands/project/workspace-select.ts @@ -19,12 +19,14 @@ function workspaceLabel(workspace: WorkspaceListEntry): string { } /** - * Resolve the workspace a new app should belong to. + * Resolve which workspace a command should target (creating an app in it, or + * scoping the app list for `link`). The server is the source of truth for + * permissions — the CLI never filters or validates by role: * - * - `--workspace ` set: pass it straight through — the server authorizes - * creation in that workspace and returns a clear error if you can't. + * - `--workspace ` set: pass it straight through; the server returns a clear + * error if you can't use it. * - interactive with more than one workspace: prompt to pick (no role filter; - * personal first). The server rejects a workspace you can't create in. + * personal first). * - otherwise: return `undefined` so the server defaults to the personal * workspace (no extra API call in the common single-workspace case). */ @@ -32,6 +34,7 @@ export async function resolveWorkspaceId( ctx: CLIContext, flagWorkspaceId: string | undefined, isInteractive: boolean, + options: { promptMessage?: string } = {}, ): Promise { if (flagWorkspaceId) { return flagWorkspaceId; @@ -47,14 +50,15 @@ export async function resolveWorkspaceId( return undefined; } - const options: PromptOption[] = workspaces.map((w) => ({ + const promptOptions: PromptOption[] = workspaces.map((w) => ({ value: w.id, label: workspaceLabel(w), })); const selected = await select({ - message: "Which workspace should this app belong to?", - options, + message: + options.promptMessage ?? "Which workspace should this app belong to?", + options: promptOptions, initialValue: workspaces[0].id, }); diff --git a/packages/cli/src/core/project/api.ts b/packages/cli/src/core/project/api.ts index 48ba5b551..87a02619b 100644 --- a/packages/cli/src/core/project/api.ts +++ b/packages/cli/src/core/project/api.ts @@ -78,7 +78,9 @@ export async function setAppVisibility( } } -export async function listProjects(): Promise { +export async function listProjects( + options: { workspaceId?: string } = {}, +): Promise { let response: KyResponse; try { response = await base44Client.get("api/apps", { @@ -86,6 +88,9 @@ export async function listProjects(): Promise { sort: "-updated_date", fields: "id,name,user_description,is_managed_source_code", limit: 50, + // Scope to a specific workspace when given; otherwise the server + // scopes to the caller's active/personal workspace. + ...(options.workspaceId ? { workspace_id: options.workspaceId } : {}), }, }); } catch (error) { @@ -112,7 +117,9 @@ export async function getApp(appId: string): Promise { let response: KyResponse; try { response = await base44Client.get(`api/apps/${appId}`, { - searchParams: { fields: "id,name,organization_id" }, + searchParams: { + fields: "id,name,organization_id,is_managed_source_code", + }, }); } catch (error) { throw await ApiError.fromHttpError(error, "fetching app"); diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 451ed9091..d01177f21 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -73,11 +73,13 @@ export const AppDetailSchema = z id: z.string(), name: z.string().optional(), organization_id: z.string().nullish(), + is_managed_source_code: z.boolean().optional(), }) .transform((data) => ({ id: data.id, name: data.name, organizationId: data.organization_id ?? undefined, + isManagedSourceCode: data.is_managed_source_code, })); export type AppDetail = z.infer; diff --git a/packages/cli/tests/cli/link.spec.ts b/packages/cli/tests/cli/link.spec.ts index 7af9d9a76..7e96d6f52 100644 --- a/packages/cli/tests/cli/link.spec.ts +++ b/packages/cli/tests/cli/link.spec.ts @@ -44,13 +44,11 @@ describe("link command", () => { it("links an existing app with --app-id", async () => { await t.givenLoggedInWithProject(fixture("no-app-config")); - t.api.mockListProjects([ - { - id: "existing-app-id", - name: "Existing App", - is_managed_source_code: false, - }, - ]); + t.api.mockGetApp({ + id: "existing-app-id", + name: "Existing App", + is_managed_source_code: false, + }); const result = await t.run("link", "--app-id", "existing-app-id"); @@ -65,15 +63,56 @@ describe("link command", () => { expect(appConfig).toContain("existing-app-id"); }); + it("links an app that lives in another workspace via --app-id", async () => { + await t.givenLoggedInWithProject(fixture("no-app-config")); + // An app whose organization_id is a workspace other than the caller's + // personal one — previously unreachable because the picker was + // personal-scoped. Now validated directly via getApp. + t.api.mockGetApp({ + id: "other-ws-app", + name: "Team App", + organization_id: "ws-team", + is_managed_source_code: false, + }); + + const result = await t.run("link", "--app-id", "other-ws-app"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Project linked"); + t.expectResult(result).toContain("other-ws-app"); + }); + + it("rejects a managed-source app via --app-id", async () => { + await t.givenLoggedInWithProject(fixture("no-app-config")); + t.api.mockGetApp({ + id: "managed-app", + name: "Managed App", + is_managed_source_code: true, + }); + + const result = await t.run("link", "--app-id", "managed-app"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("managed-source"); + }); + + it("fails with a clear message when --app-id is not found", async () => { + await t.givenLoggedInWithProject(fixture("no-app-config")); + // No mockGetApp registered → server 404. + + const result = await t.run("link", "--app-id", "does-not-exist"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("not found"); + }); + it("links an existing app with legacy --project-id", async () => { await t.givenLoggedInWithProject(fixture("no-app-config")); - t.api.mockListProjects([ - { - id: "legacy-app-id", - name: "Legacy App", - is_managed_source_code: false, - }, - ]); + t.api.mockGetApp({ + id: "legacy-app-id", + name: "Legacy App", + is_managed_source_code: false, + }); const result = await t.run("link", "--project-id", "legacy-app-id"); @@ -84,13 +123,11 @@ describe("link command", () => { it("links an existing app with legacy --projectId", async () => { await t.givenLoggedInWithProject(fixture("no-app-config")); - t.api.mockListProjects([ - { - id: "camel-legacy-app-id", - name: "Camel Legacy App", - is_managed_source_code: false, - }, - ]); + t.api.mockGetApp({ + id: "camel-legacy-app-id", + name: "Camel Legacy App", + is_managed_source_code: false, + }); const result = await t.run("link", "--projectId", "camel-legacy-app-id"); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index a6fd52655..76687c3d1 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -540,6 +540,17 @@ export class TestAPIServer { return this.addRoute("GET", "/api/apps", response); } + /** Mock GET /api/apps/{appId} - Fetch a single app's details. Keyed off the + * response's own `id`, so tests can mock apps other than the context app. */ + mockGetApp(response: { + id: string; + name?: string; + organization_id?: string | null; + is_managed_source_code?: boolean; + }): this { + return this.addRoute("GET", `/api/apps/${response.id}`, response); + } + /** Mock GET /api/workspace/workspaces - List the user's workspaces */ mockListWorkspaces(workspaces: WorkspaceResponse[]): this { return this.addRoute("GET", "/api/workspace/workspaces", { workspaces });