Skip to content
Closed
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
### 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"`.
- `base44 build` runs the site's `buildCommand` with `VITE_BASE44_APP_ID` injected, so built bundles always carry the linked app's id.
- `base44 deploy` (and `base44 site deploy`) can now build first: interactive runs ask, and `--build` / `--no-build` pre-answer the prompt.

### Fixed

Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
"knip:fix": "knip --fix"
},
"devDependencies": {
"@biomejs/biome": "^2.0.0",
"knip": "^5.83.1"
},
"workspaces": [
Expand Down
32 changes: 32 additions & 0 deletions packages/cli/src/cli/commands/project/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Command } from "commander";
import { runSiteBuild } from "@/cli/commands/project/site-build.js";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command, theme } from "@/cli/utils/index.js";
import { ConfigInvalidError } from "@/core/errors.js";
import { readProjectConfig } from "@/core/project/index.js";

async function buildAction(ctx: CLIContext): Promise<RunCommandResult> {
const { app } = ctx;
if (!app?.projectRoot) {
throw new ConfigInvalidError(
"base44 build requires a linked local project. Run it from a project with base44/.app.jsonc.",
);
}

const { project } = await readProjectConfig(app.projectRoot);
await runSiteBuild(ctx, {
root: project.root,
buildCommand: project.site?.buildCommand,
appId: app.id,
});

return {
outroMessage: `Site built with app id ${theme.styles.bold(app.id)}`,
};
}

export function getBuildCommand(): Command {
return new Base44Command("build")
.description("Build the site with the Base44 app id injected")
.action(buildAction);
}
25 changes: 24 additions & 1 deletion packages/cli/src/cli/commands/project/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import {
promptOAuthFlows,
} from "@/cli/commands/connectors/oauth-prompt.js";
import { formatDeployResult } from "@/cli/commands/functions/formatDeployResult.js";
import {
runSiteBuild,
shouldBuildBeforeDeploy,
} from "@/cli/commands/project/site-build.js";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import {
Base44Command,
Expand All @@ -26,13 +30,15 @@ import type {

interface DeployOptions {
yes?: boolean;
build?: boolean;
projectRoot?: string;
}

export async function deployAction(
{ isNonInteractive, log }: CLIContext,
ctx: CLIContext,
options: DeployOptions = {},
): Promise<RunCommandResult> {
const { isNonInteractive, log } = ctx;
if (isNonInteractive && !options.yes) {
throw new InvalidInputError("--yes is required in non-interactive mode");
}
Expand Down Expand Up @@ -97,6 +103,21 @@ export async function deployAction(
log.info(`Deploying:\n${summaryLines.join("\n")}`);
}

if (project.site?.outputDirectory && ctx.app) {
const shouldBuild = await shouldBuildBeforeDeploy({
build: options.build,
isNonInteractive,
buildCommand: project.site.buildCommand,
});
if (shouldBuild) {
await runSiteBuild(ctx, {
root: project.root,
buildCommand: project.site.buildCommand,
appId: ctx.app.id,
});
}
}

// Deploy resources with per-function progress
let functionCompleted = 0;
const functionTotal = functions.length;
Expand Down Expand Up @@ -145,6 +166,8 @@ export function getDeployCommand(): Command {
"Deploy all project resources (entities, functions, agents, connectors, and site)",
)
.option("-y, --yes", "Skip confirmation prompt")
.option("--build", "Build the site before deploying (skips the prompt)")
.option("--no-build", "Deploy without building (skips the prompt)")
.action(deployAction);
}

Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/cli/commands/project/eject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,11 @@ async function eject(
},
);

await deployAction(ctx, { yes: true, projectRoot: resolvedPath });
await deployAction(ctx, {
yes: true,
build: false,
projectRoot: resolvedPath,
});
}
}

