Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

### Changed

- `base44 deploy --git-hash <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.
Expand Down
36 changes: 20 additions & 16 deletions docs/deployments.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
# 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.

**One flow, one `createDeployment()` call.** Whether the build carries a user worker changes only what that call sends, so there are no parallel full-stack and static flows to keep in step: `deployToDeployments()` checks for a worker itself, and a worker's `config` (framework builds — React Router 7, TanStack Start, Astro 6, vinext — anything built with `@cloudflare/vite-plugin`) is what makes the server answer with the `cf` arm. No worker means no `config` and the `s3` arm. That is also the progressive-upgrade path: when a static app adopts a server framework, its emitted wrangler artifact starts being detected, the create request starts carrying the worker config, and the server flips arms — one CLI protocol, zero CLI change.

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**.

Expand Down Expand Up @@ -55,29 +62,25 @@ Entry = `main` from the wrangler config. With `no_bundle: true`, every file unde

## Command UX

**`base44 site deploy [--git-hash <hash>] [--concurrency <n>] [--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 <id> (commit <hash>)`. Under `--json`, stdout is a single `{deploymentId, gitHash}` document.
**`base44 site deploy [--git-hash <hash>] [--concurrency <n>] [--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 <id> (commit <hash>)`. 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 <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 <commit>` 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)

Expand All @@ -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
15 changes: 14 additions & 1 deletion packages/cli/src/cli/commands/project/deploy.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -12,6 +13,7 @@ import {
Base44Command,
getConnectorsUrl,
getDashboardUrl,
parseGitHash,
theme,
} from "@/cli/utils/index.js";
import { InvalidInputError } from "@/core/errors.js";
Expand All @@ -29,6 +31,7 @@ interface DeployOptions {
yes?: boolean;
build?: boolean;
projectRoot?: string;
gitHash?: string;
}

export async function deployAction(
Expand All @@ -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",
};
Expand Down Expand Up @@ -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}`);
},
Expand Down Expand Up @@ -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 <hash>",
"Deploy by the commit the build came from, through the deployments API",
).argParser(parseGitHash),
)
.action(deployAction);
}

Expand Down
Loading
Loading