diff --git a/CHANGELOG.md b/CHANGELOG.md index e203778bb..2d58f01be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ ### Changed +- `base44 deploy --git-hash ` ships the site through the deployments API — the same lane `base44 site deploy` uses — addressed by that commit, and publishes it, so a full-stack build's worker ships too. Without `--git-hash` the site deploys through the archive upload and goes live, as before; a full-stack build, which that upload cannot carry, now fails asking for the flag before anything ships instead of silently dropping its worker. +- `base44 site deploy` is no longer behind `BASE44_DEPLOYMENTS_API`; setting the env var now does nothing. `--git-hash` / `--concurrency` are always available, and `--git-hash` is what selects the deployments API: with it, the build is deployed by that commit but **not** published — `--publish` asks for that, and the command says so when it leaves production alone — because the platform's own build step runs it against builder-managed apps, which are published from the builder. Without `--git-hash` it deploys through the archive upload and goes live, as before. - The `site` block's two commands now default to the conventions `base44 create` scaffolds, so a block only has to name what a project does differently: `installCommand` `npm install` and `buildCommand` `npm run build`. `site` itself stays optional, and `serveCommand` and `outputDirectory` are deliberately not defaulted — their absence says "no frontend to run here" and "nothing built to upload", so `base44 dev` still runs the backend alone and `base44 deploy` still skips the site step. For a project that declared a partial block, `base44 build` and `deploy --build` now build it instead of refusing, and the deploy flow may offer to build first. - `base44 sandbox` help now explains that writes are committed but not checkpointed: a Restore or Revert in the builder rolls the app back to the last checkpoint and discards everything after it, so run `base44 sandbox checkpoint` after each unit of work and before stopping. The note appears on `sandbox`, `sandbox write`, `sandbox edit`, `sandbox run`, and `sandbox checkpoint`. - The `backend-and-client` template now scaffolds the same client convention editor-created apps use: `@base44/vite-plugin` + `src/lib/app-params.js`, with the SDK client on same-origin `/api` (`serverUrl: ''`). Under `base44 dev` the plugin proxies `/api` to the local dev backend, so scaffolded apps get local entities and functions; the app id is injected via `VITE_BASE44_APP_ID` by `base44 dev`, `base44 dev --remote`, and the build/deploy commands instead of being baked into source. diff --git a/docs/deployments.md b/docs/deployments.md index 026742c4b..081455025 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -1,6 +1,6 @@ # Deployments -**Keywords:** deployments, full-stack, Cloudflare Workers, wrangler, no_bundle, asset manifest, hash, git hash, commit, buckets, presigned, S3, upload session, finalize, .assetsignore, negation, concurrency, .wrangler/deploy/config.json, static site, BASE44_DEPLOYMENTS_API, env gate, target +**Keywords:** deployments, full-stack, Cloudflare Workers, wrangler, no_bundle, asset manifest, hash, git hash, commit, buckets, presigned, S3, upload session, finalize, .assetsignore, negation, concurrency, .wrangler/deploy/config.json, static site, publish, --publish, target Deployments ship an app's built output addressed by the commit that produced it. This is a transport of the site module, not a module of its own, so it lives directly in `src/core/site/`: `deployment.ts` (the flow), `wrangler-config.ts` (artifact detection), `modules.ts` (worker module collection), `manifest.ts` (asset walk + hashing), `upload.ts` (bucket and presigned uploads), `git-hash.ts` (the commit address), with the requests and responses in the shared `api.ts` / `schema.ts` next to the legacy tar.gz upload. @@ -8,15 +8,22 @@ Deployments ship an app's built output addressed by the commit that produced it. Everything downstream follows from the server's answer rather than from a decision of ours: `uploadDeploymentAssets()` dispatches on the `asset_uploads` discriminant, and `finalizeDeployment()` takes whichever payload completes the deployment (worker modules, or the index.html sentinel). The only place the two shapes are spelled out is where the protocol itself differs — the finalize form in `api.ts`. -**Deploying builds — it never publishes.** A deployment is addressed by the commit that produced the build: the server derives the deployment id from `git_hash`, so one commit means one deployment and re-deploying a commit is idempotent. What production serves is decided by the platform publish flow, not by this CLI — there is no `--prod`, no promote/rollback, and no deployment list/logs surface. +**Deploying and publishing are separate acts.** A deployment is addressed by the commit that produced the build: the server derives the deployment id from `git_hash`, so one commit means one deployment and re-deploying a commit is idempotent. Making a commit's build the one production serves is a second call: the builder's own publish, `POST /deploy` with `{ git_commit_hash }` instead of a saved version (`publishDeployment()` in `api.ts`, which then reads the app's URL from `published-url`). The server takes a commit only for an app whose source Base44 does not manage, and refuses it with `error.code` `git_commit_hash_not_supported` otherwise: a builder-managed app is published from the builder, where checkpoints and branch rules live. -## Git Hash Resolution +**This lane is opted into with `--git-hash`, and only with it.** Nothing is inferred from the local checkout: without the flag both commands keep the legacy tar.gz upload, which goes live as it always did. With it, which command publishes is not a style choice: -`resolveGitHash(projectRoot, explicit?)` — an explicit `--git-hash` wins; otherwise `git rev-parse HEAD` in the project root. No hash (not a git checkout, no flag) fails fast with guidance. A non-hex value is rejected by the option's `argParser` before the action even runs — `isGitCommitHash()` in `src/core/utils/git.ts`, pattern `^[a-fA-F0-9]{7,64}$`, the same validation as the server. +- **`base44 deploy --git-hash` always publishes.** It is the door a developer uses on their own project, where the archive upload always left the app live. +- **`base44 site deploy --git-hash` does not** (`--publish` asks for it, and the command says so when it leaves production alone). Its main caller is the platform's own build step, which builds a builder-managed app and repoints production itself; publishing there would fail those apps outright. + +There is still no promote/rollback surface: rolling back is publishing an earlier commit. + +## The Git Hash + +Only `--git-hash` supplies it; `git rev-parse HEAD` is never consulted for a deployment (versions still read it, as provenance — `resolveProvenanceCommit()`). A non-hex value is rejected by the option's `argParser` before the action even runs — `isGitCommitHash()` in `src/core/utils/git.ts`, pattern `^[a-fA-F0-9]{7,64}$`, the same validation as the server. ## Artifact Detection -The transport is **not** picked from the artifact: `base44 site deploy` takes the deployments API whenever `deploymentsApiEnabled()` says the env gate is on (see [the gate](#the-deployments-api-lane-experimental-env-gated)), and the legacy tar.gz upload otherwise. Detection then decides only what the create call *sends* — a worker config or none — inside `deployToDeployments()`. A gated-on deploy needs no `site.outputDirectory` when the build emitted a worker, since the worker brings its own assets directory. **This lane is reachable only from `site deploy`** — `base44 deploy` ships the site through `deployAll()`'s legacy tar.gz step and has none of these flags. +The transport is **not** picked from the artifact: every site deploy goes through the deployments API. Detection decides only what the create call *sends* — a worker config or none — inside `deployToDeployments()`. A deploy needs no `site.outputDirectory` when the build emitted a worker, since the worker brings its own assets directory, and `hasResourcesToDeploy()` counts such a build as a site for the same reason. `detectFullStackArtifact(projectRoot)` looks for exactly one thing: `.wrangler/deploy/config.json`, the redirect file emitted by `@cloudflare/vite-plugin` builds. Its `configPath` points at the generated `wrangler.json`, **relative to the redirect file's directory**. @@ -55,29 +62,25 @@ Entry = `main` from the wrangler config. With `no_bundle: true`, every file unde ## Command UX -**`base44 site deploy [--git-hash ] [--concurrency ] [--build|--no-build]`** — the optional build step is `maybeBuildBeforeDeploy` (`--build` forces it, `--no-build` skips it, otherwise an interactive ask whenever the project has a `site` block, since `buildCommand` defaults inside one). It runs **before** the deploy confirmation, so the prompt is the last gate before anything leaves the machine. Progress: "Found N static assets (M new)" → "Uploaded X of Y assets" → "Deploying worker (K modules)…" (only when there is one) → outro `Deployment (commit )`. Under `--json`, stdout is a single `{deploymentId, gitHash}` document. +**`base44 site deploy [--git-hash ] [--concurrency ] [--build|--no-build] [--publish]`** — the optional build step is `maybeBuildBeforeDeploy` (`--build` forces it, `--no-build` skips it, otherwise an interactive ask whenever the project has a `site` block, since `buildCommand` defaults inside one). It runs **before** the deploy confirmation, so the prompt is the last gate before anything leaves the machine. Progress: "Found N static assets (M new)" → "Uploaded X of Y assets" → "Deploying worker (K modules)…" (only when there is one) → outro `Deployment (commit )`. Under `--json`, stdout is a single `{deploymentId, gitHash, published}` document. **"Site" is the only word for it in user-facing copy.** A site is whatever we deploy, worker or no worker, so the prompt, spinner, success line and errors say "site" and never distinguish the two — the distinction is ours, not the user's, and a deploy that reports itself differently depending on the build reads as two products. Internally the code says "worker" for the thing that may or may not be there. **Config only, no resources.** `site deploy` reads the project config through `readProjectSettings()`, not `readProjectConfig()`: it ships the built output and touches none of the project's resource files, so an invalid one must not fail it. It used to — builder apps carry entity schemas the CLI's `EntitySchema` rejects, and every publish through this lane failed with `SCHEMA_INVALID` before reaching a single asset. Commands that do consume those resources keep using `readProjectConfig()`. -`base44 deploy` is deliberately untouched by this: it deploys the project's resources and ships the site through `deployAll()`'s legacy tar.gz step, exactly as before, and neither `--git-hash` nor `--concurrency` exists on it. Adopting the lane there is a separate decision — it would need a commit address the unified deploy has no way to take. +**`base44 deploy [--git-hash ]`** ships the project's resources and then its site, through this same lane, and publishes what it deployed. Without `--git-hash` it ships the site through the archive upload instead; it takes no `--concurrency`, since the caller uploading at scale is `site deploy`. The primary automated consumer is the platform's build/deploy sandbox, which runs `base44 site deploy -y --json --git-hash ` with a scoped `apps:deploy` workspace key — so the sandbox and a human at a terminal go through the exact same door. -## The Deployments-API Lane (experimental, env-gated) - -The whole lane is one env var: with `BASE44_DEPLOYMENTS_API=1` (or `true`; internal gate, not user-facing yet) `site deploy` ships through the deployments API — static output and full-stack builds alike — and without it, through the legacy tar.gz upload. `deploymentsApiEnabled()` in `core/site/deployment.ts` is read in exactly two places, both in the command: the transport choice, and the registration of `--git-hash` / `--concurrency`, which exist only on the lane that can honor them (with the gate off they are unknown options, as they were before the lane existed). - -The commit comes from `--git-hash` when passed, otherwise `git rev-parse HEAD`; on the lane with neither available the deploy fails asking for the flag, rather than silently falling back to the tar.gz upload — a deployment is addressed by the commit that produced it, so a build with no address could never be published. +## The Commit Address, and What Is Left of the Archive Upload -On the lane with no worker, the output directory becomes the asset manifest (index.html included — it is only ever excluded from uploads), and the create request carries **no `config`**, which the server answers with the `s3` arm. The CLI PUTs each requested file directly to its presigned URL and finalizes with the index.html bytes; today's serving keeps working because the server stores the result the way the legacy site upload does. Same flow, same commands, same `--git-hash` addressing, same `--json` output. +Without `--git-hash`, a **static** build ships its output directory through the legacy tar.gz upload (`deploySite()`), which publishes as it always did — from `base44 deploy` and `site deploy` alike. A **worker** build has no archive form, so there the flag is required (`workerBuildNeedsGitHashError()`); `base44 deploy` checks that before shipping any resource. The archive upload's other caller is the scaffold deploy in `base44 create`, which runs before a new project has any history at all. Those are the whole of what is left on it — everything else is the lane. -With the gate off, every `site deploy` takes the legacy tar.gz path unchanged — including a full-stack project, whose worker is then not shipped at all. +With no worker, the output directory becomes the asset manifest (index.html included — it is only ever excluded from uploads), and the create request carries **no `config`**, which the server answers with the `s3` arm. The CLI PUTs each requested file directly to its presigned URL and finalizes with the index.html bytes. ## Testing -`TestAPIServer` mocks: `mockDeploymentCreate` (captures the JSON body in `deploymentCreateRequests`; echoes whatever response shape you pass — `asset_uploads` selects the arm: `{type: "cf", ...}`, `{type: "s3", ...}` or `null`), `mockAssetUpload` (serves a Cloudflare-style `POST /cf-assets/upload` target, captures the Authorization header, `?base64=true` query and multipart fields in `assetUploadRequests`, responds 201 with the completion jwt), `mockPresignedUpload(path)` (serves a presigned-style `PUT /presigned{path}` target, captures body/Content-Type/Authorization in `presignedUploadRequests`), `mockDeploymentFinalize` (captures multipart fields in `finalizeRequests` and query strings in `finalizeQueries`). Fixtures: `tests/fixtures/fullstack-project/` (redirect file + `build/server` worker + `build/client` assets with `.assetsignore`) and `tests/fixtures/with-site/` (static output dir) — not git repos, so specs pass `--git-hash`. Unit tests live in `tests/core/site-*.spec.ts`. +`TestAPIServer` mocks: `mockDeploymentCreate` (captures the JSON body in `deploymentCreateRequests`; echoes whatever response shape you pass — `asset_uploads` selects the arm: `{type: "cf", ...}`, `{type: "s3", ...}` or `null`), `mockAssetUpload` (serves a Cloudflare-style `POST /cf-assets/upload` target, captures the Authorization header, `?base64=true` query and multipart fields in `assetUploadRequests`, responds 201 with the completion jwt), `mockPresignedUpload(path)` (serves a presigned-style `PUT /presigned{path}` target, captures body/Content-Type/Authorization in `presignedUploadRequests`), `mockDeploymentFinalize` (captures multipart fields in `finalizeRequests` and query strings in `finalizeQueries`). Fixtures: `tests/fixtures/fullstack-project/` (redirect file + `build/server` worker + `build/client` assets with `.assetsignore`) and `tests/fixtures/with-site/` (static output dir) — specs pass `--git-hash` to take this lane. Unit tests live in `tests/core/site-*.spec.ts`. ## Rules (Deployments-Specific) @@ -89,4 +92,5 @@ With the gate off, every `site deploy` takes the legacy tar.gz path unchanged - **`git_hash` is required** — a build with no commit behind it has no address and could never be published - **One `createDeployment()` call site** — a worker changes its parameters, never the flow around it; do not fork the code path on "full-stack vs static" - **Say "site" to the user** — never "full-stack app"; the presence of a worker is not a distinction user-facing copy makes -- **Legacy behavior stays identical** when no worker artifact exists and the static gate is off — the tar.gz site path must not change +- **`site deploy` never publishes on its own** — the platform's build step runs it against builder-managed apps, where a publish is both wrong and fatal +- **The archive upload is for static builds deployed without `--git-hash` only** — `base44 create`'s scaffold deploy and `deploy` / `site deploy` without the flag; never add a third caller diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index 624abeebc..ecc8d1760 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -1,6 +1,7 @@ import type { Logger } from "@base44-cli/logger"; import { confirm, isCancel } from "@clack/prompts"; import type { Command } from "commander"; +import { Option } from "commander"; import { filterPendingOAuth, promptOAuthFlows, @@ -12,6 +13,7 @@ import { Base44Command, getConnectorsUrl, getDashboardUrl, + parseGitHash, theme, } from "@/cli/utils/index.js"; import { InvalidInputError } from "@/core/errors.js"; @@ -29,6 +31,7 @@ interface DeployOptions { yes?: boolean; build?: boolean; projectRoot?: string; + gitHash?: string; } export async function deployAction( @@ -42,7 +45,7 @@ export async function deployAction( const projectData = await readProjectConfig(options.projectRoot); - if (!hasResourcesToDeploy(projectData)) { + if (!(await hasResourcesToDeploy(projectData))) { return { outroMessage: "No resources found to deploy", }; @@ -120,6 +123,10 @@ export async function deployAction( let actorCompleted = 0; const result = await deployAll(projectData, { + gitHash: options.gitHash, + onSiteProgress: (message) => { + log.message(theme.styles.dim(message)); + }, onVisibilitySet: (level) => { log.success(`App visibility set to ${level}`); }, @@ -179,6 +186,12 @@ export function getDeployCommand(): Command { .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)") + .addOption( + new Option( + "--git-hash ", + "Deploy by the commit the build came from, through the deployments API", + ).argParser(parseGitHash), + ) .action(deployAction); } diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index 4e7d99fe7..dcc187eda 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -4,7 +4,7 @@ import type { Command } from "commander"; import { InvalidArgumentError, Option } from "commander"; import { maybeBuildBeforeDeploy } from "@/cli/commands/project/site-build.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; -import { Base44Command, theme } from "@/cli/utils/index.js"; +import { Base44Command, parseGitHash, theme } from "@/cli/utils/index.js"; import { InvalidInputError } from "@/core/errors.js"; import { DEFAULT_SITE, @@ -14,19 +14,20 @@ import { import type { ProjectWithPaths } from "@/core/project/types.js"; import { DEFAULT_UPLOAD_CONCURRENCY, - deploymentsApiEnabled, deploySite, deployToDeployments, + detectFullStackArtifact, MAX_UPLOAD_CONCURRENCY, - resolveGitHash, + publishDeployment, + workerBuildNeedsGitHashError, } from "@/core/site/index.js"; -import { isGitCommitHash } from "@/core/utils/git.js"; interface DeployOptions { yes?: boolean; build?: boolean; gitHash?: string; concurrency?: number; + publish?: boolean; } async function deployAction( @@ -57,9 +58,7 @@ async function deployAction( } } - return deploymentsApiEnabled() - ? await deployToDeploymentsApi(ctx, project, options) - : await deployTarball(ctx, project); + return deployToDeploymentsApi(ctx, project, options); } async function deployToDeploymentsApi( @@ -69,7 +68,16 @@ async function deployToDeploymentsApi( ): Promise { const { runTask, log, jsonMode } = ctx; const projectRoot = project.root; - const gitHash = await resolveGitHash(projectRoot, options.gitHash); + // First, so a defaulted site block is reported before anything can fail. + const outputDir = siteOutputDir(project); + // Without a commit, a static build keeps the archive upload, live as before. + const gitHash = options.gitHash; + if (!gitHash) { + if (await detectFullStackArtifact(projectRoot)) { + throw workerBuildNeedsGitHashError(); + } + return deployArchive(ctx, outputDir); + } const progressLines: string[] = []; const warnings: string[] = []; @@ -79,7 +87,7 @@ async function deployToDeploymentsApi( await deployToDeployments({ projectRoot, // A build carrying a worker brings its own assets directory instead. - outputDir: siteOutputDir(project), + outputDir, gitHash, concurrency: options.concurrency, progress: { @@ -109,22 +117,41 @@ async function deployToDeploymentsApi( log.warn(warning); } - // No URL: what production serves is decided when the app is published from - // the builder, not by this deploy. + // Deploying is not publishing: this command builds an app's commit, and the + // platform's own build step is its main caller — for an app the builder + // manages, production is repointed there, and publishing here would fail it. + // `base44 deploy` is the door that puts a build live; `--publish` is the same + // step on demand. + const published = options.publish + ? await runTask( + "Publishing site...", + async () => await publishDeployment(gitHash), + { successMessage: "Site published", errorMessage: "Publish failed" }, + ) + : undefined; + + if (published) { + log.message( + `${theme.styles.header("App URL")}: ${theme.colors.links(published.appUrl)}`, + ); + } else { + log.info( + "Not published: production still serves its previous build. Run again with --publish to serve this one.", + ); + } + return { outroMessage: `Deployment ${deploymentId} (commit ${gitHash.slice(0, 12)})`, stdout: jsonMode - ? `${JSON.stringify({ deploymentId, gitHash }, null, 2)}\n` + ? `${JSON.stringify({ deploymentId, gitHash, published: Boolean(published) }, null, 2)}\n` : undefined, }; } -async function deployTarball( - { runTask }: CLIContext, - project: ProjectWithPaths, +async function deployArchive( + { runTask, jsonMode }: CLIContext, + outputDir: string, ): Promise { - const outputDir = siteOutputDir(project); - const { appUrl } = await runTask( "Creating archive and deploying site...", async () => await deploySite(outputDir), @@ -133,8 +160,12 @@ async function deployTarball( errorMessage: "Deployment failed", }, ); - - return { outroMessage: `Visit your site at: ${appUrl}` }; + return { + outroMessage: `Visit your site at: ${appUrl}`, + stdout: jsonMode + ? `${JSON.stringify({ appUrl, published: true }, null, 2)}\n` + : undefined, + }; } function siteOutputDir(project: ProjectWithPaths): string { @@ -144,39 +175,24 @@ function siteOutputDir(project: ProjectWithPaths): string { } export function getSiteDeployCommand(): Command { - const command = new Base44Command("deploy") + 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)"); - - // Registered on the enabled lane only: with the gate off they are absent from - // --help and rejected as unknown options, rather than accepted by a tar.gz - // upload that can honor neither. - if (deploymentsApiEnabled()) { - command.addOption( + .option("--no-build", "Deploy without building (skips the prompt)") + .option("--publish", "Serve this build in production once it is deployed") + .addOption( new Option( "--git-hash ", - "Commit the build came from (defaults to the checkout's HEAD)", + "Deploy by the commit the build came from, through the deployments API", ).argParser(parseGitHash), - ); - command.addOption( + ) + .addOption( new Option("--concurrency ", "Parallel asset uploads") .default(DEFAULT_UPLOAD_CONCURRENCY) .argParser(parseConcurrency), - ); - } - - return command.action(deployAction); -} - -function parseGitHash(value: string): string { - if (!isGitCommitHash(value)) { - throw new InvalidArgumentError( - "Expected a git commit hash (7-64 hex chars).", - ); - } - return value; + ) + .action(deployAction); } function parseConcurrency(value: string): number { diff --git a/packages/cli/src/cli/commands/versions/options.ts b/packages/cli/src/cli/commands/versions/options.ts index 0efdebef0..a0bef294f 100644 --- a/packages/cli/src/cli/commands/versions/options.ts +++ b/packages/cli/src/cli/commands/versions/options.ts @@ -1,5 +1,5 @@ import { InvalidArgumentError, Option } from "commander"; -import { isGitCommitHash } from "@/core/utils/git.js"; +import { parseGitHash } from "@/cli/utils/index.js"; import { DEFAULT_VERSION_UPLOAD_CONCURRENCY, MAX_VERSION_UPLOAD_CONCURRENCY, @@ -25,14 +25,7 @@ export function gitHashOption(): Option { return new Option( "--git-hash ", "Commit the build came from (defaults to the checkout's HEAD)", - ).argParser((value) => { - if (!isGitCommitHash(value)) { - throw new InvalidArgumentError( - "Expected a git commit hash (7-64 hex chars).", - ); - } - return value; - }); + ).argParser(parseGitHash); } export function concurrencyOption(): Option { diff --git a/packages/cli/src/cli/utils/git-hash.ts b/packages/cli/src/cli/utils/git-hash.ts new file mode 100644 index 000000000..caa5c0892 --- /dev/null +++ b/packages/cli/src/cli/utils/git-hash.ts @@ -0,0 +1,12 @@ +import { InvalidArgumentError } from "commander"; +import { isGitCommitHash } from "@/core/utils/git.js"; + +/** Commander argParser for a `--git-hash` flag. */ +export function parseGitHash(value: string): string { + if (!isGitCommitHash(value)) { + throw new InvalidArgumentError( + "Expected a git commit hash (7-64 hex chars).", + ); + } + return value; +} diff --git a/packages/cli/src/cli/utils/index.ts b/packages/cli/src/cli/utils/index.ts index f8313a547..6485a60ed 100644 --- a/packages/cli/src/cli/utils/index.ts +++ b/packages/cli/src/cli/utils/index.ts @@ -3,6 +3,7 @@ export * from "./banner.js"; export * from "./command/index.js"; export * from "./confirm-push.js"; export * from "./datetime.js"; +export * from "./git-hash.js"; export * from "./json.js"; export * from "./prompts.js"; export * from "./runTask.js"; diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 4a6711976..7dc2f9377 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -1,6 +1,6 @@ import { resolve } from "node:path"; import { hasWorkspaceApiKeyAuth } from "@/core/auth/config.js"; -import { ResourceDeploymentError } from "@/core/errors.js"; +import { ApiError, ResourceDeploymentError } from "@/core/errors.js"; import { setAppVisibility } from "@/core/project/api.js"; import type { Visibility } from "@/core/project/schema.js"; import type { ProjectData } from "@/core/project/types.js"; @@ -21,7 +21,13 @@ import { deployFunctionsSequentially, type SingleFunctionDeployResult, } from "@/core/resources/function/deploy.js"; -import { deploySite } from "@/core/site/index.js"; +import { + deploySite, + deployToDeployments, + detectFullStackArtifact, + publishDeployment, + workerBuildNeedsGitHashError, +} from "@/core/site/index.js"; /** * Checks if there are any resources to deploy in the project. @@ -29,7 +35,9 @@ import { deploySite } from "@/core/site/index.js"; * @param projectData - The project configuration and resources * @returns true if there are entities, functions, agents, connectors, or a configured site to deploy */ -export function hasResourcesToDeploy(projectData: ProjectData): boolean { +export async function hasResourcesToDeploy( + projectData: ProjectData, +): Promise { const { project, entities, @@ -40,7 +48,11 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { connectors, authConfig, } = projectData; - const hasSite = Boolean(project.site?.outputDirectory); + // A full-stack build brings its own assets directory, so it is a site to + // deploy even with no site.outputDirectory configured. + const hasSite = + Boolean(project.site?.outputDirectory) || + Boolean(await detectFullStackArtifact(project.root)); const hasEntities = entities.length > 0; const hasFunctions = functions.length > 0; const hasActors = actors.length > 0; @@ -78,6 +90,9 @@ interface DeployAllResult { } interface DeployAllOptions { + /** The commit the built output came from; resolved from the checkout if omitted. */ + gitHash?: string; + onSiteProgress?: (message: string) => void; onActorStart?: (name: string) => void; onActorResult?: (result: SingleActorDeployResult) => void; onFunctionStart?: (names: string[]) => void; @@ -107,6 +122,11 @@ export async function deployAll( authConfig, } = projectData; + // Before anything ships: a worker build cannot go without its commit. + if (!options?.gitHash && (await detectFullStackArtifact(project.root))) { + throw workerBuildNeedsGitHashError(); + } + await setAppVisibility(project.visibility); if (project.visibility) { options?.onVisibilitySet?.(project.visibility); @@ -160,11 +180,79 @@ export async function deployAll( ? [] : (await pushConnectors(connectors)).results; - if (project.site?.outputDirectory) { - const outputDir = resolve(project.root, project.site.outputDirectory); - const { appUrl } = await deploySite(outputDir); - return { appUrl, connectorResults }; + const appUrl = await deployProjectSite(project, options); + return appUrl ? { appUrl, connectorResults } : { connectorResults }; +} + +/** + * Ship the built output, then serve it. + * + * A commit given with `--git-hash` ships through the deployments API, addressed + * by it, so a full-stack build's worker ships too. Without one the static output + * keeps the archive upload, which publishes as it always did; a worker build has + * no archive form, so there the commit is required. + */ +async function deployProjectSite( + project: ProjectData["project"], + options?: DeployAllOptions, +): Promise { + const outputDirectory = project.site?.outputDirectory; + const outputDir = outputDirectory + ? resolve(project.root, outputDirectory) + : null; + const gitHash = options?.gitHash; + + if (gitHash && (outputDir || (await detectFullStackArtifact(project.root)))) { + return shipCommit(project.root, outputDir, gitHash, options); } + if (!outputDir) { + return undefined; + } + const { appUrl } = await deploySite(outputDir); + return appUrl; +} + +async function shipCommit( + projectRoot: string, + outputDir: string | null, + gitHash: string, + options?: DeployAllOptions, +): Promise { + await deployToDeployments({ + projectRoot, + outputDir, + gitHash, + progress: { + onWarning: (message) => options?.onSiteProgress?.(message), + onAssets: ({ totalAssets, newAssets }) => + options?.onSiteProgress?.( + `Found ${totalAssets} static assets (${newAssets} new)`, + ), + }, + }); - return { connectorResults }; + try { + const { appUrl } = await publishDeployment(gitHash); + return appUrl; + } catch (error) { + // An app Base44 builds is published from the builder, where checkpoints and + // branch rules live. Its resources and its build did deploy, so reporting + // that as a failed deploy would be wrong. + if (!publishesFromBuilder(error)) { + throw error; + } + options?.onSiteProgress?.( + "Site deployed. This app is published from the Base44 builder, so production was left where it was.", + ); + return undefined; + } +} + +/** The server's stable code for "this app does not publish by commit". */ +function publishesFromBuilder(error: unknown): boolean { + if (!(error instanceof ApiError)) { + return false; + } + const body = error.responseBody as { error?: { code?: unknown } } | undefined; + return body?.error?.code === "git_commit_hash_not_supported"; } diff --git a/packages/cli/src/core/site/api.ts b/packages/cli/src/core/site/api.ts index 4071d9c10..4cb96112f 100644 --- a/packages/cli/src/core/site/api.ts +++ b/packages/cli/src/core/site/api.ts @@ -1,12 +1,14 @@ import type { KyResponse } from "ky"; import { getAppClient } from "@/core/clients/index.js"; import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import { getSiteUrl } from "@/core/project/api.js"; import type { CreateDeploymentRequest, CreateDeploymentResponse, DeployResponse, FinalizeDeploymentResponse, ModuleType, + PublishedDeployment, WorkerModule, } from "@/core/site/schema.js"; import { @@ -153,3 +155,31 @@ export async function finalizeDeployment( } return result.data; } + +/** + * Serve a deployed commit's build in production. + * + * Deploying and publishing are separate acts server-side — a build lands at its + * commit's address and only a publish repoints production at it — so this is a + * second call rather than a flag on finalize. It is the builder's own publish, + * given the commit instead of a saved version: the platform holds no source for + * an app the CLI deploys, so the commit is how it names what to put live. + */ +export async function publishDeployment( + gitHash: string, +): Promise { + const appClient = getAppClient(); + + try { + // The body is the editor's full app for a logged-in user and a slim record + // for an API key; neither carries anything the CLI doesn't already know. + await appClient.post("deploy", { + json: { git_commit_hash: gitHash }, + timeout: 120_000, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "publishing deployment"); + } + + return { gitHash, appUrl: await getSiteUrl() }; +} diff --git a/packages/cli/src/core/site/deployment.ts b/packages/cli/src/core/site/deployment.ts index 10af687e5..f0365ca54 100644 --- a/packages/cli/src/core/site/deployment.ts +++ b/packages/cli/src/core/site/deployment.ts @@ -26,24 +26,6 @@ interface WorkerBuild { const NO_ASSETS: AssetManifestResult = { manifest: {}, filesByHash: new Map() }; -const DEPLOYMENTS_API_ENV = "BASE44_DEPLOYMENTS_API"; - -/** - * Internal gate for the deployments-API lane — static output and full-stack - * builds alike, neither user-facing yet. With it off `site deploy` takes the - * legacy tar.gz upload and the flags that only mean something on this lane are - * not registered at all, so the whole lane is one env var away from existing. - * - * It is the only thing that selects the transport: whether the build carries a - * worker changes what `deployToDeployments()` sends, never which flow runs. - */ -export function deploymentsApiEnabled( - env: NodeJS.ProcessEnv = process.env, -): boolean { - const value = env[DEPLOYMENTS_API_ENV]; - return value === "1" || value === "true"; -} - /** * Deploy a build for a commit through the deployments API: hash its static * assets into a manifest, create the deployment at the commit's address, upload diff --git a/packages/cli/src/core/site/git-hash.ts b/packages/cli/src/core/site/git-hash.ts index d4fe4d058..efef62da1 100644 --- a/packages/cli/src/core/site/git-hash.ts +++ b/packages/cli/src/core/site/git-hash.ts @@ -2,27 +2,19 @@ import { execa } from "execa"; import { InvalidInputError } from "@/core/errors.js"; import { isGitCommitHash } from "@/core/utils/git.js"; -export async function resolveGitHash( - projectRoot: string, - explicit?: string, -): Promise { - const hash = explicit ?? (await gitHead(projectRoot)); - if (!hash || !isGitCommitHash(hash)) { - throw new InvalidInputError( - explicit - ? `'${explicit}' is not a git commit hash.` - : "Deployments are addressed by the commit that produced the build, and no git commit was found.", - { - hints: [ - { - message: - "Run the deploy from a git checkout, or pass the commit explicitly with --git-hash.", - }, - ], - }, - ); - } - return hash; +/** A worker build has no archive form, so it can only ship by the commit it came from. */ +export function workerBuildNeedsGitHashError(): InvalidInputError { + return new InvalidInputError( + "A full-stack build deploys by the commit that produced it, and none was given.", + { + hints: [ + { + message: + "Pass it with --git-hash, e.g. --git-hash $(git rev-parse HEAD).", + }, + ], + }, + ); } /** @@ -30,15 +22,17 @@ export async function resolveGitHash( * * For a version the commit is PROVENANCE — recorded, never hashed, and not part * of what the version is — so a build outside a checkout is still a complete - * version. That is the whole difference from {@link resolveGitHash}, whose - * caller addresses a deployment BY the hash and so cannot go without one. + * version, and the local checkout is a fair default. */ export async function resolveProvenanceCommit( projectRoot: string, explicit?: string, ): Promise { if (explicit) { - return await resolveGitHash(projectRoot, explicit); + if (!isGitCommitHash(explicit)) { + throw new InvalidInputError(`'${explicit}' is not a git commit hash.`); + } + return explicit; } const hash = await gitHead(projectRoot); return hash && isGitCommitHash(hash) ? hash : undefined; diff --git a/packages/cli/src/core/site/schema.ts b/packages/cli/src/core/site/schema.ts index 0c6312862..2b6e9e687 100644 --- a/packages/cli/src/core/site/schema.ts +++ b/packages/cli/src/core/site/schema.ts @@ -202,3 +202,9 @@ export const FinalizeDeploymentResponseSchema = z export type FinalizeDeploymentResponse = z.infer< typeof FinalizeDeploymentResponseSchema >; + +/** A commit's build now serving production, and where. */ +export interface PublishedDeployment { + gitHash: string; + appUrl: string; +} diff --git a/packages/cli/tests/cli/build.spec.ts b/packages/cli/tests/cli/build.spec.ts index 18dc8c74d..7140bfc64 100644 --- a/packages/cli/tests/cli/build.spec.ts +++ b/packages/cli/tests/cli/build.spec.ts @@ -98,9 +98,23 @@ describe("deploy --build", () => { 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.api.mockDeploymentCreate({ + deployment_id: "test-app-git-a1b2c3d4e5f6", + session_id: "3f9a1c07b8e44d2f", + asset_uploads: null, + }); + t.api.mockDeploymentFinalize({ + deployment_id: "test-app-git-a1b2c3d4e5f6", + }); + + const result = await t.run( + "site", + "deploy", + "--yes", + "--build", + "--git-hash", + "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + ); t.expectResult(result).toSucceed(); expect(await t.readProjectFile("build-env.txt")).toBe( diff --git a/packages/cli/tests/cli/deploy.spec.ts b/packages/cli/tests/cli/deploy.spec.ts index ab3b3313c..33b75d4a8 100644 --- a/packages/cli/tests/cli/deploy.spec.ts +++ b/packages/cli/tests/cli/deploy.spec.ts @@ -55,18 +55,18 @@ describe("deploy command (unified)", () => { // The deployments lane belongs to `site deploy`. This command ships the site // through the legacy tar.gz step, so it has no commit to address and none of // the lane's flags. - it("does not take the deployments-lane flags", async () => { + it("takes the commit that produced the build, but not upload tuning", async () => { + // The site ships through the deployments API now, so the deploy needs the + // address it is made at. Concurrency stays on `site deploy`, whose caller + // is the one uploading at scale. await t.givenLoggedInWithProject(fixture("with-site")); - const gitHash = await t.run("deploy", "-y", "--git-hash", "a1b2c3d4e5f6"); const concurrency = await t.run("deploy", "-y", "--concurrency", "5"); const help = await t.run("deploy", "--help"); - t.expectResult(gitHash).toFail(); - t.expectResult(gitHash).toContain("unknown option"); t.expectResult(concurrency).toFail(); t.expectResult(concurrency).toContain("unknown option"); - t.expectResult(help).toNotContain("--git-hash"); + t.expectResult(help).toContain("--git-hash"); t.expectResult(help).toNotContain("--concurrency"); }); @@ -283,4 +283,89 @@ describe("deploy command (unified)", () => { t.expectResult(result).toContain("connect.stripe.com/setup/claim/xxx"); t.expectResult(result).toContain("Connectors dashboard"); }); + + describe("shipping the site", () => { + const GIT_HASH = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"; + const DEPLOYMENT_ID = "test-app-git-a1b2c3d4e5f6"; + + function mockProjectResources(): void { + t.api.mockEntitiesPush({ created: [], updated: [], deleted: [] }); + t.api.mockSingleFunctionDeploy({ status: "deployed" }); + t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); + } + + function mockDeployment(): void { + t.api.mockDeploymentCreate({ + deployment_id: DEPLOYMENT_ID, + session_id: "3f9a1c07b8e44d2f", + asset_uploads: null, + }); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + } + + it("deploys the site through the deployments API and publishes it", async () => { + // What the archive upload did in one step: a deploy still leaves the app + // live, now addressed by the commit that produced the build. + await t.givenLoggedInWithProject(fixture("full-project")); + mockProjectResources(); + mockDeployment(); + t.api.mockDeploymentPublish({ + app_url: "https://full-project.base44.app", + }); + + const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("https://full-project.base44.app"); + expect(t.api.publishedCommits).toEqual([GIT_HASH]); + }); + + it("still succeeds when the app is published from the builder", async () => { + // Its resources and its build did deploy; only production was left alone. + await t.givenLoggedInWithProject(fixture("full-project")); + mockProjectResources(); + mockDeployment(); + t.api.mockDeploymentPublishError(400, { + error: { + code: "git_commit_hash_not_supported", + message: + "This app's source is managed by Base44. Deploy a saved version with checkpoint_id instead.", + details: {}, + }, + }); + + const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("published from the Base44 builder"); + }); + + it("fails when the publish itself fails", async () => { + await t.givenLoggedInWithProject(fixture("full-project")); + mockProjectResources(); + mockDeployment(); + t.api.mockDeploymentPublishError(404, { + message: "No build exists for this commit.", + extra_data: { code: "build_not_found" }, + }); + + const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toFail(); + }); + + it("keeps the archive upload, which goes live, without --git-hash", async () => { + await t.givenLoggedInWithProject(fixture("full-project")); + mockProjectResources(); + t.api.mockSiteDeploy({ app_url: "https://full-project.base44.app" }); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("https://full-project.base44.app"); + expect(t.api.deploymentCreateRequests).toHaveLength(0); + }); + }); }); diff --git a/packages/cli/tests/cli/fullstack_deploy.spec.ts b/packages/cli/tests/cli/fullstack_deploy.spec.ts index 72ab59cf2..3d18e3a2b 100644 --- a/packages/cli/tests/cli/fullstack_deploy.spec.ts +++ b/packages/cli/tests/cli/fullstack_deploy.spec.ts @@ -15,7 +15,7 @@ function assetHash(appId: string, content: string): string { const INDEX_HTML = "