Expand Down
67 changes: 67 additions & 0 deletions packages/cli/src/cli/commands/project/site-build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { confirm, isCancel } from "@clack/prompts";
import { execa } from "execa";
import type { CLIContext } from "@/cli/types.js";
import { ConfigNotFoundError } from "@/core/errors.js";

interface SiteBuildTarget {
root: string;
buildCommand?: string;
appId: string;
}

export async function runSiteBuild(
{ runTask }: Pick<CLIContext, "runTask">,
{ root, buildCommand, appId }: SiteBuildTarget,
): Promise<void> {
if (!buildCommand) {
throw new ConfigNotFoundError("No site build command found.", {
hints: [
{
message:
'Add \'site.buildCommand\' to your config.jsonc (e.g., "site": { "buildCommand": "npm run build" })',
},
],
});
}

await runTask(
"Building site...",
async () => {
await execa({
cwd: root,
shell: true,
env: { VITE_BASE44_APP_ID: appId },
})`${buildCommand}`;
},
{
successMessage: "Site built successfully",
errorMessage: "Build failed",
},
);
}

interface BuildBeforeDeployChoice {
build?: boolean;
isNonInteractive: boolean;
buildCommand?: string;
}

export async function shouldBuildBeforeDeploy({
build,
isNonInteractive,
buildCommand,
}: BuildBeforeDeployChoice): Promise<boolean> {
if (!buildCommand) {
return false;
}
if (build !== undefined) {
return build;
}
if (isNonInteractive) {
return false;
}
const answer = await confirm({
message: `Build the site first? (runs '${buildCommand}' with your app id)`,
});
return !isCancel(answer) && answer;
}
23 changes: 22 additions & 1 deletion packages/cli/src/cli/commands/site/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { resolve } from "node:path";
import { confirm, isCancel } from "@clack/prompts";
import type { Command } from "commander";
import {
runSiteBuild,
shouldBuildBeforeDeploy,
} from "@/cli/commands/project/site-build.js";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command } from "@/cli/utils/index.js";
import { ConfigNotFoundError, InvalidInputError } from "@/core/errors.js";
Expand All @@ -9,12 +13,14 @@ import { deploySite } from "@/core/site/index.js";

interface DeployOptions {
yes?: boolean;
build?: boolean;
}

async function deployAction(
{ isNonInteractive, runTask }: CLIContext,
ctx: CLIContext,
options: DeployOptions,
): Promise<RunCommandResult> {
const { isNonInteractive, runTask } = ctx;
if (isNonInteractive && !options.yes) {
throw new InvalidInputError("--yes is required in non-interactive mode");
}
Expand Down Expand Up @@ -44,6 +50,19 @@ async function deployAction(
}
}

const shouldBuild = await shouldBuildBeforeDeploy({
build: options.build,
isNonInteractive,
buildCommand: project.site.buildCommand,
});
if (shouldBuild && ctx.app) {
await runSiteBuild(ctx, {
root: project.root,
buildCommand: project.site.buildCommand,
appId: ctx.app.id,
});
}

