diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f871affa..e16aaceb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added - 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/package.json b/package.json index 6d46b416c..47aafb2c5 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "knip:fix": "knip --fix" }, "devDependencies": { - "@biomejs/biome": "^2.0.0", "knip": "^5.83.1" }, "workspaces": [ diff --git a/packages/cli/src/cli/commands/project/build.ts b/packages/cli/src/cli/commands/project/build.ts new file mode 100644 index 000000000..747329cee --- /dev/null +++ b/packages/cli/src/cli/commands/project/build.ts @@ -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 { + 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); +} diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index a021c9389..eeb53c602 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 (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; @@ -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 new file mode 100644 index 000000000..354a18b0b --- /dev/null +++ b/packages/cli/src/cli/commands/project/site-build.ts @@ -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, + { root, buildCommand, appId }: SiteBuildTarget, +): Promise { + 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 { + 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..7da44b4b3 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,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 () => { @@ -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); } diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 49d07be43..6af871b89 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -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"; @@ -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()); diff --git a/packages/cli/tests/cli/build.spec.ts b/packages/cli/tests/cli/build.spec.ts new file mode 100644 index 000000000..0f404aa26 --- /dev/null +++ b/packages/cli/tests/cli/build.spec.ts @@ -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"); + }); +}); diff --git a/packages/cli/tests/fixtures/with-buildable-site/base44/config.jsonc b/packages/cli/tests/fixtures/with-buildable-site/base44/config.jsonc new file mode 100644 index 000000000..077b2755d --- /dev/null +++ b/packages/cli/tests/fixtures/with-buildable-site/base44/config.jsonc @@ -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" + } +} 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 new file mode 100644 index 000000000..58a5347ba --- /dev/null +++ b/packages/cli/tests/fixtures/with-failing-build/base44/config.jsonc @@ -0,0 +1,7 @@ +{ + "name": "Failing Build Project", + "site": { + "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 @@ +