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: 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
81 changes: 61 additions & 20 deletions docs/deployments.md

Large diffs are not rendered by default.

21 changes: 12 additions & 9 deletions docs/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,19 +97,22 @@ The workflow module at `packages/cli/src/core/resources/workflow/` is read-only

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: `deploymentsApiEnabled()` in `deployment.ts` only decides, and `base44 site deploy` calls the chosen flow.

```typescript
import { deploySite, deployStaticSite } from "@/core/site/index.js";
import { deploymentsApiEnabled } 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 viaDeployments = deploymentsApiEnabled();
```

`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`.
- Gate on → the deployments API, see [deployments.md](deployments.md). Whether the build carries a worker changes what that flow sends, never which flow runs, and a worker brings its own assets directory — so the command may pass a null `outputDir`.
- Gate off → the legacy tar.gz path: tar.gz `site.outputDirectory` and upload via `POST /api/apps/{app_id}/deploy-dist`. This is the flow that requires the config field, and the one that raises "No site configuration found."

Each flow validates its own inputs, so the decision itself is a boolean and needs nothing from the tree.

`base44 deploy` does **not** go through this. It ships the site through `deployAll()`'s legacy tar.gz step, so the deployments-API transport is reachable only from `base44 site deploy` — it needs a commit address the unified deploy has no way to take.

One flow per transport: `deployment.ts` (deployments API, worker or not) and `deploy.ts` (legacy tar.gz). The first uses `manifest.ts`, `modules.ts`, `upload.ts`, `wrangler-config.ts`, `git-hash.ts`, and the module's `api.ts` / `schema.ts`; see [deployments.md](deployments.md).

### Deploy Flow

Expand Down Expand Up @@ -139,7 +142,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 deployments-API transport is 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/ # Workers build 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
28 changes: 6 additions & 22 deletions packages/cli/src/cli/commands/project/site-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,41 +43,25 @@ export async function runSiteBuild(
export async function maybeBuildBeforeDeploy(
ctx: Pick<CLIContext, "runTask" | "isNonInteractive" | "app">,
project: ProjectData["project"],
build?: boolean,
explicitBuild?: boolean,
): Promise<void> {
if (!ctx.app) {
return;
}

// An explicit --build must be loud when there is nothing to build:
// runSiteBuild throws ConfigNotFoundError when buildCommand is missing.
if (build === true) {
await runSiteBuild(ctx, {
root: project.root,
buildCommand: project.site?.buildCommand,
appId: ctx.app.id,
});
return;
}

if (build === false || !project.site?.outputDirectory) {
return;
}

const shouldBuild = await shouldAskToBuild(
ctx.isNonInteractive,
project.site.buildCommand,
);
const shouldBuild =
explicitBuild ??
(await maybeAskToBuild(ctx.isNonInteractive, project.site?.buildCommand));
if (shouldBuild) {
await runSiteBuild(ctx, {
root: project.root,
buildCommand: project.site.buildCommand,
buildCommand: project.site?.buildCommand,
appId: ctx.app.id,
});
}
}

async function shouldAskToBuild(
async function maybeAskToBuild(
isNonInteractive: boolean,
buildCommand?: string,
): Promise<boolean> {
Expand Down
123 changes: 69 additions & 54 deletions packages/cli/src/cli/commands/site/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command, theme } from "@/cli/utils/index.js";
import { ConfigNotFoundError, InvalidInputError } from "@/core/errors.js";
import { readProjectSettings } from "@/core/project/index.js";
import type { ProjectWithPaths } from "@/core/project/types.js";
import {
DEFAULT_UPLOAD_CONCURRENCY,
deploymentsApiEnabled,
deploySite,
deployStaticSite,
deployToDeployments,
MAX_UPLOAD_CONCURRENCY,
resolveGitHash,
} from "@/core/site/index.js";
import { isGitCommitHash } from "@/core/utils/git.js";

Expand All @@ -35,58 +38,51 @@ async function deployAction(
// invalid one must not fail it.
const project = await readProjectSettings();

const outputDirectory = project.site?.outputDirectory;

if (!outputDirectory) {
throw new ConfigNotFoundError("No site configuration found.", {
hints: [
{
message:
'Add \'site.outputDirectory\' to your config.jsonc (e.g., "site": { "outputDirectory": "dist" })',
},
],
});
}
await maybeBuildBeforeDeploy(ctx, project, options.build);

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

if (isCancel(shouldDeploy) || !shouldDeploy) {
return { outroMessage: "Deployment cancelled" };
}
}

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

const outputDir = resolve(project.root, outputDirectory);

// 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;

return gitHash
? await deployToDeploymentsApi(ctx, outputDir, gitHash, concurrency)
: await deployTarball(ctx, outputDir);
return deploymentsApiEnabled()
? await deployToDeploymentsApi(ctx, project, options)
: await deployTarball(ctx, project);
}

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

const { deploymentId } = await runTask(
"Deploying site...",
async (updateMessage) =>
await deployStaticSite({
outputDir,
await deployToDeployments({
projectRoot,
// Null is fine: a build carrying a worker brings its own assets
// directory, so it needs no site.outputDirectory.
outputDir: siteOutputDir(project),
gitHash,
concurrency,
concurrency: options.concurrency,
progress: {
onWarning: (message) => {
warnings.push(message);
},
onAssets: ({ totalAssets, newAssets }) => {
const line = `Found ${totalAssets} static assets (${newAssets} new)`;
progressLines.push(line);
Expand All @@ -95,6 +91,9 @@ async function deployToDeploymentsApi(
onAssetUpload: ({ uploadedFiles, totalFiles }) => {
updateMessage(`Uploaded ${uploadedFiles} of ${totalFiles} assets`);
},
onWorker: ({ moduleCount }) => {
updateMessage(`Deploying worker (${moduleCount} modules)…`);
},
},
}),
{ successMessage: "Site deployed", errorMessage: "Site deploy failed" },
Expand All @@ -103,9 +102,12 @@ async function deployToDeploymentsApi(
for (const line of progressLines) {
log.message(theme.styles.dim(line));
}
for (const warning of warnings) {
log.warn(warning);
}

// 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.
// 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
Expand All @@ -116,8 +118,20 @@ async function deployToDeploymentsApi(

async function deployTarball(
{ runTask }: CLIContext,
outputDir: string,
project: ProjectWithPaths,
): Promise<RunCommandResult> {
const outputDir = siteOutputDir(project);
if (!outputDir) {
throw new ConfigNotFoundError("No site configuration found.", {
hints: [
{
message:
'Add \'site.outputDirectory\' to your config.jsonc (e.g., "site": { "outputDirectory": "dist" })',
},
],
});
}

const { appUrl } = await runTask(
"Creating archive and deploying site...",
async () => await deploySite(outputDir),
Expand All @@ -130,28 +144,27 @@ async function deployTarball(
return { outroMessage: `Visit your site at: ${appUrl}` };
}

function siteOutputDir(project: ProjectWithPaths): string | null {
const outputDirectory = project.site?.outputDirectory;
return outputDirectory ? resolve(project.root, outputDirectory) : null;
}

export function getSiteDeployCommand(): Command {
const command = 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)");

// 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()) {
// 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(
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;
}),
"Commit the build came from (defaults to the checkout's HEAD)",
).argParser(parseGitHash),
);
command.addOption(
new Option("--concurrency <n>", "Parallel asset uploads")
Expand All @@ -163,6 +176,15 @@ export function getSiteDeployCommand(): Command {
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;
}

function parseConcurrency(value: string): number {
const parsed = Number(value);
if (
Expand All @@ -176,10 +198,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
Loading