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..986c01ffc 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -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, @@ -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 { + const { isNonInteractive, log } = ctx; if (isNonInteractive && !options.yes) { throw new InvalidInputError("--yes is required in non-interactive mode"); } @@ -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; @@ -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); } 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..3e57add23 100644 --- a/packages/cli/src/cli/commands/project/site-build.ts +++ b/packages/cli/src/cli/commands/project/site-build.ts @@ -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; @@ -37,3 +39,53 @@ export async function runSiteBuild( }, ); } + +export async function maybeBuildBeforeDeploy( + ctx: Pick, + project: ProjectData["project"], + build?: boolean, +): Promise { + 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 { + if (!buildCommand || 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..03ecc2519 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -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"; @@ -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 { + const { isNonInteractive, runTask } = ctx; if (isNonInteractive && !options.yes) { throw new InvalidInputError("--yes is required in non-interactive mode"); } @@ -44,6 +47,8 @@ async function deployAction( } } + await maybeBuildBeforeDeploy(ctx, project, options.build); + const result = await runTask( "Creating archive and deploying site...", async () => { @@ -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); } diff --git a/packages/cli/tests/cli/build.spec.ts b/packages/cli/tests/cli/build.spec.ts index d8d390db2..a7d758a15 100644 --- a/packages/cli/tests/cli/build.spec.ts +++ b/packages/cli/tests/cli/build.spec.ts @@ -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"); + }); +}); 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 @@ +