Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- 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
9 changes: 8 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,7 @@ import {
promptOAuthFlows,
} from "@/cli/commands/connectors/oauth-prompt.js";
import { formatDeployResult } from "@/cli/commands/functions/formatDeployResult.js";
import { maybeBuildBeforeDeploy } from "@/cli/commands/project/site-build.js";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import {
Base44Command,
Expand All @@ -26,13 +27,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 +100,8 @@ export async function deployAction(
log.info(`Deploying:\n${summaryLines.join("\n")}`);
}

await maybeBuildBeforeDeploy(ctx, project, options.build);

// Deploy resources with per-function progress
let functionCompleted = 0;
const functionTotal = functions.length;
Expand Down Expand Up @@ -145,6 +150,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
52 changes: 52 additions & 0 deletions packages/cli/src/cli/commands/project/site-build.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { confirm, isCancel } from "@clack/prompts";
import { execa } from "execa";
import type { CLIContext } from "@/cli/types.js";
import { ConfigNotFoundError } from "@/core/errors.js";
import type { ProjectData } from "@/core/project/types.js";

interface SiteBuildTarget {
root: string;
Expand Down Expand Up @@ -37,3 +39,53 @@ export async function runSiteBuild(
},
);
}

export async function maybeBuildBeforeDeploy(
ctx: Pick<CLIContext, "runTask" | "isNonInteractive" | "app">,
project: ProjectData["project"],
build?: boolean,
): Promise<void> {
if (!ctx.app) {
return;
}

// An explicit --build must be loud when there is nothing to build:
// runSiteBuild throws ConfigNotFoundError when buildCommand is missing.
if (build === true) {
await runSiteBuild(ctx, {
root: project.root,
buildCommand: project.site?.buildCommand,
appId: ctx.app.id,
});
return;
}

if (build === false || !project.site?.outputDirectory) {
return;
}

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

async function shouldAskToBuild(
isNonInteractive: boolean,
buildCommand?: string,
): Promise<boolean> {
if (!buildCommand || isNonInteractive) {
return false;
}
const answer = await confirm({
message: `Build the site first? (runs '${buildCommand}' with your app id)`,
});
return !isCancel(answer) && answer;
}
9 changes: 8 additions & 1 deletion packages/cli/src/cli/commands/site/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { resolve } from "node:path";
import { confirm, isCancel } from "@clack/prompts";
import type { Command } from "commander";
import { maybeBuildBeforeDeploy } 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 +10,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 +47,8 @@ async function deployAction(
}
}

await maybeBuildBeforeDeploy(ctx, project, options.build);

const result = await runTask(
"Creating archive and deploying site...",
async () => {
Expand All @@ -62,5 +67,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);
}
81 changes: 81 additions & 0 deletions packages/cli/tests/cli/build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,84 @@ describe("build command", () => {
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");
});

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

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

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

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

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

t.expectResult(result).toFail();
t.expectResult(result).toContain("No site build command found");
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "Buildable Site Project",
"site": {
"buildCommand": "node -e \"require('fs').writeFileSync('build-env.txt', 'BUILD_APP=' + process.env.VITE_BASE44_APP_ID)\""
"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
@@ -1,6 +1,7 @@
{
"name": "Failing Build Project",
"site": {
"buildCommand": "node -e \"process.exit(1)\""
"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