From 1e137fd5eaa4b8f439e86673717a95b0509e0e64 Mon Sep 17 00:00:00 2001 From: David Susskind Date: Mon, 3 Aug 2026 10:33:49 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(deploy):=20opt-in=20build=20step=20?= =?UTF-8?q?=E2=80=94=20--build=20/=20--no-build=20/=20interactive=20ask?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit base44 deploy and base44 site deploy can build the site before uploading: an interactive run asks, --build / --no-build pre-answer, and non-interactive runs default to upload-only so CI behavior is unchanged. The build logic moves from build.ts into site-build.ts, shared by the command and both deploy call sites (same gate shape in both). eject opts out explicitly — its deploy already follows a fresh build. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + .../cli/src/cli/commands/project/deploy.ts | 25 +++++++- .../cli/src/cli/commands/project/eject.ts | 6 +- .../src/cli/commands/project/site-build.ts | 27 ++++++++ packages/cli/src/cli/commands/site/deploy.ts | 25 +++++++- packages/cli/tests/cli/build.spec.ts | 63 +++++++++++++++++++ .../with-buildable-site/base44/config.jsonc | 3 +- .../site-output/index.html | 1 + .../with-failing-build/base44/config.jsonc | 3 +- .../with-failing-build/site-output/index.html | 1 + 10 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 packages/cli/tests/fixtures/with-buildable-site/site-output/index.html create mode 100644 packages/cli/tests/fixtures/with-failing-build/site-output/index.html diff --git a/CHANGELOG.md b/CHANGELOG.md index e0eaada18..e16aaceb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - 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"`. - `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 diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index a021c9389..85394ec3f 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -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, @@ -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 { + const { isNonInteractive, log } = ctx; if (isNonInteractive && !options.yes) { throw new InvalidInputError("--yes is required in non-interactive mode"); } @@ -97,6 +103,21 @@ export async function deployAction( log.info(`Deploying:\n${summaryLines.join("\n")}`); } + if (ctx.app && project.site?.outputDirectory) { + 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; @@ -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); } diff --git a/packages/cli/src/cli/commands/project/eject.ts b/packages/cli/src/cli/commands/project/eject.ts index c95f4e0e2..ca87fd436 100644 --- a/packages/cli/src/cli/commands/project/eject.ts +++ b/packages/cli/src/cli/commands/project/eject.ts @@ -183,7 +183,11 @@ async function eject( }, ); - await deployAction(ctx, { yes: true, projectRoot: resolvedPath }); + await deployAction(ctx, { + yes: true, + build: false, + projectRoot: resolvedPath, + }); } } diff --git a/packages/cli/src/cli/commands/project/site-build.ts b/packages/cli/src/cli/commands/project/site-build.ts index 9a51a6cc6..0c98befb1 100644 --- a/packages/cli/src/cli/commands/project/site-build.ts +++ b/packages/cli/src/cli/commands/project/site-build.ts @@ -1,3 +1,4 @@ +import { confirm, isCancel } from "@clack/prompts"; import { execa } from "execa"; import type { CLIContext } from "@/cli/types.js"; import { ConfigNotFoundError } from "@/core/errors.js"; @@ -37,3 +38,29 @@ export async function runSiteBuild( }, ); } + +interface BuildBeforeDeployChoice { + build?: boolean; + isNonInteractive: boolean; + buildCommand?: string; +} + +export async function shouldBuildBeforeDeploy({ + build, + isNonInteractive, + buildCommand, +}: BuildBeforeDeployChoice): Promise { + 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; +} diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index 346e02be0..b3b8e8421 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -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"; @@ -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 { + const { isNonInteractive, runTask } = ctx; if (isNonInteractive && !options.yes) { throw new InvalidInputError("--yes is required in non-interactive mode"); } @@ -44,6 +50,21 @@ async function deployAction( } } + if (ctx.app && project.site?.outputDirectory) { + 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, + }); + } + } + const result = await runTask( "Creating archive and deploying site...", async () => { @@ -62,5 +83,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); } diff --git a/packages/cli/tests/cli/build.spec.ts b/packages/cli/tests/cli/build.spec.ts index d8d390db2..432db51bc 100644 --- a/packages/cli/tests/cli/build.spec.ts +++ b/packages/cli/tests/cli/build.spec.ts @@ -41,3 +41,66 @@ 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"); + }); +}); diff --git a/packages/cli/tests/fixtures/with-buildable-site/base44/config.jsonc b/packages/cli/tests/fixtures/with-buildable-site/base44/config.jsonc index 05abc950b..077b2755d 100644 --- a/packages/cli/tests/fixtures/with-buildable-site/base44/config.jsonc +++ b/packages/cli/tests/fixtures/with-buildable-site/base44/config.jsonc @@ -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" } } diff --git a/packages/cli/tests/fixtures/with-buildable-site/site-output/index.html b/packages/cli/tests/fixtures/with-buildable-site/site-output/index.html new file mode 100644 index 000000000..cea6f157e --- /dev/null +++ b/packages/cli/tests/fixtures/with-buildable-site/site-output/index.html @@ -0,0 +1 @@ +buildable site fixture diff --git a/packages/cli/tests/fixtures/with-failing-build/base44/config.jsonc b/packages/cli/tests/fixtures/with-failing-build/base44/config.jsonc index fb3844ea9..58a5347ba 100644 --- a/packages/cli/tests/fixtures/with-failing-build/base44/config.jsonc +++ b/packages/cli/tests/fixtures/with-failing-build/base44/config.jsonc @@ -1,6 +1,7 @@ { "name": "Failing Build Project", "site": { - "buildCommand": "node -e \"process.exit(1)\"" + "buildCommand": "node -e \"process.exit(1)\"", + "outputDirectory": "site-output" } } diff --git a/packages/cli/tests/fixtures/with-failing-build/site-output/index.html b/packages/cli/tests/fixtures/with-failing-build/site-output/index.html new file mode 100644 index 000000000..18ecdcb79 --- /dev/null +++ b/packages/cli/tests/fixtures/with-failing-build/site-output/index.html @@ -0,0 +1 @@ + From a99c5b7cc7eb1cfa7411403a9b67674fd8d83426 Mon Sep 17 00:00:00 2001 From: David Susskind Date: Mon, 3 Aug 2026 14:51:25 +0300 Subject: [PATCH 2/3] refactor(deploy): extract the shared build-before-deploy step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both deploy call sites carried the identical gate → ask → build block; maybeBuildBeforeDeploy(ctx, project, build) is now the one copy. Review feedback from #587. Co-Authored-By: Claude Fable 5 --- .../cli/src/cli/commands/project/deploy.ts | 20 ++------------ .../src/cli/commands/project/site-build.ts | 26 ++++++++++++++++++- packages/cli/src/cli/commands/site/deploy.ts | 20 ++------------ 3 files changed, 29 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index 85394ec3f..986c01ffc 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -6,10 +6,7 @@ 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 { maybeBuildBeforeDeploy } from "@/cli/commands/project/site-build.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command, @@ -103,20 +100,7 @@ export async function deployAction( log.info(`Deploying:\n${summaryLines.join("\n")}`); } - if (ctx.app && project.site?.outputDirectory) { - 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, - }); - } - } + await maybeBuildBeforeDeploy(ctx, project, options.build); // Deploy resources with per-function progress let functionCompleted = 0; diff --git a/packages/cli/src/cli/commands/project/site-build.ts b/packages/cli/src/cli/commands/project/site-build.ts index 0c98befb1..a1258dd7c 100644 --- a/packages/cli/src/cli/commands/project/site-build.ts +++ b/packages/cli/src/cli/commands/project/site-build.ts @@ -2,6 +2,7 @@ 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; @@ -39,13 +40,36 @@ export async function runSiteBuild( ); } +export async function maybeBuildBeforeDeploy( + ctx: Pick, + project: ProjectData["project"], + build?: boolean, +): Promise { + if (!ctx.app || !project.site?.outputDirectory) { + return; + } + + const shouldBuild = await shouldBuildBeforeDeploy({ + build, + isNonInteractive: ctx.isNonInteractive, + buildCommand: project.site.buildCommand, + }); + if (shouldBuild) { + await runSiteBuild(ctx, { + root: project.root, + buildCommand: project.site.buildCommand, + appId: ctx.app.id, + }); + } +} + interface BuildBeforeDeployChoice { build?: boolean; isNonInteractive: boolean; buildCommand?: string; } -export async function shouldBuildBeforeDeploy({ +async function shouldBuildBeforeDeploy({ build, isNonInteractive, buildCommand, diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index b3b8e8421..03ecc2519 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -1,10 +1,7 @@ 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 { 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"; @@ -50,20 +47,7 @@ async function deployAction( } } - if (ctx.app && project.site?.outputDirectory) { - 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, - }); - } - } + await maybeBuildBeforeDeploy(ctx, project, options.build); const result = await runTask( "Creating archive and deploying site...", From 4cf5174f9e3c75a4cc577c96abb0fd2ee9e74325 Mon Sep 17 00:00:00 2001 From: David Susskind Date: Mon, 3 Aug 2026 15:23:24 +0300 Subject: [PATCH 3/3] feat(deploy): make an explicit --build loud when there is nothing to build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --build states intent; silently uploading without building betrays it. The flag is now honored before any site-config gate, so a missing site.buildCommand (or a project with no site at all) fails with the existing ConfigNotFoundError instead of no-opping. The silent paths — no flag, --no-build, non-interactive — are unchanged, and the ask still only appears when a buildCommand exists. Review feedback from #587. Co-Authored-By: Claude Fable 5 --- .../src/cli/commands/project/site-build.ts | 49 ++++++++++--------- packages/cli/tests/cli/build.spec.ts | 18 +++++++ 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/cli/commands/project/site-build.ts b/packages/cli/src/cli/commands/project/site-build.ts index a1258dd7c..3e57add23 100644 --- a/packages/cli/src/cli/commands/project/site-build.ts +++ b/packages/cli/src/cli/commands/project/site-build.ts @@ -45,15 +45,29 @@ export async function maybeBuildBeforeDeploy( project: ProjectData["project"], build?: boolean, ): Promise { - if (!ctx.app || !project.site?.outputDirectory) { + if (!ctx.app) { return; } - const shouldBuild = await shouldBuildBeforeDeploy({ - build, - isNonInteractive: ctx.isNonInteractive, - buildCommand: project.site.buildCommand, - }); + // 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, @@ -63,24 +77,11 @@ export async function maybeBuildBeforeDeploy( } } -interface BuildBeforeDeployChoice { - build?: boolean; - isNonInteractive: boolean; - buildCommand?: string; -} - -async function shouldBuildBeforeDeploy({ - build, - isNonInteractive, - buildCommand, -}: BuildBeforeDeployChoice): Promise { - if (!buildCommand) { - return false; - } - if (build !== undefined) { - return build; - } - if (isNonInteractive) { +async function shouldAskToBuild( + isNonInteractive: boolean, + buildCommand?: string, +): Promise { + if (!buildCommand || isNonInteractive) { return false; } const answer = await confirm({ diff --git a/packages/cli/tests/cli/build.spec.ts b/packages/cli/tests/cli/build.spec.ts index 432db51bc..a7d758a15 100644 --- a/packages/cli/tests/cli/build.spec.ts +++ b/packages/cli/tests/cli/build.spec.ts @@ -103,4 +103,22 @@ describe("deploy --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"); + }); });