Skip to content
Draft
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` now ships the site through the deployments API — the same lane `base44 site deploy` uses — and publishes what it deployed, so a full-stack build's worker is no longer silently dropped. It takes `--git-hash <hash>` (the commit the build came from, defaulting to the checkout's HEAD) and `--no-publish`. A project with a `site.outputDirectory` and no commit behind it still deploys through the legacy archive upload, with a warning.
- `base44 site deploy` is no longer behind `BASE44_DEPLOYMENTS_API`: it always goes through the deployments API, and `--git-hash` / `--concurrency` are always available. Setting the env var now does nothing. It still does **not** publish what it deploys — `--publish` asks for that — because the platform's own build step runs it against builder-managed apps, which are published from the builder.
- 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
30 changes: 18 additions & 12 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, --no-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, `POST deployments/{git_hash}/publish` (`publishDeployment()` in `api.ts`), and the server takes it only for an app whose source Base44 does not manage — a builder-managed app is published from the builder, where checkpoints and branch rules live.

Which command publishes is therefore not a style choice:

- **`base44 deploy` publishes by default** (`--no-publish` opts out). It is the door a developer uses on their own project, where the archive upload it replaces always left the app live.
- **`base44 site deploy` does not** (`--publish` asks for it). 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.

## Git Hash Resolution

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

## 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,25 +62,23 @@ 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>] [--no-publish]`** ships the project's resources and then its site, through this same lane, and publishes what it deployed. It takes `--git-hash` because a deployment needs the address it is made at; 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 Address, and What Is Left of the Archive Upload

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 comes from `--git-hash` when passed, otherwise `git rev-parse HEAD`. `site deploy` with neither fails asking for the flag: its build has no address, so nothing could publish it later.

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.
`base44 deploy` is softer, in the one case where a project legitimately has no history: with a `site.outputDirectory` and no commit, it warns and ships that directory through the legacy tar.gz upload (`deploySite()`), which publishes as it always did. A **worker** build has no archive form, so there a missing commit is fatal. 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 two 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

Expand All @@ -89,4 +94,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 builds with no commit only** — `base44 create`'s scaffold deploy and a `base44 deploy` in a non-git project; never add a third caller
30 changes: 29 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 { InvalidArgumentError, Option } from "commander";
import {
filterPendingOAuth,
promptOAuthFlows,
Expand All @@ -24,11 +25,14 @@ import type {
ConnectorSyncResult,
StripeSyncResult,
} from "@/core/resources/connector/index.js";
import { isGitCommitHash } from "@/core/utils/git.js";

interface DeployOptions {
yes?: boolean;
build?: boolean;
projectRoot?: string;
gitHash?: string;
publish?: boolean;
}

export async function deployAction(
Expand All @@ -42,7 +46,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 +124,11 @@ export async function deployAction(
let actorCompleted = 0;

const result = await deployAll(projectData, {
gitHash: options.gitHash,
publish: options.publish,
onSiteProgress: (message) => {
log.message(theme.styles.dim(message));
},
onVisibilitySet: (level) => {
log.success(`App visibility set to ${level}`);
},
Expand Down Expand Up @@ -179,6 +188,16 @@ 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)")
.option(
"--no-publish",
"Deploy the site without serving it in production yet",
)
.addOption(
new Option(
"--git-hash <hash>",
"Commit the built site came from (defaults to the checkout's HEAD)",
).argParser(parseGitHash),
)
.action(deployAction);
}

Expand Down Expand Up @@ -212,3 +231,12 @@ function printStripeResult(r: StripeSyncResult, log: Logger): void {
}
log.info(` Connectors dashboard: ${theme.colors.links(getConnectorsUrl())}`);
}

function parseGitHash(value: string): string {
if (!isGitCommitHash(value)) {
throw new InvalidArgumentError(
"Expected a git commit hash (7-64 hex chars).",
);
}
return value;
}
Loading
Loading