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
2 changes: 1 addition & 1 deletion docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +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
- **[Deployments API](deployments.md)** - Static-site deploys addressed by commit, asset manifest hashing, presigned uploads, index.html finalize sentinel
- **[Deployments](deployments.md)** - Deploys addressed by commit, wrangler config, asset manifest hashing, direct asset uploads (Workers) and presigned uploads (static)
- **[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
73 changes: 53 additions & 20 deletions docs/deployments.md

Large diffs are not rendered by default.

24 changes: 15 additions & 9 deletions docs/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,19 +78,25 @@ Agent skills are app-scoped instruction snippets shared across the app's agents.

The site module at `packages/cli/src/core/site/` handles deploying an app's built output. It follows a different pattern than resources — there is no item list, so no `readAll`/`push`.

It exposes **two ways to ship `site.outputDirectory`**, and the caller picks:
It owns **which transport ships the build**, but not the shipping itself: `planAppDeploy()` in `deploy-app.ts` only decides, and `base44 site deploy` calls the chosen flow.

```typescript
import { deploySite, deployStaticSite } from "@/core/site/index.js";
import { planAppDeploy } from "@/core/site/index.js";

// Legacy: tar.gz the built files, POST /api/apps/{app_id}/deploy-dist
const { appUrl } = await deploySite(outputDir);

// Deployments API (env-gated lane, see deployments.md)
const { deploymentId } = await deployStaticSite({ outputDir, gitHash });
const plan = await planAppDeploy(project);
// { kind: "full-stack" } | { kind: "static-deployment", outputDir }
// | { kind: "static", outputDir } | { kind: "none" }
```

`base44 site deploy` chooses between them on whether `--git-hash` was passed; `base44 deploy` always uses `deploySite()` via `deployAll()`. The lane's own files are `gate.ts`, `manifest.ts`, `static-site.ts`, and `upload.ts`; both transports share the module's `api.ts` and `schema.ts`.
- A full-stack (Workers) artifact wins when one is present — see [deployments.md](deployments.md). It carries the server too, so shipping the static output directory instead would silently drop the worker.
- Otherwise `site.outputDirectory` ships as a static site: through the deployments API when the env-gated lane is enabled, else the legacy path — tar.gz the built files and upload via `POST /api/apps/{app_id}/deploy-dist`.
- Neither applies → `{ kind: "none" }`.

The plan answers for the current state of the tree, and the full-stack artifact is itself a build output — so the command plans once before the build (for the prompt and the no-config error) and again after it, which is the answer it acts on.

`base44 deploy` does **not** go through this. It ships the site through `deployAll()`'s legacy tar.gz step, so the full-stack and deployments-API transports are reachable only from `base44 site deploy` — they need a commit address the unified deploy has no way to take.

One flow per file: `full-stack.ts` (Workers), `static-site.ts` (deployments-API static), `deploy.ts` (legacy tar.gz). The first two share `manifest.ts`, `upload.ts`, `git-hash.ts`, and the module's `api.ts` / `schema.ts`; see [deployments.md](deployments.md).

### Deploy Flow

Expand Down Expand Up @@ -120,7 +126,7 @@ What it deploys (in order):
3. Agent skills (via `agentSkillResource.push()`)
4. Agents (via `agentResource.push()`)
5. Connectors (via `pushConnectors()`) -- may return OAuth redirect URLs
6. Site (if `site.outputDirectory` is configured) — the legacy tar.gz upload. The env-gated deployments-API lane is reachable only from `base44 site deploy`, not from here (see [deployments.md](deployments.md)).
6. Site (if `site.outputDirectory` is configured) — the legacy tar.gz upload. The full-stack and deployments-API transports are not reachable from here; see [deployments.md](deployments.md).

```bash
base44 deploy # With confirmation prompt
Expand Down
12 changes: 8 additions & 4 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 @@ -300,15 +301,18 @@ t.api.mockFunctionLogsError("my-function", { status: 500, body: { error: "Server

### Deployment Mocks

See [deployments.md](deployments.md) for the API contract. Requests are captured for assertions: `t.api.deploymentCreateRequests` (JSON bodies), `t.api.presignedUploadRequests` (raw body, Content-Type, Authorization), and `t.api.finalizeRequests` (parsed multipart fields).
See [deployments.md](deployments.md) for the API contract. Requests are captured for assertions: `t.api.deploymentCreateRequests` (JSON bodies), `t.api.assetUploadRequests` (cf arm — Authorization header, `base64` query, multipart fields), `t.api.presignedUploadRequests` (s3 arm — raw body, Content-Type, Authorization), and `t.api.finalizeRequests` (parsed multipart fields).

```typescript
t.api.mockDeploymentCreate({
deployment_id: "app-1-git-a1b2c3d4e5f6",
// {type: "s3", uploads: [...]} or null (nothing owed)
asset_uploads: { type: "s3", uploads: [{ path, content_type, content_length, url }] },
// cf arm shown; also {type: "s3", uploads: [{path, content_type, content_length, url}]}
// or null (nothing owed)
asset_uploads: { type: "cf", url, jwt: "session-jwt", buckets: [["<hash>"]] },
});
t.api.mockPresignedUpload("/main.js"); // serves a presigned-style PUT target
t.api.mockAssetUpload("completion-jwt"); // serves the cf asset-upload target, responds 201 {result:{jwt}}
t.api.mockAssetUploadError({ status: 500, body: { error: "Server error" } });
t.api.mockPresignedUpload("/main.js"); // serves a presigned-style PUT target (s3 arm)
t.api.mockDeploymentFinalize({ deployment_id: "app-1-git-a1b2c3d4e5f6" });
```

Expand Down
225 changes: 154 additions & 71 deletions packages/cli/src/cli/commands/site/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { resolve } from "node:path";
import { confirm, isCancel } from "@clack/prompts";
import type { Command } from "commander";
import { InvalidArgumentError, Option } from "commander";
Expand All @@ -7,11 +6,15 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command, theme } from "@/cli/utils/index.js";
import { ConfigNotFoundError, InvalidInputError } from "@/core/errors.js";
import { readProjectConfig } from "@/core/project/index.js";
import type { DeploymentProgress } from "@/core/site/index.js";
import {
DEFAULT_UPLOAD_CONCURRENCY,
deployFullStack,
deploySite,
deployStaticSite,
MAX_UPLOAD_CONCURRENCY,
planAppDeploy,
resolveGitHash,
} from "@/core/site/index.js";
import { isGitCommitHash } from "@/core/utils/git.js";

Expand All @@ -32,23 +35,29 @@ async function deployAction(
}

const { project } = await readProjectConfig();
const planned = await planAppDeploy(project);

const outputDirectory = project.site?.outputDirectory;

if (!outputDirectory) {
if (planned.kind === "none") {
throw new ConfigNotFoundError("No site configuration found.", {
hints: [
{
message:
'Add \'site.outputDirectory\' to your config.jsonc (e.g., "site": { "outputDirectory": "dist" })',
},
{
message:
"Full-stack apps ship from their build artifact — run your framework's build first",
},
],
});
}

if (!options.yes) {
const shouldDeploy = await confirm({
message: `Deploy site from ${outputDirectory}?`,
message:
planned.kind === "full-stack"
? "Deploy full-stack app?"
: `Deploy site from ${project.site?.outputDirectory}?`,
});

if (isCancel(shouldDeploy) || !shouldDeploy) {
Expand All @@ -58,58 +67,78 @@ async function deployAction(

await maybeBuildBeforeDeploy(ctx, project, options.build);

const outputDir = resolve(project.root, outputDirectory);
// Planned again: the build may have produced the full-stack artifact that
// decides which transport applies.
const plan = await planAppDeploy(project);

switch (plan.kind) {
case "full-stack":
return await deployFullStackApp(ctx, project.root, options);
case "static-deployment":
return await deployToDeploymentsApi(
ctx,
project.root,
plan.outputDir,
options,
);
case "static":
return await deployTarball(ctx, plan.outputDir);
case "none":
return { outroMessage: "Nothing to deploy" };
}
}

async function deployFullStackApp(
ctx: CLIContext,
projectRoot: string,
options: DeployOptions,
): Promise<RunCommandResult> {
const gitHash = await resolveGitHash(projectRoot, options.gitHash);

// A commit means a deployments-API deploy: a deployment is addressed by the
// commit that produced the build. Without one, ship the legacy tar.gz upload.
const { gitHash, concurrency } = options;
const { deploymentId } = await runDeployTask(
ctx,
{
start: "Deploying full-stack app...",
success: theme.colors.base44Orange("Full-stack app deployed"),
error: "Full-stack deploy failed",
},
async (progress) =>
await deployFullStack({
projectRoot,
gitHash,
concurrency: options.concurrency,
progress,
}),
);

return gitHash
? await deployToDeploymentsApi(ctx, outputDir, gitHash, concurrency)
: await deployTarball(ctx, outputDir);
return deploymentResult(ctx, deploymentId, gitHash);
}

async function deployToDeploymentsApi(
{ runTask, log, jsonMode }: CLIContext,
ctx: CLIContext,
projectRoot: string,
outputDir: string,
gitHash: string,
concurrency?: number,
options: DeployOptions,
): Promise<RunCommandResult> {
const progressLines: string[] = [];
const gitHash = await resolveGitHash(projectRoot, options.gitHash);

const { deploymentId } = await runTask(
"Deploying site...",
async (updateMessage) =>
const { deploymentId } = await runDeployTask(
ctx,
{
start: "Deploying site...",
success: "Site deployed",
error: "Site deploy failed",
},
async (progress) =>
await deployStaticSite({
outputDir,
gitHash,
concurrency,
progress: {
onAssets: ({ totalAssets, newAssets }) => {
const line = `Found ${totalAssets} static assets (${newAssets} new)`;
progressLines.push(line);
updateMessage(line);
},
onAssetUpload: ({ uploadedFiles, totalFiles }) => {
updateMessage(`Uploaded ${uploadedFiles} of ${totalFiles} assets`);
},
},
concurrency: options.concurrency,
progress,
}),
{ successMessage: "Site deployed", errorMessage: "Site deploy failed" },
);

for (const line of progressLines) {
log.message(theme.styles.dim(line));
}

// A build has no URL of its own: what production serves is decided when the
// app is published from the builder, not by this deploy.
return {
outroMessage: `Deployment ${deploymentId} (commit ${gitHash.slice(0, 12)})`,
stdout: jsonMode
? `${JSON.stringify({ deploymentId, gitHash }, null, 2)}\n`
: undefined,
};
return deploymentResult(ctx, deploymentId, gitHash);
}

async function deployTarball(
Expand All @@ -128,37 +157,98 @@ async function deployTarball(
return { outroMessage: `Visit your site at: ${appUrl}` };
}

/**
* Run a deployments-API deploy behind a spinner, streaming its stages into the
* spinner message. Asset counts are also kept for a summary line, since the
* spinner only ever shows the latest one, and warnings are held back so they
* land after the task instead of being overwritten by it.
*/
async function runDeployTask(
{ runTask, log }: CLIContext,
labels: { start: string; success: string; error: string },
deploy: (
progress: DeploymentProgress,
) => Promise<{ deploymentId: string; gitHash: string }>,
): Promise<{ deploymentId: string }> {
const progressLines: string[] = [];
const warnings: string[] = [];

const result = await runTask(
labels.start,
async (updateMessage) =>
await deploy({
onWarning: (message) => {
warnings.push(message);
},
onAssets: ({ totalAssets, newAssets }) => {
const line = `Found ${totalAssets} static assets (${newAssets} new)`;
progressLines.push(line);
updateMessage(line);
},
onAssetUpload: ({ uploadedFiles, totalFiles }) => {
updateMessage(`Uploaded ${uploadedFiles} of ${totalFiles} assets`);
},
onWorker: ({ moduleCount }) => {
updateMessage(`Deploying worker (${moduleCount} modules)…`);
},
}),
{ successMessage: labels.success, errorMessage: labels.error },
);

for (const line of progressLines) {
log.message(theme.styles.dim(line));
}
for (const warning of warnings) {
log.warn(warning);
}

return result;
}

function deploymentResult(
{ jsonMode }: CLIContext,
deploymentId: string,
gitHash: string,
): RunCommandResult {
// No URL: what production serves is decided when the app is published from
// the builder, not by this deploy.
return {
outroMessage: `Deployment ${deploymentId} (commit ${gitHash.slice(0, 12)})`,
stdout: jsonMode
? `${JSON.stringify({ deploymentId, gitHash }, null, 2)}\n`
: undefined,
};
}

export function getSiteDeployCommand(): Command {
const command = new Base44Command("deploy")
.description("Deploy built site files to Base44 hosting")
return new Base44Command("deploy")
.description(
"Deploy the built site to Base44 hosting (full-stack apps deploy their Workers build)",
)
.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)");

// Only registered on the enabled lane, so with the gate off the flag is
// absent from --help and rejected as an unknown option.
if (staticDeploymentsEnabled()) {
command.addOption(
.option("--no-build", "Deploy without building (skips the prompt)")
.addOption(
new Option(
"--git-hash <hash>",
"Commit the build came from — deploys through the deployments API",
).argParser((value) => {
if (!isGitCommitHash(value)) {
throw new InvalidArgumentError(
"Expected a git commit hash (7-64 hex chars).",
);
}
return value;
}),
);
command.addOption(
"Commit the build came from (defaults to the checkout's HEAD)",
).argParser(parseGitHash),
)
.addOption(
new Option("--concurrency <n>", "Parallel asset uploads")
.default(DEFAULT_UPLOAD_CONCURRENCY)
.argParser(parseConcurrency),
)
.action(deployAction);
}

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

return command.action(deployAction);
return value;
}

function parseConcurrency(value: string): number {
Expand All @@ -174,10 +264,3 @@ function parseConcurrency(value: string): number {
}
return parsed;
}

function staticDeploymentsEnabled(
env: NodeJS.ProcessEnv = process.env,
): boolean {
const value = env.BASE44_STATIC_DEPLOYMENTS;
return value === "1" || value === "true";
}
Loading