diff --git a/packages/cli/src/core/project/api.ts b/packages/cli/src/core/project/api.ts index f3713269..133d1d95 100644 --- a/packages/cli/src/core/project/api.ts +++ b/packages/cli/src/core/project/api.ts @@ -5,7 +5,7 @@ import { extract } from "tar"; import { base44Client, getAppClient } from "@/core/clients/index.js"; import { ApiError, SchemaValidationError } from "@/core/errors.js"; import { getAppConfig } from "@/core/project/app-config.js"; -import type { ProjectsResponse } from "@/core/project/schema.js"; +import type { ProjectsResponse, Visibility } from "@/core/project/schema.js"; import { CreateProjectResponseSchema, ProjectsResponseSchema, @@ -21,7 +21,7 @@ export async function createProject(projectName: string, description?: string) { name: projectName, user_description: description ?? `Backend for '${projectName}'`, is_managed_source_code: false, - public_settings: "public_without_login", + public_settings: "private_with_login", }, }); } catch (error) { @@ -42,6 +42,27 @@ export async function createProject(projectName: string, description?: string) { }; } +const VISIBILITY_TO_PUBLIC_SETTINGS: Record = { + public: "public_without_login", + private: "private_with_login", + workspace: "workspace_with_login", +}; + +export async function updateProjectVisibility( + visibility: Visibility, +): Promise { + const { id } = getAppConfig(); + try { + await base44Client.put(`api/apps/${id}`, { + json: { + public_settings: VISIBILITY_TO_PUBLIC_SETTINGS[visibility], + }, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "updating project visibility"); + } +} + export async function listProjects(): Promise { let response: KyResponse; try { diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index adcb6f5e..51963136 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -1,4 +1,5 @@ import { resolve } from "node:path"; +import { updateProjectVisibility } from "@/core/project/api.js"; import type { ProjectData } from "@/core/project/types.js"; import { agentResource } from "@/core/resources/agent/index.js"; import { @@ -69,6 +70,10 @@ export async function deployAll( await agentResource.push(agents); const { results: connectorResults } = await pushConnectors(connectors); + if (project.visibility) { + await updateProjectVisibility(project.visibility); + } + if (project.site?.outputDirectory) { const outputDir = resolve(project.root, project.site.outputDirectory); const { appUrl } = await deploySite(outputDir); diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 60bf4a01..280ab7ec 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -19,6 +19,13 @@ const SiteConfigSchema = z.object({ installCommand: z.string().optional(), }); +export const VisibilitySchema = z.enum(["public", "private", "workspace"], { + error: + 'Invalid visibility value. Allowed values: "public", "private", "workspace"', +}); + +export type Visibility = z.infer; + export const ProjectConfigSchema = z.object({ name: z .string({ @@ -26,6 +33,7 @@ export const ProjectConfigSchema = z.object({ }) .min(1, "App name cannot be empty"), description: z.string().optional(), + visibility: VisibilitySchema.optional(), site: SiteConfigSchema.optional(), entitiesDir: z.string().optional().default("entities"), functionsDir: z.string().optional().default("functions"), diff --git a/packages/cli/tests/cli/deploy.spec.ts b/packages/cli/tests/cli/deploy.spec.ts index bd59390d..abd45bd0 100644 --- a/packages/cli/tests/cli/deploy.spec.ts +++ b/packages/cli/tests/cli/deploy.spec.ts @@ -209,3 +209,64 @@ describe("deploy command (unified)", () => { t.expectResult(result).toContain("Connectors dashboard"); }); }); + +describe("deploy command — visibility sync", () => { + const t = setupCLITests(); + + it('sends PUT to update visibility when config has visibility: "public"', async () => { + // Given + await t.givenLoggedInWithProject(fixture("with-visibility")); + t.api.mockEntitiesPush({ created: ["Item"], updated: [], deleted: [] }); + t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockUpdateAppVisibility({ + id: "test-app-id", + public_settings: "public_without_login", + }); + + // When + const result = await t.run("deploy", "-y"); + + // Then + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("App deployed successfully"); + }); + + it("fails deploy when visibility PUT returns an API error", async () => { + // Given + await t.givenLoggedInWithProject(fixture("with-visibility")); + t.api.mockEntitiesPush({ created: ["Item"], updated: [], deleted: [] }); + t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockUpdateAppVisibilityError({ + status: 403, + body: { error: "Forbidden" }, + }); + + // When + const result = await t.run("deploy", "-y"); + + // Then + t.expectResult(result).toFail(); + }); + + it("does not send PUT when visibility is omitted from config", async () => { + // Given — use fixture without visibility (with-entities has no visibility field) + await t.givenLoggedInWithProject(fixture("with-entities")); + t.api.mockEntitiesPush({ + created: ["Customer", "Product"], + updated: [], + deleted: [], + }); + t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); + t.api.mockConnectorsList({ integrations: [] }); + // No mockUpdateAppVisibility registered — PUT must NOT be called + + // When + const result = await t.run("deploy", "-y"); + + // Then + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("App deployed successfully"); + }); +}); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 2cabf4f7..a3712084 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -204,6 +204,11 @@ interface ListProjectsResponse { is_managed_source_code?: boolean; } +interface UpdateAppVisibilityResponse { + id: string; + public_settings: string; +} + interface ErrorResponse { status: number; body?: unknown; @@ -211,7 +216,7 @@ interface ErrorResponse { // ─── ROUTE HANDLER TYPES ───────────────────────────────────── -type Method = "GET" | "POST" | "PUT" | "DELETE"; +type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; interface RouteEntry { method: Method; @@ -289,6 +294,7 @@ export class TestAPIServer { | "get" | "post" | "put" + | "patch" | "delete"; this.app[method](entry.path, entry.handler); } @@ -517,6 +523,16 @@ export class TestAPIServer { // ─── GENERAL ENDPOINTS ─────────────────────────────────── + /** Mock PUT /api/apps/{appId} - Update app visibility */ + mockUpdateAppVisibility(response: UpdateAppVisibilityResponse): this { + return this.addRoute("PUT", `/api/apps/${this.appId}`, response); + } + + /** Mock PUT /api/apps/{appId} - Update app visibility error */ + mockUpdateAppVisibilityError(error: ErrorResponse): this { + return this.addErrorRoute("PUT", `/api/apps/${this.appId}`, error); + } + mockCreateApp(response: CreateAppResponse): this { return this.addRoute("POST", "/api/apps", response); } diff --git a/packages/cli/tests/core/project-schema.spec.ts b/packages/cli/tests/core/project-schema.spec.ts new file mode 100644 index 00000000..4ebfe012 --- /dev/null +++ b/packages/cli/tests/core/project-schema.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { ProjectConfigSchema } from "@/core/project/schema.js"; + +describe("ProjectConfigSchema visibility field", () => { + it('accepts visibility: "public"', () => { + const result = ProjectConfigSchema.safeParse({ + name: "My App", + visibility: "public", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.visibility).toBe("public"); + } + }); + + it('accepts visibility: "private"', () => { + const result = ProjectConfigSchema.safeParse({ + name: "My App", + visibility: "private", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.visibility).toBe("private"); + } + }); + + it("accepts omitted visibility (no-op, field is undefined)", () => { + const result = ProjectConfigSchema.safeParse({ name: "My App" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.visibility).toBeUndefined(); + } + }); + + it('accepts visibility: "workspace"', () => { + const result = ProjectConfigSchema.safeParse({ + name: "My App", + visibility: "workspace", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.visibility).toBe("workspace"); + } + }); + + it("rejects invalid visibility value with a clear error", () => { + const result = ProjectConfigSchema.safeParse({ + name: "My App", + visibility: "restricted", + }); + expect(result.success).toBe(false); + if (!result.success) { + const message = result.error.issues[0]?.message ?? ""; + expect(message).toMatch( + /Invalid visibility value|Invalid enum value|public.*private/i, + ); + } + }); +}); diff --git a/packages/cli/tests/fixtures/with-visibility/base44/.app.jsonc b/packages/cli/tests/fixtures/with-visibility/base44/.app.jsonc new file mode 100644 index 00000000..d7852426 --- /dev/null +++ b/packages/cli/tests/fixtures/with-visibility/base44/.app.jsonc @@ -0,0 +1,4 @@ +// Base44 App Configuration +{ + "id": "test-app-id" +} diff --git a/packages/cli/tests/fixtures/with-visibility/base44/config.jsonc b/packages/cli/tests/fixtures/with-visibility/base44/config.jsonc new file mode 100644 index 00000000..07a3d81e --- /dev/null +++ b/packages/cli/tests/fixtures/with-visibility/base44/config.jsonc @@ -0,0 +1,4 @@ +{ + "name": "Visibility Test Project", + "visibility": "public" +} diff --git a/packages/cli/tests/fixtures/with-visibility/base44/entities/Item.json b/packages/cli/tests/fixtures/with-visibility/base44/entities/Item.json new file mode 100644 index 00000000..f9abb57e --- /dev/null +++ b/packages/cli/tests/fixtures/with-visibility/base44/entities/Item.json @@ -0,0 +1,11 @@ +{ + "name": "Item", + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Item title" + } + }, + "required": ["title"] +}