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
128 changes: 89 additions & 39 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 @@ -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<string> {
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<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",
},
);

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,
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -293,7 +343,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
11 changes: 9 additions & 2 deletions 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 All @@ -112,7 +117,9 @@ export async function getApp(appId: string): Promise<AppDetail> {
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");
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/core/project/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof AppDetailSchema>;
Expand Down
79 changes: 58 additions & 21 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 @@ -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");

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

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 @@ -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 });
Expand Down
Loading