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
110 changes: 79 additions & 31 deletions packages/cli/src/cli/commands/project/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
theme,
} from "@/cli/utils/index.js";
import {
ApiError,
ConfigExistsError,
ConfigNotFoundError,
InvalidInputError,
Expand All @@ -19,6 +20,7 @@ import {
appConfigExists,
createProject,
findProjectRoot,
getApp,
listProjects,
setAppContext,
writeAppConfig,
Expand Down Expand Up @@ -134,6 +136,76 @@ async function promptForExistingProject(projects: Project[]): Promise<Project> {
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<string> {
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<string | undefined> {
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,
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -281,7 +329,7 @@ export function getLinkCommand(): Command {
.option("-d, --description <description>", "Project description")
.option(
"-w, --workspace <id>",
"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 <id>", "Alias for --workspace").hideHelp(),
Expand Down
18 changes: 11 additions & 7 deletions packages/cli/src/cli/commands/project/workspace-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,22 @@ 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 <id>` set: pass it straight throughthe server authorizes
* creation in that workspace and returns a clear error if you can't.
* - `--workspace <id>` 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).
*/
export async function resolveWorkspaceId(
ctx: CLIContext,
flagWorkspaceId: string | undefined,
isInteractive: boolean,
options: { promptMessage?: string } = {},
): Promise<string | undefined> {
if (flagWorkspaceId) {
return flagWorkspaceId;
Expand All @@ -47,14 +50,15 @@ export async function resolveWorkspaceId(
return undefined;
}

const options: PromptOption<string>[] = workspaces.map((w) => ({
const promptOptions: PromptOption<string>[] = 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,
});

Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/core/project/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,19 @@ export async function setAppVisibility(
}
}

export async function listProjects(): Promise<ProjectsResponse> {
export async function listProjects(
options: { workspaceId?: string } = {},
): Promise<ProjectsResponse> {
let response: KyResponse;
try {
response = await base44Client.get("api/apps", {
searchParams: {
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) {
Expand Down
77 changes: 49 additions & 28 deletions packages/cli/tests/cli/link.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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");

Expand All @@ -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");

Expand All @@ -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");

Expand Down
11 changes: 11 additions & 0 deletions packages/cli/tests/cli/testkit/TestAPIServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading