Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Test fixtures are hashed byte-for-byte by the deploy tests; CRLF checkout
# on Windows would change the bytes and break the content-addressed hashes.
packages/cli/tests/fixtures/** text=auto eol=lf
1 change: 1 addition & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ Read these when working on the relevant area:
- **[Adding or modifying CLI commands](commands.md)** - Factory pattern, `runCommand()`, `runTask()`, `CLIContext`, theming, `chalk` ban
- **[Making API calls](api-patterns.md)** - HTTP clients, Zod snake_case-to-camelCase transforms, `ApiError.fromHttpError()`
- **[Working with resources](resources.md)** - `Resource<T>` interface, adding new resources, site module, unified deploy
- **[Full-stack deployments](deployments.md)** - Workers-based deploys, wrangler config, asset manifest hashing, promote/rollback, deployment logs
- **[Plugins](plugins.md)** - Plugin config, namespaces, entity extension rules, function namespacing, pull/deploy behavior
- **[Error handling](error-handling.md)** - Error hierarchy, throwing patterns, error codes, `CLIExitError`, `process.exit` ban
- **[Writing tests](testing.md)** - Testkit, Given/When/Then pattern, API mocks, fixtures, test overrides
Expand Down
54 changes: 54 additions & 0 deletions docs/deployments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Full-Stack Deployments

**Keywords:** deployments, full-stack, Cloudflare Workers, wrangler, no_bundle, asset manifest, hash, buckets, finalize, promote, rollback, deployment logs, .assetsignore, .wrangler/deploy/config.json

Full-stack deployments host framework builds (React Router 7, TanStack Start, Astro 6, vinext — anything built with `@cloudflare/vite-plugin`) as Workers on Base44. The core module is `src/core/deployments/` (`wrangler-config.ts`, `manifest.ts`, `modules.ts`, `upload.ts`, `api.ts`, `deploy.ts`).

## Artifact Detection

`detectFullStackArtifact(projectRoot)` checks, in order:

1. `.wrangler/deploy/config.json` — redirect file emitted by `@cloudflare/vite-plugin`; its `configPath` points at the generated `wrangler.json`, **relative to the redirect file's directory**
2. Root `wrangler.jsonc` / `wrangler.json` — hand-authored config (JSON5-parsed)
3. Root `wrangler.toml` — **unsupported**, fails with a clear error

The resolved config must have `no_bundle: true`; otherwise the deploy fails with "this framework's output requires bundling; not yet supported". Any non-empty binding (`kv_namespaces`, `d1_databases`, `r2_buckets`, `durable_objects.bindings`, `services`, `queues.*`, `hyperdrive`, `analytics_engine_datasets`, `vectorize`) fails fast; `vars` are allowed and passed through in the create payload's `config.vars`.

Framework quirks observed in the wild: extra redirect-file fields (`auxiliaryWorkers`, Astro 6's `prerenderWorkerConfigPath`) are ignored for now, and a generated config with no `nodejs_compat` compatibility flag (Astro 6 can emit empty `compatibility_flags`) produces a warning — the flag is not injected, since the fix belongs in the framework's adapter settings.

## API Contract (app-scoped, via `getAppClient()`)

1. `POST deployments` — JSON body: `target` (`preview`/`production`), `framework`, `config` (`main`, `compatibility_date`, `compatibility_flags`, `assets_config`, `vars`), `modules` (`[{name, size, type}]`), `asset_manifest` (`{"/path": {hash, size}}`). Returns `deployment_id`, `script_name`, `immutable_url`, and `asset_upload` (`{jwt, buckets, upload_url}` or `null` when there are no assets; empty `buckets` means the jwt is already the completion token).
2. **Asset bucket upload** — for each bucket, `POST` multipart/form-data **directly** to `asset_upload.upload_url` with `Authorization: Bearer <jwt>` and `?base64=true`; each field: name = file hash, value = base64 file bytes, contentType = the file's real MIME type. Buckets upload with concurrency 3; each bucket retries up to 3 times with exponential backoff. The final response (HTTP 201) returns `{"result": {"jwt": "<completion token>"}}`. A 401 after retries surfaces "upload session expired — rerun deploy" (re-running re-creates the deployment; already-stored hashes won't be in the new buckets).
3. `POST deployments/{id}/finalize` — multipart: field `payload` = JSON `{"completion_jwt": string|null}` plus one file field per module (name = module path, contentType `application/javascript+module` for esm / `application/source-map` for `.map`). Returns `status`, `deployment_id`, `urls` (`[{url, kind: "immutable"|"production"}]`).
4. `GET deployments?limit=20` / `GET deployments/{id}` — list/get, rows: `deployment_id`, `status` (`ready`/`failed`/`in_progress`), `target`, `created_at`, `urls`, `framework`.
5. `POST deployments/{id}/promote`, `POST deployments/rollback` — return `{deployment_id, urls}`.
6. `GET deployments/{id}/logs?since=&until=&limit=&order=` and `GET deployments/logs?alias=production&...` — `{logs: [{time, level, message}]}` (same row shape as function logs).

## Asset Manifest & Hashing

`hash = first 32 hex chars of sha256(utf8(app_id) || raw file bytes)` — see `hashAsset()` in `src/core/deployments/manifest.ts`. The app-id salt is a cache-poisoning defense: a tenant can only produce hash collisions with its own files.

The assets directory (from `assets.directory`, relative to the config dir) is walked recursively. `.assetsignore` at the assets root is honored (minimal gitignore-style matching: exact names, `*`/`**` globs, directory patterns; no negation). `.assetsignore` itself, `wrangler.json`, and `.dev.vars` are always skipped. Files over 25 MiB fail with a per-file error; total file count is capped at 100,000. Manifest keys are `/`-prefixed forward-slash paths.

## Module Collection

Entry = `main` from the wrangler config. With `no_bundle: true`, every file under the config dir matching the `rules` globs is included, excluding `wrangler.json` and `.dev.vars`, preserving relative paths as module names. `.map` files next to modules (or all of them when `upload_source_maps` is set) are included as `sourcemap`. Total module payload is capped at 40 MB.

## Command UX

- **`base44 deploy [--prod] [--build] [--prebuilt]`** — after the resource pushes: when `site.buildCommand` is configured, install/build runs **only when `--build` is passed or no build artifact exists yet** (`--prebuilt` always skips it). Then, if a full-stack artifact is detected it replaces the legacy site tar.gz upload; otherwise behavior is unchanged. Progress: "Found N static assets (M new)" → "Uploaded X of Y assets" → "Deploying worker (K modules)…" → summary rows `Preview: <immutable_url>` and (with `--prod`) `Production: <url>`. Under `--json`, stdout is a single `{deploymentId, target, urls}` document.
- **`base44 deployments list` / `base44 deployments get <id>`** — recent deployments / one deployment.
- **`base44 promote <deployment-id>`** — point production at an existing deployment.
- **`base44 rollback [-y]`** — roll production back to the previous deployment; confirmation prompt, `-y` required in non-interactive mode.
- **`base44 logs --deployment <id|production>`** — deployment logs; supports `--since --until --limit --order --follow` (`--follow` polls every 2s); `--function`, `--level`, and `--env` don't apply.

## Testing

`TestAPIServer` mocks: `mockDeploymentCreate` (captures the JSON body in `deploymentCreateRequests`; fills `upload_url` to point at the test server), `mockAssetUpload` (serves the upload endpoint, captures multipart fields in `assetUploadRequests`, responds 201 with the completion jwt), `mockDeploymentFinalize` (captures fields in `finalizeRequests`), `mockDeploymentsList`, `mockDeploymentGet`, `mockPromote`, `mockRollback`, `mockDeploymentLogs`. Fixture: `tests/fixtures/fullstack-project/` (redirect file + `build/server` worker + `build/client` assets with `.assetsignore`). Unit tests live in `tests/core/deployments-*.spec.ts`.

## Rules (Deployments-Specific)

- **Never re-derive the asset hash** — always go through `hashAsset()` so the app-id salt stays consistent
- **Bucket uploads go to `upload_url` directly** (absolute URL, bearer jwt) — not through `base44Client`
- **Legacy behavior stays identical** when no full-stack artifact exists — the tar.gz site path must not change
2 changes: 1 addition & 1 deletion docs/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ What it deploys (in order):
2. Functions (via `functionResource.push()`)
3. Agents (via `agentResource.push()`)
4. Connectors (via `pushConnectors()`) -- may return OAuth redirect URLs
5. Site (if `site.outputDirectory` is configured)
5. Site — a full-stack Workers artifact when one is detected (see [deployments.md](deployments.md)), otherwise the legacy tar.gz upload (if `site.outputDirectory` is configured). The deploy command passes `site: false` to `deployAll()` and handles this step itself.

```bash
base44 deploy # With confirmation prompt
Expand Down
25 changes: 25 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ tests/
├── duplicate-function-names/ # Error: duplicate function names
├── with-zero-config-functions/ # Full project: zero-config + path-named functions (CLI integration)
├── with-site/ # Project with site config
├── fullstack-project/ # Full-stack Workers artifact (.wrangler redirect + build output)
├── full-project/ # All resources combined
├── no-app-config/ # Unlinked project (no .app.jsonc)
└── invalid-*/ # Error case fixtures
Expand Down Expand Up @@ -298,6 +299,30 @@ t.api.mockFunctionLogs("my-function", [
t.api.mockFunctionLogsError("my-function", { status: 500, body: { error: "Server error" } });
```

### Deployment (Full-Stack) Mocks

See [deployments.md](deployments.md) for the API contract. Requests are captured for assertions: `t.api.deploymentCreateRequests` (JSON bodies), `t.api.assetUploadRequests` and `t.api.finalizeRequests` (parsed multipart fields).

```typescript
t.api.mockDeploymentCreate({
deployment_id: "dep-1",
script_name: "script-1",
immutable_url: "https://dep-1.app.base44.app",
asset_upload: { jwt: "upload-jwt", buckets: [["<hash>"]] }, // upload_url auto-points at the test server
});
t.api.mockAssetUpload("completion-jwt"); // serves the upload_url endpoint, responds 201
t.api.mockDeploymentFinalize({ status: "ready", deployment_id: "dep-1", urls: [...] });
t.api.mockDeploymentsList({ deployments: [...] });
t.api.mockDeploymentGet("dep-1", { deployment_id: "dep-1", ... });
t.api.mockPromote({ deployment_id: "dep-1", urls: [...] });
t.api.mockRollback({ deployment_id: "dep-0", urls: [...] });
t.api.mockRollbackError({ status: 400, body: { message: "No previous production deployment" } });
t.api.mockDeploymentLogs("dep-1", [{ time: "...", level: "info", message: "..." }]);
t.api.mockDeploymentLogs("production", [...]); // alias route
t.api.mockDeploymentLogsError("dep-1", { status: 500, body: { error: "Server error" } });
t.api.mockAssetUploadError({ status: 500, body: { error: "Server error" } });
```

### Custom Route Mock

For advanced scenarios (e.g. stateful responses across retries):
Expand Down
41 changes: 41 additions & 0 deletions packages/cli/src/cli/commands/deployments/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Command } from "commander";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command } from "@/cli/utils/index.js";
import { getDeployment } from "@/core/deployments/index.js";
import {
formatDeploymentLine,
logDeploymentUrls,
toJsonStdout,
} from "./shared.js";

async function getDeploymentAction(
{ log, runTask, jsonMode }: CLIContext,
deploymentId: string,
): Promise<RunCommandResult> {
const deployment = await runTask(
"Fetching deployment...",
async () => await getDeployment(deploymentId),
{ errorMessage: "Failed to fetch deployment" },
);

if (jsonMode) {
return {
outroMessage: `Deployment ${deployment.deploymentId} is ${deployment.status}`,
stdout: toJsonStdout(deployment),
};
}

log.message(formatDeploymentLine(deployment));
logDeploymentUrls(deployment.urls, log);

return {
outroMessage: `Deployment ${deployment.deploymentId} is ${deployment.status}`,
};
}

export function getDeploymentsGetCommand(): Command {
return new Base44Command("get")
.description("Show a full-stack deployment")
.argument("<deployment-id>", "Deployment ID")
.action(getDeploymentAction);
}
10 changes: 10 additions & 0 deletions packages/cli/src/cli/commands/deployments/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Command } from "commander";
import { getDeploymentsGetCommand } from "./get.js";
import { getDeploymentsListCommand } from "./list.js";

export function getDeploymentsCommand(): Command {
return new Command("deployments")
.description("Manage full-stack deployments")
.addCommand(getDeploymentsListCommand())
.addCommand(getDeploymentsGetCommand());
}
42 changes: 42 additions & 0 deletions packages/cli/src/cli/commands/deployments/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { Command } from "commander";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command } from "@/cli/utils/index.js";
import { listDeployments } from "@/core/deployments/index.js";
import { formatDeploymentLine, toJsonStdout } from "./shared.js";

async function listDeploymentsAction({
log,
runTask,
jsonMode,
}: CLIContext): Promise<RunCommandResult> {
const deployments = await runTask(
"Fetching deployments...",
async () => await listDeployments(),
{ errorMessage: "Failed to fetch deployments" },
);

if (jsonMode) {
return {
outroMessage: `${deployments.length} deployments`,
stdout: toJsonStdout({ deployments }),
};
}

if (deployments.length === 0) {
return { outroMessage: "No deployments found" };
}

for (const deployment of deployments) {
log.message(formatDeploymentLine(deployment));
}

return {
outroMessage: `${deployments.length} deployment${deployments.length !== 1 ? "s" : ""}`,
};
}

export function getDeploymentsListCommand(): Command {
return new Base44Command("list")
.description("List recent full-stack deployments")
.action(listDeploymentsAction);
}
30 changes: 30 additions & 0 deletions packages/cli/src/cli/commands/deployments/promote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { Command } from "commander";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command } from "@/cli/utils/index.js";
import { promoteDeployment } from "@/core/deployments/index.js";
import { logDeploymentUrls, toJsonStdout } from "./shared.js";

async function promoteAction(
{ log, runTask, jsonMode }: CLIContext,
deploymentId: string,
): Promise<RunCommandResult> {
const result = await runTask(
`Promoting deployment ${deploymentId} to production...`,
async () => await promoteDeployment(deploymentId),
{ errorMessage: "Failed to promote deployment" },
);

logDeploymentUrls(result.urls, log);

return {
outroMessage: `Deployment ${result.deploymentId} promoted to production`,
stdout: jsonMode ? toJsonStdout(result) : undefined,
};
}

export function getPromoteCommand(): Command {
return new Base44Command("promote")
.description("Promote a full-stack deployment to production")
.argument("<deployment-id>", "Deployment ID to promote")
.action(promoteAction);
}
49 changes: 49 additions & 0 deletions packages/cli/src/cli/commands/deployments/rollback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { confirm, isCancel } from "@clack/prompts";
import type { Command } from "commander";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command } from "@/cli/utils/index.js";
import { rollbackDeployment } from "@/core/deployments/index.js";
import { InvalidInputError } from "@/core/errors.js";
import { logDeploymentUrls, toJsonStdout } from "./shared.js";

interface RollbackOptions {
yes?: boolean;
}

async function rollbackAction(
{ log, runTask, jsonMode, isNonInteractive }: CLIContext,
options: RollbackOptions,
): Promise<RunCommandResult> {
if (isNonInteractive && !options.yes) {
throw new InvalidInputError("--yes is required in non-interactive mode");
}

if (!options.yes) {
const shouldRollback = await confirm({
message: "Roll back production to the previous deployment?",
});
if (isCancel(shouldRollback) || !shouldRollback) {
return { outroMessage: "Rollback cancelled" };
}
}

const result = await runTask(
"Rolling back production...",
async () => await rollbackDeployment(),
{ errorMessage: "Failed to roll back" },
);

logDeploymentUrls(result.urls, log);

return {
outroMessage: `Production rolled back to deployment ${result.deploymentId}`,
stdout: jsonMode ? toJsonStdout(result) : undefined,
};
}

export function getRollbackCommand(): Command {
return new Base44Command("rollback")
.description("Roll back production to the previous full-stack deployment")
.option("-y, --yes", "Skip confirmation prompt")
.action(rollbackAction);
}
20 changes: 20 additions & 0 deletions packages/cli/src/cli/commands/deployments/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Logger } from "@base44-cli/logger";
import { theme } from "@/cli/utils/index.js";
import type { Deployment, DeploymentUrl } from "@/core/deployments/index.js";

export function toJsonStdout(result: unknown): string {
return `${JSON.stringify(result, null, 2)}\n`;
}

export function logDeploymentUrls(urls: DeploymentUrl[], log: Logger): void {
for (const { url, kind } of urls) {
const label = kind === "production" ? "Production" : "Preview";
log.message(`${theme.styles.header(label)}: ${theme.colors.links(url)}`);
}
}

export function formatDeploymentLine(deployment: Deployment): string {
const url = deployment.urls[0]?.url ?? "";
const framework = deployment.framework ? ` ${deployment.framework}` : "";
return `${deployment.deploymentId} ${deployment.status.padEnd(11)} ${deployment.target.padEnd(10)} ${deployment.createdAt}${framework} ${theme.colors.links(url)}`;
}
66 changes: 66 additions & 0 deletions packages/cli/src/cli/commands/domains/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { Command } from "commander";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command } from "@/cli/utils/index.js";
import type { Domain } from "@/core/domains/index.js";
import { addDomain, waitForDomainActive } from "@/core/domains/index.js";
import { domainStatusText, logDomainSetup, toJsonStdout } from "./shared.js";

interface AddOptions {
wait?: boolean;
}

function waitMessage(domain: Domain | undefined): string {
if (!domain) return "Waiting for domain to appear...";
return `Waiting for domain to become active (${domainStatusText(domain)})...`;
}

async function addDomainAction(
{ log, runTask, jsonMode }: CLIContext,
hostname: string,
options: AddOptions,
): Promise<RunCommandResult> {
let domain = await runTask(
`Connecting ${hostname}...`,
async () => await addDomain(hostname),
{ errorMessage: "Failed to connect domain" },
);

if (options.wait && !domain.active) {
domain = await runTask(
waitMessage(domain),
async (updateMessage) =>
await waitForDomainActive(hostname, {
onTick: (d) => updateMessage(waitMessage(d)),
}),
{
successMessage: `${hostname} is active`,
errorMessage: "Domain did not become active",
},
);
}

if (jsonMode) {
return {
outroMessage: `Domain ${hostname} is ${domainStatusText(domain)}`,
stdout: toJsonStdout(domain),
};
}

logDomainSetup(domain, log);
return {
outroMessage: domain.active
? `${hostname} is active`
: `${hostname} connected — add the CNAME record above to finish`,
};
}

export function getDomainsAddCommand(): Command {
return new Base44Command("add")
.description("Connect a custom domain to this app")
.argument("<hostname>", "Domain to connect, e.g. app.example.com")
.option(
"--wait",
"Poll until the domain and its TLS certificate are active",
)
.action(addDomainAction);
}
12 changes: 12 additions & 0 deletions packages/cli/src/cli/commands/domains/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Command } from "commander";
import { getDomainsAddCommand } from "./add.js";
import { getDomainsListCommand } from "./list.js";
import { getDomainsRemoveCommand } from "./remove.js";

export function getDomainsCommand(): Command {
return new Command("domains")
.description("Manage custom domains for full-stack apps")
.addCommand(getDomainsAddCommand())
.addCommand(getDomainsListCommand())
.addCommand(getDomainsRemoveCommand());
}
Loading
Loading