diff --git a/packages/cli/src/cli/commands/project/link.ts b/packages/cli/src/cli/commands/project/link.ts index cb31e920..d9a5de4a 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, @@ -134,6 +136,76 @@ async function promptForExistingProject(projects: Project[]): Promise { 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. Editor-created (managed-source) apps are linkable too (#577). + */ +async function resolveExplicitAppId( + ctx: CLIContext, + appId: string, +): Promise { + 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; + }); + + 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 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", + }, + ); + + if (!projects.length) { + return undefined; + } + + const selectedProject = await promptForExistingProject(projects); + return selectedProject.id; +} + async function link( ctx: CLIContext, options: LinkOptions, @@ -179,40 +251,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); - if (!projects.length) { + if (!linkedAppId) { return { outroMessage: "No projects available for linking" }; } - let linkedAppId: string; - - if (appId) { - const project = projects.find((p) => p.id === appId); - if (!project) { - throw new InvalidInputError(`App with ID "${appId}" not found.`, { - 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(projects); - linkedAppId = selectedProject.id; - } - await runTask( "Linking project...", async () => { @@ -281,7 +329,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 2997f405..adaf5934 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 48ba5b55..3f59abc9 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) { diff --git a/packages/cli/tests/cli/link.spec.ts b/packages/cli/tests/cli/link.spec.ts index ca6be2e2..b02695aa 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"); @@ -67,13 +65,11 @@ describe("link command", () => { it("links an editor-created app (managed source code)", async () => { await t.givenLoggedInWithProject(fixture("no-app-config")); - t.api.mockListProjects([ - { - id: "editor-app-id", - name: "Editor App", - is_managed_source_code: true, - }, - ]); + t.api.mockGetApp({ + id: "editor-app-id", + name: "Editor App", + is_managed_source_code: true, + }); const result = await t.run("link", "--app-id", "editor-app-id"); @@ -82,15 +78,42 @@ describe("link command", () => { t.expectResult(result).toContain("editor-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("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"); @@ -101,13 +124,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 d210e5e2..7fa078c7 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -593,6 +593,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 });