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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [Unreleased]

### Added

- App visibility: `base44 visibility <public|private|workspace>` 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
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/cli/commands/project/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down
29 changes: 29 additions & 0 deletions packages/cli/src/cli/commands/project/visibility.ts
Original file line number Diff line number Diff line change
@@ -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<RunCommandResult> {
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("<level>", "Visibility level").choices([
...VISIBILITY_LEVELS,
]),
)
.action(setVisibility);
}
2 changes: 2 additions & 0 deletions packages/cli/src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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());

Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }];
}

Expand Down
28 changes: 26 additions & 2 deletions packages/cli/src/core/project/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,20 @@ 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,
} from "@/core/project/schema.js";
import { PublishedUrlResponseSchema } from "@/core/site/schema.js";
import { makeDirectory } from "@/core/utils/fs.js";

const PUBLIC_SETTINGS: Record<Visibility, string> = {
public: "public_without_login",
private: "private_with_login",
workspace: "workspace_with_login",
};

export async function createProject(projectName: string, description?: string) {
let response: KyResponse;
try {
Expand All @@ -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) {
Expand All @@ -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<void> {
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<ProjectsResponse> {
let response: KyResponse;
try {
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/core/project/deploy.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -28,13 +29,15 @@ 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 ||
hasFunctions ||
hasAgents ||
hasConnectors ||
hasAuthConfig ||
hasVisibility ||
hasSite
);
}
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/core/project/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,17 @@ export const PluginReferenceSchema = z.object({

export type PluginReference = z.infer<typeof PluginReferenceSchema>;

export const VISIBILITY_LEVELS = ["public", "private", "workspace"] as const;
export type Visibility = (typeof VISIBILITY_LEVELS)[number];

export const ProjectConfigSchema = z.object({
name: z
.string({
error: "App name cannot be empty",
})
.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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/templates/backend-only/base44/config.jsonc.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

{
"name": "<%= name %>"<% if (description) { %>,
"description": "<%= description %>"<% } %>
"description": "<%= description %>"<% } %>,

"visibility": "public"

// Site/hosting configuration
// Docs: https://docs.base44.com/configuration/hosting
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/tests/cli/create.spec.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
20 changes: 19 additions & 1 deletion packages/cli/tests/cli/deploy.spec.ts
Original file line number Diff line number Diff line change
@@ -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: [] });
Expand Down
69 changes: 69 additions & 0 deletions packages/cli/tests/cli/visibility.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
13 changes: 13 additions & 0 deletions packages/cli/tests/core/errors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"}';
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/tests/fixtures/with-visibility/base44/.app.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Base44 App Configuration
{
"id": "test-app-id"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "Visibility Test Project",
"visibility": "private"
}
Loading