diff --git a/CHANGELOG.md b/CHANGELOG.md index dc39a156..bc7e9bdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Added + +- App visibility: `base44 visibility ` sets it on the server directly (accepts `--app-id` to target any app). Also configurable via `"visibility"` in `config.jsonc`, which `base44 deploy` applies. New projects scaffold `"visibility": "public"`. + ## [0.0.51] - 2026-04-28 ### Added diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index ad07fa79..3c07334c 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -73,6 +73,9 @@ export async function deployAction( if (authConfig.length > 0) { summaryLines.push(" - Auth config"); } + if (project.visibility) { + summaryLines.push(` - Visibility: ${project.visibility}`); + } if (project.site?.outputDirectory) { summaryLines.push(` - Site from ${project.site.outputDirectory}`); } diff --git a/packages/cli/src/cli/commands/project/visibility.ts b/packages/cli/src/cli/commands/project/visibility.ts new file mode 100644 index 00000000..d76f1151 --- /dev/null +++ b/packages/cli/src/cli/commands/project/visibility.ts @@ -0,0 +1,29 @@ +import { Argument, type Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { setAppVisibility } from "@/core/project/api.js"; +import { VISIBILITY_LEVELS, type Visibility } from "@/core/project/schema.js"; + +async function setVisibility( + { runTask }: CLIContext, + level: Visibility, +): Promise { + await runTask(`Setting app visibility to ${level}`, () => + setAppVisibility(level), + ); + + return { outroMessage: `App visibility set to ${level}` }; +} + +export function getVisibilityCommand(): Command { + return new Base44Command("visibility") + .description( + "Set the app's visibility on the server (public, private, or workspace)", + ) + .addArgument( + new Argument("", "Visibility level").choices([ + ...VISIBILITY_LEVELS, + ]), + ) + .action(setVisibility); +} diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index eabf27f9..eb719924 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -13,6 +13,7 @@ import { getDeployCommand } from "@/cli/commands/project/deploy.js"; import { getLinkCommand } from "@/cli/commands/project/link.js"; import { getLogsCommand } from "@/cli/commands/project/logs.js"; import { getScaffoldCommand } from "@/cli/commands/project/scaffold.js"; +import { getVisibilityCommand } from "@/cli/commands/project/visibility.js"; import { getSecretsCommand } from "@/cli/commands/secrets/index.js"; import { getSiteCommand } from "@/cli/commands/site/index.js"; import { getTypesCommand } from "@/cli/commands/types/index.js"; @@ -60,6 +61,7 @@ export function createProgram(context: CLIContext): Command { program.addCommand(getScaffoldCommand()); program.addCommand(getDashboardCommand()); program.addCommand(getDeployCommand()); + program.addCommand(getVisibilityCommand()); program.addCommand(getLinkCommand()); program.addCommand(getEjectCommand()); diff --git a/packages/cli/src/core/errors.ts b/packages/cli/src/core/errors.ts index 5a358842..6c91c214 100644 --- a/packages/cli/src/core/errors.ts +++ b/packages/cli/src/core/errors.ts @@ -417,6 +417,11 @@ export class ApiError extends SystemError { }, ]; } + // Remaining 4xx are client errors, not connectivity problems, so the network + // hint would mislead. Reserve it for connection failures and 5xx server faults. + if (statusCode && statusCode < 500) { + return []; + } return [{ message: "Check your network connection and try again" }]; } diff --git a/packages/cli/src/core/project/api.ts b/packages/cli/src/core/project/api.ts index 75f6fec1..150c2c7b 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 { getAppContext } 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, @@ -13,6 +13,12 @@ import { import { PublishedUrlResponseSchema } from "@/core/site/schema.js"; import { makeDirectory } from "@/core/utils/fs.js"; +const PUBLIC_SETTINGS: Record = { + public: "public_without_login", + private: "private_with_login", + workspace: "workspace_with_login", +}; + export async function createProject(projectName: string, description?: string) { let response: KyResponse; try { @@ -21,7 +27,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: PUBLIC_SETTINGS.public, }, }); } catch (error) { @@ -42,6 +48,24 @@ export async function createProject(projectName: string, description?: string) { }; } +/** + * Applies the app's visibility via the backend. No-op when visibility is unset, + * so callers (e.g. deploy) don't need to guard the call themselves. + */ +export async function setAppVisibility( + visibility: Visibility | undefined, +): Promise { + if (!visibility) return; + const { id } = getAppContext(); + try { + await base44Client.put(`api/apps/${id}`, { + json: { public_settings: PUBLIC_SETTINGS[visibility] }, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "updating app 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 68089173..098cbd64 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 { setAppVisibility } from "@/core/project/api.js"; import type { ProjectData } from "@/core/project/types.js"; import { agentResource } from "@/core/resources/agent/index.js"; import { authConfigResource } from "@/core/resources/auth-config/index.js"; @@ -28,6 +29,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { const hasAgents = agents.length > 0; const hasConnectors = connectors.length > 0; const hasAuthConfig = authConfig.length > 0; + const hasVisibility = Boolean(project.visibility); return ( hasEntities || @@ -35,6 +37,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { hasAgents || hasConnectors || hasAuthConfig || + hasVisibility || hasSite ); } @@ -72,6 +75,7 @@ export async function deployAll( const { project, entities, functions, agents, connectors, authConfig } = projectData; + await setAppVisibility(project.visibility); await entityResource.push(entities); await deployFunctionsSequentially(functions, { onStart: options?.onFunctionStart, diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 22938a99..0fb46f3b 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -35,6 +35,9 @@ export const PluginReferenceSchema = z.object({ export type PluginReference = z.infer; +export const VISIBILITY_LEVELS = ["public", "private", "workspace"] as const; +export type Visibility = (typeof VISIBILITY_LEVELS)[number]; + export const ProjectConfigSchema = z.object({ name: z .string({ @@ -42,6 +45,7 @@ export const ProjectConfigSchema = z.object({ }) .min(1, "App name cannot be empty"), description: z.string().optional(), + visibility: z.enum(VISIBILITY_LEVELS).optional(), site: SiteConfigSchema.optional(), entitiesDir: z.string().optional().default("entities"), functionsDir: z.string().optional().default("functions"), diff --git a/packages/cli/templates/backend-and-client/base44/config.jsonc.ejs b/packages/cli/templates/backend-and-client/base44/config.jsonc.ejs index d0f470db..f27b582e 100644 --- a/packages/cli/templates/backend-and-client/base44/config.jsonc.ejs +++ b/packages/cli/templates/backend-and-client/base44/config.jsonc.ejs @@ -6,6 +6,8 @@ "name": "<%= name %>"<% if (description) { %>, "description": "<%= description %>"<% } %>, + "visibility": "public", + // Site/hosting configuration for the client application // Docs: https://docs.base44.com/configuration/hosting "site": { diff --git a/packages/cli/templates/backend-only/base44/config.jsonc.ejs b/packages/cli/templates/backend-only/base44/config.jsonc.ejs index 6e73acfe..b4e6c776 100644 --- a/packages/cli/templates/backend-only/base44/config.jsonc.ejs +++ b/packages/cli/templates/backend-only/base44/config.jsonc.ejs @@ -4,7 +4,9 @@ { "name": "<%= name %>"<% if (description) { %>, - "description": "<%= description %>"<% } %> + "description": "<%= description %>"<% } %>, + + "visibility": "public" // Site/hosting configuration // Docs: https://docs.base44.com/configuration/hosting diff --git a/packages/cli/tests/cli/create.spec.ts b/packages/cli/tests/cli/create.spec.ts index 29158c83..af646f99 100644 --- a/packages/cli/tests/cli/create.spec.ts +++ b/packages/cli/tests/cli/create.spec.ts @@ -1,5 +1,6 @@ +import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { setupCLITests } from "./testkit/index.js"; describe("create command", () => { @@ -67,6 +68,12 @@ describe("create command", () => { t.expectResult(result).toContain("Project created successfully"); t.expectResult(result).toContain("My New Project"); t.expectResult(result).toContain("new-project-id"); + + const config = await readFile( + join(projectPath, "base44", "config.jsonc"), + "utf-8", + ); + expect(config).toContain('"visibility": "public"'); }); it("infers path from name when --path is not provided", async () => { diff --git a/packages/cli/tests/cli/deploy.spec.ts b/packages/cli/tests/cli/deploy.spec.ts index 06192555..709dd173 100644 --- a/packages/cli/tests/cli/deploy.spec.ts +++ b/packages/cli/tests/cli/deploy.spec.ts @@ -1,9 +1,27 @@ -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { fixture, setupCLITests } from "./testkit/index.js"; describe("deploy command (unified)", () => { const t = setupCLITests(); + it("applies app visibility from config during deploy", async () => { + await t.givenLoggedInWithProject(fixture("with-visibility")); + + let body: unknown; + t.api.mockRoute("PUT", `/api/apps/${t.api.appId}`, (req, res) => { + body = req.body; + res.status(200).json({}); + }); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Visibility: private"); + expect(body).toEqual({ public_settings: "private_with_login" }); + }); + it("fails when --yes is not provided in non-interactive mode", async () => { await t.givenLoggedInWithProject(fixture("with-entities")); t.api.mockEntitiesPush({ created: ["Task"], updated: [], deleted: [] }); diff --git a/packages/cli/tests/cli/visibility.spec.ts b/packages/cli/tests/cli/visibility.spec.ts new file mode 100644 index 00000000..6604e2d6 --- /dev/null +++ b/packages/cli/tests/cli/visibility.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +const USER = { email: "test@example.com", name: "Test User" }; + +describe("visibility command", () => { + const t = setupCLITests(); + + it("fails when no level argument is provided", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run("visibility"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("missing required argument"); + }); + + it("fails with an invalid level", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run("visibility", "invalid"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("public"); + t.expectResult(result).toContain("private"); + t.expectResult(result).toContain("workspace"); + }); + + it("sets visibility on the server from a linked project", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + let body: unknown; + t.api.mockRoute("PUT", `/api/apps/${t.api.appId}`, (req, res) => { + body = req.body; + res.status(200).json({}); + }); + + const result = await t.run("visibility", "private"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("private"); + expect(body).toEqual({ public_settings: "private_with_login" }); + }); + + it("works projectless via --app-id", async () => { + await t.givenLoggedIn(USER); + let body: unknown; + t.api.mockRoute("PUT", `/api/apps/${t.api.appId}`, (req, res) => { + body = req.body; + res.status(200).json({}); + }); + + const result = await t.run( + "visibility", + "workspace", + "--app-id", + t.api.appId, + ); + + t.expectResult(result).toSucceed(); + expect(body).toEqual({ public_settings: "workspace_with_login" }); + }); + + it("shows help with --help flag", async () => { + const result = await t.run("visibility", "--help"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Set the app's visibility"); + }); +}); diff --git a/packages/cli/tests/core/errors.spec.ts b/packages/cli/tests/core/errors.spec.ts index 09f7a9be..d14924f4 100644 --- a/packages/cli/tests/core/errors.spec.ts +++ b/packages/cli/tests/core/errors.spec.ts @@ -152,6 +152,19 @@ describe("SystemError subclasses", () => { ); }); + it("does not suggest network troubleshooting for unhandled 4xx errors", () => { + // e.g. 402 plan-gating: the server message already explains the problem. + const error402 = new ApiError("Private Apps not on your plan", { + statusCode: 402, + }); + expect(error402.hints).toEqual([]); + }); + + it("suggests network troubleshooting when there is no status code", () => { + const error = new ApiError("Connection failed"); + expect(error.hints.some((h) => h.message.includes("network"))).toBe(true); + }); + it("ApiError stores request and response data", () => { const responseBody = { error: "Bad Request", detail: "Invalid field" }; const requestBody = '{"name":"test"}'; 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..5554ad0d --- /dev/null +++ b/packages/cli/tests/fixtures/with-visibility/base44/config.jsonc @@ -0,0 +1,4 @@ +{ + "name": "Visibility Test Project", + "visibility": "private" +}