const result = await runTask(
"Creating archive and deploying site...",
async () => {
Expand All @@ -62,5 +81,7 @@ export function getSiteDeployCommand(): Command {
return new Base44Command("deploy")
.description("Deploy built site files to Base44 hosting")
.option("-y, --yes", "Skip confirmation prompt")
.option("--build", "Build the site before deploying (skips the prompt)")
.option("--no-build", "Deploy without building (skips the prompt)")
.action(deployAction);
}
2 changes: 2 additions & 0 deletions packages/cli/src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getConnectorsCommand } from "@/cli/commands/connectors/index.js";
import { getDashboardCommand } from "@/cli/commands/dashboard/index.js";
import { getEntitiesPushCommand } from "@/cli/commands/entities/push.js";
import { getFunctionsCommand } from "@/cli/commands/functions/index.js";
import { getBuildCommand } from "@/cli/commands/project/build.js";
import { getCreateCommand } from "@/cli/commands/project/create.js";
import { getDeployCommand } from "@/cli/commands/project/deploy.js";
import { getLinkCommand } from "@/cli/commands/project/link.js";
Expand Down Expand Up @@ -68,6 +69,7 @@ export function createProgram(context: CLIContext): Command {
program.addCommand(getCreateCommand());
program.addCommand(getScaffoldCommand());
program.addCommand(getDashboardCommand());
program.addCommand(getBuildCommand());
program.addCommand(getDeployCommand());
program.addCommand(getVisibilityCommand());
program.addCommand(getLinkCommand());
Expand Down
97 changes: 97 additions & 0 deletions packages/cli/tests/cli/build.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import { fixture, setupCLITests } from "./testkit/index.js";

describe("build command", () => {
const t = setupCLITests();

it("runs the site buildCommand with the app id injected", async () => {
await t.givenLoggedInWithProject(fixture("with-buildable-site"));

const result = await t.run("build");

t.expectResult(result).toSucceed();
expect(await t.readProjectFile("build-env.txt")).toBe(
`BUILD_APP=${t.api.appId}`,
);
});

it("fails when the project has no site.buildCommand", async () => {
await t.givenLoggedInWithProject(fixture("with-site"));

const result = await t.run("build");

t.expectResult(result).toFail();
t.expectResult(result).toContain("No site build command found");
});

it("fails when not in a project directory", async () => {
await t.givenLoggedIn({ email: "test@example.com", name: "Test User" });

const result = await t.run("build");

t.expectResult(result).toFail();
});
});

describe("deploy --build", () => {
const t = setupCLITests();

const mockDeployApi = () => {
t.api.mockConnectorsList({ integrations: [] });
t.api.mockStripeStatus({ stripe_mode: null });
t.api.mockSiteDeploy({ app_url: "https://buildable.base44.app" });
};

it("builds before deploying when --build is passed", async () => {
await t.givenLoggedInWithProject(fixture("with-buildable-site"));
mockDeployApi();

const result = await t.run("deploy", "--yes", "--build");

t.expectResult(result).toSucceed();
expect(await t.readProjectFile("build-env.txt")).toBe(
`BUILD_APP=${t.api.appId}`,
);
});

it("does not build when the build flag is absent in non-interactive mode", async () => {
await t.givenLoggedInWithProject(fixture("with-buildable-site"));
mockDeployApi();

const result = await t.run("deploy", "--yes");

t.expectResult(result).toSucceed();
expect(await t.readProjectFile("build-env.txt")).toBeNull();
});

it("does not build with --no-build", async () => {
await t.givenLoggedInWithProject(fixture("with-buildable-site"));
mockDeployApi();

const result = await t.run("deploy", "--yes", "--no-build");

t.expectResult(result).toSucceed();
expect(await t.readProjectFile("build-env.txt")).toBeNull();
});

it("site deploy --build builds before uploading", async () => {
await t.givenLoggedInWithProject(fixture("with-buildable-site"));
t.api.mockSiteDeploy({ app_url: "https://buildable.base44.app" });

const result = await t.run("site", "deploy", "--yes", "--build");

t.expectResult(result).toSucceed();
expect(await t.readProjectFile("build-env.txt")).toBe(
`BUILD_APP=${t.api.appId}`,
);
});

it("fails the deploy when the build fails", async () => {
await t.givenLoggedInWithProject(fixture("with-failing-build"));

const result = await t.run("deploy", "--yes", "--build");

t.expectResult(result).toFail();
t.expectResult(result).toContain("Build failed");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "Buildable Site Project",
"site": {
"buildCommand": "node -e \"require('fs').writeFileSync('build-env.txt', 'BUILD_APP=' + process.env.VITE_BASE44_APP_ID)\"",
"outputDirectory": "site-output"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<html><body>buildable site fixture</body></html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "Failing Build Project",
"site": {
"buildCommand": "node -e \"process.exit(1)\"",
"outputDirectory": "site-output"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<html></html>
Loading