Hello

\n"; const APP_JS = 'console.log("app");\n'; -/** The fixture is not a git repo, so every deploy passes --git-hash. */ +/** A worker build deploys only by an explicit --git-hash. */ const GIT_HASH = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"; const DEPLOYMENT_ID = "test-app-git-a1b2c3d4e5f6"; const SESSION_ID = "3f9a1c07b8e44d2f"; @@ -52,9 +52,18 @@ describe("site deploy command (full-stack)", () => { return { htmlHash, jsHash }; } + it("base44 deploy refuses a worker build without --git-hash before shipping anything", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("--git-hash"); + expect(t.api.deploymentCreateRequests).toHaveLength(0); + }); + it("deploys a full-stack artifact: manifest hashes, bucket relay, finalize modules", async () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); - t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); const { htmlHash, jsHash } = mockHappyPath(); const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); @@ -124,7 +133,6 @@ describe("site deploy command (full-stack)", () => { it("finalizes with a null completion token when every asset is already stored", async () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); - t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); t.api.mockDeploymentCreate({ deployment_id: DEPLOYMENT_ID, session_id: SESSION_ID, @@ -146,7 +154,6 @@ describe("site deploy command (full-stack)", () => { it("normalizes and requires a commit hash", async () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); - t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); const noHash = await t.run("site", "deploy", "-y"); t.expectResult(noHash).toFail(); @@ -168,7 +175,6 @@ describe("site deploy command (full-stack)", () => { it("outputs a single JSON document with --json", async () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); - t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); mockHappyPath(); const result = await t.run( @@ -185,12 +191,12 @@ describe("site deploy command (full-stack)", () => { expect(parsed).toEqual({ deploymentId: DEPLOYMENT_ID, gitHash: GIT_HASH, + published: false, }); }); it("forwards a config with no nodejs_compat flag as-is (e.g. Astro 6)", async () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); - t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); const configPath = join( t.getTempDir(), "project", @@ -212,7 +218,6 @@ describe("site deploy command (full-stack)", () => { it("retries a bucket upload after a transient failure, resending the body", async () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); - t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); const htmlHash = assetHash(t.api.appId, INDEX_HTML); t.api.mockDeploymentCreate({ deployment_id: DEPLOYMENT_ID, @@ -248,7 +253,6 @@ describe("site deploy command (full-stack)", () => { it("surfaces a session-expired error when Cloudflare rejects the session jwt", async () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); - t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); t.api.mockDeploymentCreate({ deployment_id: DEPLOYMENT_ID, session_id: SESSION_ID, @@ -269,7 +273,6 @@ describe("site deploy command (full-stack)", () => { it("fails when the deployment API rejects the create call", async () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); - t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); t.api.mockError("post", `/api/apps/${t.api.appId}/deployments`, { status: 422, body: { message: "unsupported artifact" }, diff --git a/packages/cli/tests/cli/site_deploy.spec.ts b/packages/cli/tests/cli/site_deploy.spec.ts index 74082fe5d..438dd543e 100644 --- a/packages/cli/tests/cli/site_deploy.spec.ts +++ b/packages/cli/tests/cli/site_deploy.spec.ts @@ -1,9 +1,9 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { fixture, setupCLITests } from "./testkit/index.js"; -/** The fullstack fixture is not a git repo, so these deploys pass --git-hash. */ +/** The deployments API is used only for an explicit --git-hash. */ const GIT_HASH = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"; const DEPLOYMENT_ID = "test-app-git-a1b2c3d4e5f6"; const SESSION_ID = "3f9a1c07b8e44d2f"; @@ -26,10 +26,11 @@ describe("site deploy command", () => { // Nothing is built in the fixture, so ./dist is missing and the deploy fails there. await t.givenLoggedInWithProject(fixture("basic")); - const result = await t.run("site", "deploy", "-y"); + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); t.expectResult(result).toFail(); t.expectResult(result).toContain("using the default site commands"); + t.expectResult(result).toContain("dist"); t.expectResult(result).toNotContain("No site configuration found"); }); @@ -44,18 +45,75 @@ describe("site deploy command", () => { it("deploys site successfully", async () => { await t.givenLoggedInWithProject(fixture("with-site")); - t.api.mockSiteDeploy({ app_url: "https://my-app.base44.app" }); + t.api.mockDeploymentCreate({ + deployment_id: DEPLOYMENT_ID, + session_id: SESSION_ID, + asset_uploads: null, + }); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain(`Deployment ${DEPLOYMENT_ID}`); + }); + + it("deploys without publishing, so the platform's build step is unaffected", async () => { + // The platform builds a builder-managed app with this command and repoints + // production itself; publishing here would fail that app outright. + await t.givenLoggedInWithProject(fixture("with-site")); + t.api.mockDeploymentCreate({ + deployment_id: DEPLOYMENT_ID, + session_id: SESSION_ID, + asset_uploads: null, + }); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + t.api.mockDeploymentPublish(); + + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Run again with --publish"); + expect(t.api.publishedCommits).toEqual([]); + }); + + it("keeps the archive upload, which goes live, without --git-hash", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + t.api.mockSiteDeploy({ app_url: "https://with-site.base44.app" }); const result = await t.run("site", "deploy", "-y"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Site deployed successfully"); + t.expectResult(result).toContain("https://with-site.base44.app"); + expect(t.api.deploymentCreateRequests).toHaveLength(0); + }); + + it("--publish serves the deployed commit", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + t.api.mockDeploymentCreate({ + deployment_id: DEPLOYMENT_ID, + session_id: SESSION_ID, + asset_uploads: null, + }); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + t.api.mockDeploymentPublish({ app_url: "https://my-app.base44.app" }); + + const result = await t.run( + "site", + "deploy", + "-y", + "--git-hash", + GIT_HASH, + "--publish", + ); + + t.expectResult(result).toSucceed(); t.expectResult(result).toContain("https://my-app.base44.app"); + expect(t.api.publishedCommits).toEqual([GIT_HASH]); }); it("deploys the Workers build for a full-stack project", async () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); - t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); t.api.mockDeploymentCreate({ deployment_id: DEPLOYMENT_ID, session_id: SESSION_ID, @@ -70,11 +128,10 @@ describe("site deploy command", () => { t.expectResult(result).toContain(DEPLOYMENT_ID); }); - it("prefers the Workers build over the tar.gz upload when both are possible", async () => { - // A full-stack artifact carries the server too, so uploading the static + it("prefers the Workers build over the configured output directory", async () => { + // A full-stack artifact carries the server too, so deploying the static // output directory instead would silently drop the worker. await t.givenLoggedInWithProject(fixture("fullstack-project")); - t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); await writeFile( join(t.getTempDir(), "project", "base44", "config.jsonc"), JSON.stringify({ @@ -82,7 +139,6 @@ describe("site deploy command", () => { site: { outputDirectory: "build/client" }, }), ); - t.api.mockSiteDeploy({ app_url: "https://legacy.base44.app" }); t.api.mockDeploymentCreate({ deployment_id: DEPLOYMENT_ID, session_id: SESSION_ID, @@ -93,19 +149,22 @@ describe("site deploy command", () => { const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); t.expectResult(result).toSucceed(); - // The deployment id is the tell: only the deployments API reports one. t.expectResult(result).toContain(DEPLOYMENT_ID); - t.expectResult(result).toNotContain("https://legacy.base44.app"); + // The worker's config is what makes the server store the assets on + // Cloudflare; a static create would carry none. + expect( + (t.api.deploymentCreateRequests[0] as { config?: unknown }).config, + ).toBeDefined(); }); it("fails when API returns error", async () => { await t.givenLoggedInWithProject(fixture("with-site")); - t.api.mockSiteDeployError({ + t.api.mockDeploymentCreateError({ status: 413, body: { error: "Site too large" }, }); - const result = await t.run("site", "deploy", "-y"); + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); t.expectResult(result).toFail(); }); diff --git a/packages/cli/tests/cli/static_site_deployments.spec.ts b/packages/cli/tests/cli/static_site_deployments.spec.ts index 46f81e35d..d223c02b5 100644 --- a/packages/cli/tests/cli/static_site_deployments.spec.ts +++ b/packages/cli/tests/cli/static_site_deployments.spec.ts @@ -33,7 +33,7 @@ interface CreateBody { asset_manifest: Record; } -describe("site deploy command (static site through the deployments API, env-gated)", () => { +describe("site deploy command (static site through the deployments API)", () => { const t = setupCLITests(); /** The s3 create arm: presigned PUT targets for the requested paths. */ @@ -65,41 +65,8 @@ describe("site deploy command (static site through the deployments API, env-gate return await readFile(join(fixture("with-site"), "site-output", name)); } - it("keeps the legacy tar.gz site upload when the gate is off", async () => { - await t.givenLoggedInWithProject(fixture("with-site")); - t.api.mockSiteDeploy({ app_url: "https://legacy.example.com" }); - - const result = await t.run("site", "deploy", "-y"); - - t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("https://legacy.example.com"); - expect(t.api.deploymentCreateRequests).toHaveLength(0); - }); - - it("hides the lane's flags when the gate is off", async () => { - await t.givenLoggedInWithProject(fixture("with-site")); - - const gitHash = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); - const concurrency = await t.run( - "site", - "deploy", - "-y", - "--concurrency", - "5", - ); - const help = await t.run("site", "deploy", "--help"); - - t.expectResult(gitHash).toFail(); - t.expectResult(gitHash).toContain("unknown option"); - t.expectResult(concurrency).toFail(); - t.expectResult(concurrency).toContain("unknown option"); - t.expectResult(help).toNotContain("--git-hash"); - t.expectResult(help).toNotContain("--concurrency"); - }); - it("deploys the site output through the deployments API when gated on", async () => { await t.givenLoggedInWithProject(fixture("with-site")); - t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); mockStaticCreate(["/main.js", "/styles.css"]); t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); @@ -146,7 +113,6 @@ describe("site deploy command (static site through the deployments API, env-gate it("sends no PUTs and still finalizes when every asset is already stored", async () => { await t.givenLoggedInWithProject(fixture("with-site")); - t.givenEnv({ BASE44_DEPLOYMENTS_API: "true" }); mockStaticCreate([]); t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); @@ -163,7 +129,6 @@ describe("site deploy command (static site through the deployments API, env-gate it("emits a single JSON document with --json", async () => { await t.givenLoggedInWithProject(fixture("with-site")); - t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); mockStaticCreate(["/main.js", "/styles.css"]); t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); @@ -180,6 +145,7 @@ describe("site deploy command (static site through the deployments API, env-gate expect(JSON.parse(result.stdout)).toEqual({ deploymentId: DEPLOYMENT_ID, gitHash: GIT_HASH, + published: false, }); }); @@ -187,7 +153,6 @@ describe("site deploy command (static site through the deployments API, env-gate // A site deploy reads no entity files, so one it can't parse is unrelated — // builder-managed entity schemas failed every publish through this path. await t.givenLoggedInWithProject(fixture("with-site")); - t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); const entitiesDir = join(t.getTempDir(), "project", "base44", "entities"); await mkdir(entitiesDir, { recursive: true }); await writeFile( @@ -205,7 +170,6 @@ describe("site deploy command (static site through the deployments API, env-gate it("reports the rejected status in the --json error envelope", async () => { await t.givenLoggedInWithProject(fixture("with-site")); - t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); t.api.mockError("post", "/api/apps/test-app-id/deployments", { status: 401, body: { message: "API key is not valid" }, @@ -228,9 +192,9 @@ describe("site deploy command (static site through the deployments API, env-gate }); }); - it("requires a commit hash outside a git checkout", async () => { - await t.givenLoggedInWithProject(fixture("with-site")); - t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); + it("requires --git-hash for a worker build", async () => { + // A static build keeps the archive upload; a worker build has no archive form. + await t.givenLoggedInWithProject(fixture("fullstack-project")); const result = await t.run("site", "deploy", "-y"); @@ -241,7 +205,6 @@ describe("site deploy command (static site through the deployments API, env-gate it("rejects a --git-hash that is not a commit hash", async () => { await t.givenLoggedInWithProject(fixture("with-site")); - t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); const result = await t.run("site", "deploy", "-y", "--git-hash", "nope"); @@ -252,7 +215,6 @@ describe("site deploy command (static site through the deployments API, env-gate it("uploads every asset under a --concurrency override", async () => { await t.givenLoggedInWithProject(fixture("with-site")); - t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); mockStaticCreate(["/main.js", "/styles.css"]); t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); @@ -272,7 +234,6 @@ describe("site deploy command (static site through the deployments API, env-gate it("rejects a --concurrency outside the allowed range", async () => { await t.givenLoggedInWithProject(fixture("with-site")); - t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); const zero = await t.run("site", "deploy", "-y", "--concurrency", "0"); const huge = await t.run("site", "deploy", "-y", "--concurrency", "999"); @@ -285,7 +246,6 @@ describe("site deploy command (static site through the deployments API, env-gate it("prefers a full-stack artifact over the static lane (cf arm)", async () => { await t.givenLoggedInWithProject(fixture("fullstack-project")); - t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); t.api.mockDeploymentCreate({ deployment_id: DEPLOYMENT_ID, session_id: SESSION_ID, diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index ccc2b0941..0ebdba5bb 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -261,6 +261,11 @@ interface DeploymentFinalizeResponse { deployment_id: string; } +interface DeploymentPublishResponse { + /** Served by the published-url route the CLI reads after publishing. */ + app_url: string; +} + /** A parsed part of a multipart/form-data request body. */ interface MultipartField { name: string; @@ -773,6 +778,9 @@ export class TestAPIServer { /** Captured query strings of finalize requests, for the session id. */ readonly finalizeQueries: Record[] = []; + /** Commits whose builds a publish was asked to serve, in call order. */ + readonly publishedCommits: string[] = []; + /** * Mock POST /api/apps/{appId}/deployments. Captures the JSON request body * in `deploymentCreateRequests`. @@ -978,6 +986,47 @@ export class TestAPIServer { return this; } + /** + * Serves the publish that puts a deployed commit live (`/deploy` given a + * `git_commit_hash`), capturing the commits it was called with in + * `publishedCommits`, and the published URL the CLI reads after it. + */ + mockDeploymentPublish( + response: Partial = {}, + ): this { + const { app_url = `https://${this.appId}.base44.app`, ...rest } = response; + this.pendingRoutes.push({ + method: "POST", + path: `/api/apps/${this.appId}/deploy`, + handler: (req, res) => { + const gitHash = String(req.body?.git_commit_hash); + this.publishedCommits.push(gitHash); + // What a logged-in user gets: the editor's full app, not the slim + // record an API key gets, so it names neither the commit nor the time. + res.status(200).json({ + id: this.appId, + last_deployed_git_commit_hash: gitHash, + last_deployed_source_type: "git", + ...rest, + }); + }, + }); + return this.mockSiteUrl({ url: app_url }); + } + + /** Answers a publish with an error, e.g. a commit whose build is missing. */ + mockDeploymentPublishError(status: number, body: unknown): this { + this.pendingRoutes.push({ + method: "POST", + path: `/api/apps/${this.appId}/deploy`, + handler: (req, res) => { + this.publishedCommits.push(String(req.body?.git_commit_hash)); + res.status(status).json(body); + }, + }); + return this; + } + // ─── SECRETS ENDPOINTS ─────────────────────────────────── mockSecretsList(response: SecretsListResponse): this { @@ -1100,10 +1149,10 @@ export class TestAPIServer { ); } - mockSiteDeployError(error: ErrorResponse): this { + mockDeploymentCreateError(error: ErrorResponse): this { return this.addErrorRoute( "POST", - `/api/apps/${this.appId}/deploy-dist`, + `/api/apps/${this.appId}/deployments`, error, ); }