From 3461f007d3155fc46f93720d7a1622743085ebb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 17:16:20 +0000 Subject: [PATCH 01/12] feat: static-site deploys through the deployments API (s3 arm, env-gated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the deployments core (commit-addressed create/finalize, asset manifest hashing, presigned uploads) and routes site.outputDirectory through it when BASE44_STATIC_DEPLOYMENTS is set: POST deployments with {git_hash, asset_manifest} and no worker config, PUT each requested file directly to its presigned URL echoing the signed content_type (the URL also signs content_length), finalize with the index.html bytes as the completion sentinel. asset_uploads: null means nothing is owed — re-deploying a commit is idempotent. The create response is a type-discriminated ADT so the worker (cf) arm can slot in next to s3 without protocol changes. Gate off keeps the legacy tar.gz upload byte-identical. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DvhQfqxACcq25XAQRpoSh9 --- .gitattributes | 3 + docs/AGENTS.md | 1 + docs/deployments.md | 45 +++++ docs/resources.md | 19 +- docs/testing.md | 14 ++ .../cli/src/cli/commands/project/deploy.ts | 62 +++++- packages/cli/src/cli/commands/site/deploy.ts | 55 ++++-- .../src/cli/commands/site/run-app-deploy.ts | 69 +++++++ packages/cli/src/core/deployments/api.ts | 85 ++++++++ packages/cli/src/core/deployments/git-hash.ts | 42 ++++ packages/cli/src/core/deployments/index.ts | 6 + packages/cli/src/core/deployments/manifest.ts | 183 +++++++++++++++++ packages/cli/src/core/deployments/schema.ts | 141 +++++++++++++ .../cli/src/core/deployments/static-site.ts | 79 ++++++++ packages/cli/src/core/deployments/upload.ts | 83 ++++++++ packages/cli/src/core/index.ts | 1 + packages/cli/src/core/project/deploy.ts | 15 +- packages/cli/src/core/site/deploy-app.ts | 83 ++++++++ packages/cli/src/core/site/index.ts | 1 + .../tests/cli/static_site_deployments.spec.ts | 185 ++++++++++++++++++ .../cli/tests/cli/testkit/TestAPIServer.ts | 148 ++++++++++++++ .../tests/core/deployments-manifest.spec.ts | 121 ++++++++++++ 22 files changed, 1405 insertions(+), 36 deletions(-) create mode 100644 .gitattributes create mode 100644 docs/deployments.md create mode 100644 packages/cli/src/cli/commands/site/run-app-deploy.ts create mode 100644 packages/cli/src/core/deployments/api.ts create mode 100644 packages/cli/src/core/deployments/git-hash.ts create mode 100644 packages/cli/src/core/deployments/index.ts create mode 100644 packages/cli/src/core/deployments/manifest.ts create mode 100644 packages/cli/src/core/deployments/schema.ts create mode 100644 packages/cli/src/core/deployments/static-site.ts create mode 100644 packages/cli/src/core/deployments/upload.ts create mode 100644 packages/cli/src/core/site/deploy-app.ts create mode 100644 packages/cli/tests/cli/static_site_deployments.spec.ts create mode 100644 packages/cli/tests/core/deployments-manifest.spec.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..05384c2db --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 4f04d7f49..453cff780 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -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` 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 - **[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 diff --git a/docs/deployments.md b/docs/deployments.md new file mode 100644 index 000000000..9e95e2058 --- /dev/null +++ b/docs/deployments.md @@ -0,0 +1,45 @@ +# Deployments API (Static Sites) + +**Keywords:** deployments, static site, asset manifest, hash, git hash, commit, presigned, S3, finalize, index.html sentinel, BASE44_STATIC_DEPLOYMENTS, upload + +Deployments ship an app's built output addressed by the commit that produced it. The core module is `src/core/deployments/` (`git-hash.ts`, `manifest.ts`, `static-site.ts`, `upload.ts`, `api.ts`, `schema.ts`). Today it carries the env-gated static-site lane; the create response is an ADT designed so a worker (`cf`) arm can slot in next to the static (`s3`) arm without protocol changes — that is the progressive-upgrade path for full-stack apps. + +**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. + +## 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) or a non-hex value fails fast with guidance. Pattern: `^[a-fA-F0-9]{7,64}$` (same validation as the server). + +## API Contract (app-scoped, via `getAppClient()`) + +1. `POST deployments` — JSON body: `git_hash` (required) and `asset_manifest` (`{"/path": {hash, size}}`). The response is `{deployment_id, asset_uploads}` where `deployment_id` is a handle for the rest of the flow and `asset_uploads` says where the assets still owed should go, discriminated on `type`: + - `{type: "s3", uploads: [{path, content_type, content_length, url}]}` — one presigned S3 PUT per asset still to upload, **always excluding `/index.html`** (finalize carries it). + - `null` — nothing owed: no assets, or the build already exists (re-deploying a commit is idempotent). +2. **Asset upload — bytes never pass through the backend.** Each upload's raw file bytes are `PUT` directly to its presigned `url` with the signed `content_type` sent verbatim (the URL also signs `content_length`, so the body must be exactly the declared bytes). The URL itself is the credential, so no auth headers and never the app client. Per file: 3 attempts with exponential backoff, concurrency 3. +3. `POST deployments/{id}/finalize` — multipart with exactly one file field named `index.html` carrying the index.html bytes (contentType `text/html`) — no other fields. index.html is the sentinel that completes the deployment, which is also why it never appears in the uploads. Returns `{deployment_id}`. + +## 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 output directory is walked recursively. `.assetsignore` at the 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. + +Content types are deliberately **not** derived client-side: the server decides each asset's Content-Type, signs it into the presigned URL, and the CLI echoes it verbatim — deriving our own value would 403 on any mapping difference. + +## The Static Lane (experimental, env-gated) + +With `BASE44_STATIC_DEPLOYMENTS=1` (or `true`; internal gate, not user-facing yet), a project with `site.outputDirectory` deploys through the deployments API instead of the legacy tar.gz upload: the output directory becomes the asset manifest (index.html included — it is only ever excluded from uploads), 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 command, same `--git-hash` addressing, same `--json` output (`{deploymentId, gitHash}`). + +Both `base44 deploy` and `base44 site deploy` route through `deployAppSite()` in `core/site/` (see [resources.md](resources.md#site-module-not-a-resource)), which picks the transport (`static-deployment` vs legacy `static`). The primary automated consumer is the platform's build/deploy sandbox, which runs `base44 deploy -y --json --git-hash ` with a scoped `apps:deploy` workspace key — so the sandbox and a human at a terminal go through the exact same door. + +## Testing + +`TestAPIServer` mocks: `mockDeploymentCreate` (captures the JSON body in `deploymentCreateRequests`; echoes whatever response shape you pass — `asset_uploads` is `{type: "s3", ...}` or `null`), `mockPresignedUpload(path)` (serves a presigned-style `PUT /presigned{path}` target, captures body/Content-Type/Authorization in `presignedUploadRequests`), `mockDeploymentFinalize` (captures multipart fields in `finalizeRequests`). Fixture: `tests/fixtures/with-site/` (static output dir) — not a git repo, so specs pass `--git-hash`. 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 +- **Never derive an upload's Content-Type client-side** — the server signs it into the presigned URL; echo the signed value verbatim +- **Presigned PUTs carry no auth headers and never use the app client** — the URL itself is the scoped credential +- **`git_hash` is required** — a build with no commit behind it has no address and could never be published +- **Legacy behavior stays identical** when the gate is off — the tar.gz site path must not change diff --git a/docs/resources.md b/docs/resources.md index 95f9fc400..4c7b01718 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -76,18 +76,23 @@ Agent skills are app-scoped instruction snippets shared across the app's agents. ## Site Module (Not a Resource) -The site module at `packages/cli/src/core/site/` handles deploying built frontend files. It follows a different pattern than resources: +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`. -- Reads built artifacts (JS, CSS, HTML) from the output directory -- Gets configuration from `site.outputDirectory` in project config -- Creates a tar.gz archive and uploads it via `POST /api/apps/{app_id}/deploy-dist` +It owns **which transport ships the build**. `deployAppSite()` in `deploy-app.ts` is the single entry point both `base44 deploy` and `base44 site deploy` call: + +- `site.outputDirectory` ships as a static site: through the deployments API when the env-gated lane is enabled (see [deployments.md](deployments.md)), else the legacy path — tar.gz the built files and upload via `POST /api/apps/{app_id}/deploy-dist`. +- No `site.outputDirectory` → `{ kind: "none" }`. ```typescript -import { deploySite } from "@/core/site/index.js"; +import { deployAppSite } from "@/core/site/index.js"; -const { appUrl } = await deploySite("./dist"); +const result = await deployAppSite(project, { gitHash }); +// { kind: "static-deployment", deploymentId, gitHash } +// | { kind: "static", appUrl } | { kind: "none" } ``` +`detectAppDeployKind()` answers what would ship right now — used for the deploy summary and spinner labels. It answers for the current state of the tree, so a build step invalidates it. + ### Deploy Flow 1. Validate output directory exists and has files @@ -116,7 +121,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) +6. Site — via `deployAppSite()`, which picks the transport (see [Site Module](#site-module-not-a-resource)). The deploy command passes `site: false` to `deployAll()` and handles this step itself, after the optional build step has produced whatever the site ships. ```bash base44 deploy # With confirmation prompt diff --git a/docs/testing.md b/docs/testing.md index 3a81485a1..f34052dc6 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -298,6 +298,20 @@ t.api.mockFunctionLogs("my-function", [ t.api.mockFunctionLogsError("my-function", { status: 500, body: { error: "Server error" } }); ``` +### 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). + +```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 }] }, +}); +t.api.mockPresignedUpload("/main.js"); // serves a presigned-style PUT target +t.api.mockDeploymentFinalize({ deployment_id: "app-1-git-a1b2c3d4e5f6" }); +``` + ### Custom Route Mock For advanced scenarios (e.g. stateful responses across retries): diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index 986c01ffc..5c3aabc35 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -7,6 +7,7 @@ import { } from "@/cli/commands/connectors/oauth-prompt.js"; import { formatDeployResult } from "@/cli/commands/functions/formatDeployResult.js"; import { maybeBuildBeforeDeploy } from "@/cli/commands/project/site-build.js"; +import { runAppSiteDeploy } from "@/cli/commands/site/run-app-deploy.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command, @@ -24,11 +25,13 @@ import type { ConnectorSyncResult, StripeSyncResult, } from "@/core/resources/connector/index.js"; +import { detectAppDeployKind } from "@/core/site/index.js"; interface DeployOptions { yes?: boolean; build?: boolean; projectRoot?: string; + gitHash?: string; } export async function deployAction( @@ -41,16 +44,20 @@ export async function deployAction( } const projectData = await readProjectConfig(options.projectRoot); + const { project, entities, functions, agents, connectors, authConfig } = + projectData; - if (!hasResourcesToDeploy(projectData)) { + // Best-effort pre-build look at what the site step would ship, for the + // summary and the no-resources check. The build below can change the + // answer, so the deploy itself decides again. + const plannedSite = await detectAppDeployKind(project); + + if (!hasResourcesToDeploy(projectData) && plannedSite === "none") { return { outroMessage: "No resources found to deploy", }; } - const { project, entities, functions, agents, connectors, authConfig } = - projectData; - // Build summary of what will be deployed const summaryLines: string[] = []; if (entities.length > 0) { @@ -102,11 +109,13 @@ export async function deployAction( await maybeBuildBeforeDeploy(ctx, project, options.build); - // Deploy resources with per-function progress + // Deploy resources with per-function progress. The site ships below, + // from whatever the build produced. let functionCompleted = 0; const functionTotal = functions.length; const result = await deployAll(projectData, { + site: false, onVisibilitySet: (level) => { log.success(`App visibility set to ${level}`); }, @@ -124,6 +133,10 @@ export async function deployAction( }, }); + const siteResult = await runAppSiteDeploy(ctx, project, { + gitHash: options.gitHash, + }); + // Handle connector-specific post-deploy flows const connectorResults = result.connectorResults ?? []; await handleOAuthConnectors(connectorResults, isNonInteractive, options, log); @@ -135,13 +148,42 @@ export async function deployAction( log.message( `${theme.styles.header("Dashboard")}: ${theme.colors.links(getDashboardUrl())}`, ); - if (result.appUrl) { + if (siteResult.kind === "static") { log.message( - `${theme.styles.header("App URL")}: ${theme.colors.links(result.appUrl)}`, + `${theme.styles.header("App URL")}: ${theme.colors.links(siteResult.appUrl)}`, ); } + const deployment = + siteResult.kind === "static-deployment" ? siteResult : undefined; + if (deployment) { + printDeploymentSummary(deployment, log); + } - return { outroMessage: "App deployed successfully" }; + return { + outroMessage: "App deployed successfully", + stdout: + ctx.jsonMode && deployment + ? `${JSON.stringify( + { + deploymentId: deployment.deploymentId, + gitHash: deployment.gitHash, + }, + null, + 2, + )}\n` + : undefined, + }; +} + +function printDeploymentSummary( + deployment: { deploymentId: string; gitHash: string }, + log: Logger, +): void { + // 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. + log.message( + `${theme.styles.header("Deployment")}: ${deployment.deploymentId} ${theme.styles.dim(`(commit ${deployment.gitHash.slice(0, 12)})`)}`, + ); } export function getDeployCommand(): Command { @@ -150,6 +192,10 @@ export function getDeployCommand(): Command { "Deploy all project resources (entities, functions, agents, connectors, and site)", ) .option("-y, --yes", "Skip confirmation prompt") + .option( + "--git-hash ", + "Commit the build came from (defaults to the checkout's HEAD)", + ) .option("--build", "Build the site before deploying (skips the prompt)") .option("--no-build", "Deploy without building (skips the prompt)") .action(deployAction); diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index 03ecc2519..38e1e300b 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -1,4 +1,3 @@ -import { resolve } from "node:path"; import { confirm, isCancel } from "@clack/prompts"; import type { Command } from "commander"; import { maybeBuildBeforeDeploy } from "@/cli/commands/project/site-build.js"; @@ -6,25 +5,29 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { ConfigNotFoundError, InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/project/index.js"; -import { deploySite } from "@/core/site/index.js"; +import { detectAppDeployKind } from "@/core/site/index.js"; +import { runAppSiteDeploy } from "./run-app-deploy.js"; interface DeployOptions { yes?: boolean; build?: boolean; + gitHash?: string; } async function deployAction( ctx: CLIContext, options: DeployOptions, ): Promise { - const { isNonInteractive, runTask } = ctx; + const { isNonInteractive } = ctx; if (isNonInteractive && !options.yes) { throw new InvalidInputError("--yes is required in non-interactive mode"); } const { project } = await readProjectConfig(); - if (!project.site?.outputDirectory) { + const kind = await detectAppDeployKind(project); + + if (kind === "none") { throw new ConfigNotFoundError("No site configuration found.", { hints: [ { @@ -35,11 +38,9 @@ async function deployAction( }); } - const outputDir = resolve(project.root, project.site.outputDirectory); - if (!options.yes) { const shouldDeploy = await confirm({ - message: `Deploy site from ${project.site.outputDirectory}?`, + message: `Deploy site from ${project.site?.outputDirectory}?`, }); if (isCancel(shouldDeploy) || !shouldDeploy) { @@ -49,24 +50,40 @@ async function deployAction( await maybeBuildBeforeDeploy(ctx, project, options.build); - const result = await runTask( - "Creating archive and deploying site...", - async () => { - return await deploySite(outputDir); - }, - { - successMessage: "Site deployed successfully", - errorMessage: "Deployment failed", - }, - ); + const result = await runAppSiteDeploy(ctx, project, { + gitHash: options.gitHash, + }); + + if (result.kind === "static-deployment") { + // 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 ${result.deploymentId} (commit ${result.gitHash.slice(0, 12)})`, + stdout: ctx.jsonMode + ? `${JSON.stringify( + { deploymentId: result.deploymentId, gitHash: result.gitHash }, + null, + 2, + )}\n` + : undefined, + }; + } + + if (result.kind === "static") { + return { outroMessage: `Visit your site at: ${result.appUrl}` }; + } - return { outroMessage: `Visit your site at: ${result.appUrl}` }; + return { outroMessage: "Nothing to deploy" }; } export function getSiteDeployCommand(): Command { return new Base44Command("deploy") - .description("Deploy built site files to Base44 hosting") + .description("Deploy the built site to Base44 hosting") .option("-y, --yes", "Skip confirmation prompt") + .option( + "--git-hash ", + "Commit the build came from (defaults to the checkout's HEAD)", + ) .option("--build", "Build the site before deploying (skips the prompt)") .option("--no-build", "Deploy without building (skips the prompt)") .action(deployAction); diff --git a/packages/cli/src/cli/commands/site/run-app-deploy.ts b/packages/cli/src/cli/commands/site/run-app-deploy.ts new file mode 100644 index 000000000..a402b9758 --- /dev/null +++ b/packages/cli/src/cli/commands/site/run-app-deploy.ts @@ -0,0 +1,69 @@ +import type { CLIContext } from "@/cli/types.js"; +import { theme } from "@/cli/utils/index.js"; +import type { AppDeployResult, AppSiteTarget } from "@/core/site/index.js"; +import { deployAppSite, detectAppDeployKind } from "@/core/site/index.js"; + +const TASK_LABELS = { + "static-deployment": { + start: "Deploying site...", + success: "Site deployed", + error: "Site deploy failed", + }, + static: { + start: "Creating archive and deploying site...", + success: "Site deployed successfully", + error: "Deployment failed", + }, +} as const; + +/** + * Run the project's site deploy behind a spinner, adapting the labels and the + * progress stream to whichever transport applies. The kind is detected here + * only to pick the messages; `deployAppSite` decides for itself what to ship. + */ +export async function runAppSiteDeploy( + { runTask, log }: CLIContext, + target: AppSiteTarget, + options: { gitHash?: string } = {}, +): Promise { + const kind = await detectAppDeployKind(target); + if (kind === "none") return { kind: "none" }; + + const labels = TASK_LABELS[kind]; + const progressLines: string[] = []; + const warnings: string[] = []; + + const result = await runTask( + labels.start, + async (updateMessage) => + await deployAppSite(target, { + gitHash: options.gitHash, + progress: { + 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`); + }, + }, + }), + { + 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; +} diff --git a/packages/cli/src/core/deployments/api.ts b/packages/cli/src/core/deployments/api.ts new file mode 100644 index 000000000..311a92092 --- /dev/null +++ b/packages/cli/src/core/deployments/api.ts @@ -0,0 +1,85 @@ +import type { KyResponse } from "ky"; +import { getAppClient } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import type { + CreateDeploymentRequest, + CreateDeploymentResponse, + FinalizeDeploymentResponse, +} from "./schema.js"; +import { + CreateDeploymentResponseSchema, + FinalizeDeploymentResponseSchema, +} from "./schema.js"; + +export async function createDeployment( + request: CreateDeploymentRequest, +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.post("deployments", { + json: request, + timeout: 120_000, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "creating deployment"); + } + + const result = CreateDeploymentResponseSchema.safeParse( + await response.json(), + ); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +/** + * Finalize a static-site (s3-target) deployment. The form carries exactly one + * file part — `index.html` — and nothing else (no `payload`, no modules): + * index.html is always excluded from the presigned uploads and travels + * through finalize as the sentinel that completes the deployment. + */ +export async function finalizeStaticDeployment( + deploymentId: string, + indexHtml: Uint8Array, +): Promise { + const formData = new FormData(); + formData.append( + "index.html", + new File([indexHtml], "index.html", { type: "text/html" }), + ); + return await postFinalize(deploymentId, formData); +} + +async function postFinalize( + deploymentId: string, + formData: FormData, +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.post( + `deployments/${encodeURIComponent(deploymentId)}/finalize`, + { body: formData, timeout: 180_000 }, + ); + } catch (error) { + throw await ApiError.fromHttpError(error, "finalizing deployment"); + } + + const result = FinalizeDeploymentResponseSchema.safeParse( + await response.json(), + ); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} diff --git a/packages/cli/src/core/deployments/git-hash.ts b/packages/cli/src/core/deployments/git-hash.ts new file mode 100644 index 000000000..3910429cd --- /dev/null +++ b/packages/cli/src/core/deployments/git-hash.ts @@ -0,0 +1,42 @@ +import { execa } from "execa"; +import { InvalidInputError } from "@/core/errors.js"; +import { GIT_HASH_PATTERN } from "./schema.js"; + +/** + * The commit this build came from — a deployment is addressed by it, so the + * hash is required. An explicit hash (flag/automation) wins; otherwise it + * comes from the git checkout at the project root. + */ +export async function resolveGitHash( + projectRoot: string, + explicit?: string, +): Promise { + const hash = explicit ?? (await gitHead(projectRoot)); + if (!hash || !GIT_HASH_PATTERN.test(hash)) { + throw new InvalidInputError( + explicit + ? `'${explicit}' is not a git commit hash.` + : "Deployments are addressed by the commit that produced the build, and no git commit was found.", + { + hints: [ + { + message: + "Run the deploy from a git checkout, or pass the commit explicitly with --git-hash.", + }, + ], + }, + ); + } + return hash; +} + +async function gitHead(projectRoot: string): Promise { + try { + const { stdout } = await execa("git", ["rev-parse", "HEAD"], { + cwd: projectRoot, + }); + return stdout.trim(); + } catch { + return null; + } +} diff --git a/packages/cli/src/core/deployments/index.ts b/packages/cli/src/core/deployments/index.ts new file mode 100644 index 000000000..0a3e99d49 --- /dev/null +++ b/packages/cli/src/core/deployments/index.ts @@ -0,0 +1,6 @@ +export * from "./api.js"; +export * from "./git-hash.js"; +export * from "./manifest.js"; +export * from "./schema.js"; +export * from "./static-site.js"; +export * from "./upload.js"; diff --git a/packages/cli/src/core/deployments/manifest.ts b/packages/cli/src/core/deployments/manifest.ts new file mode 100644 index 000000000..86653dbed --- /dev/null +++ b/packages/cli/src/core/deployments/manifest.ts @@ -0,0 +1,183 @@ +import { createHash } from "node:crypto"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { InvalidInputError } from "@/core/errors.js"; +import { pathExists, readTextFile } from "@/core/utils/fs.js"; +import type { + AssetFile, + AssetManifestEntry, + AssetManifestResult, +} from "./schema.js"; + +const MAX_ASSET_SIZE_BYTES = 25 * 1024 * 1024; // 25 MiB +const MAX_ASSET_COUNT = 100_000; + +const ASSETS_IGNORE_FILE = ".assetsignore"; + +/** Files never uploaded as assets, regardless of .assetsignore. */ +const ALWAYS_SKIPPED_FILES = new Set([ + ASSETS_IGNORE_FILE, + "wrangler.json", + ".dev.vars", +]); + +/** + * Content-addressed asset hash: first 32 hex chars of + * sha256(utf8(app_id) || raw file bytes). Salting with the app id means a + * tenant can only produce hash collisions with their own files, so a + * malicious upload cannot poison another app's asset cache. + */ +export function hashAsset(appId: string, content: Buffer): string { + return createHash("sha256") + .update(Buffer.from(appId, "utf8")) + .update(content) + .digest("hex") + .slice(0, 32); +} + +type IgnoreMatcher = (relativePath: string, isDirectory: boolean) => boolean; + +function globToRegExp(glob: string): RegExp { + let source = ""; + for (let i = 0; i < glob.length; i++) { + const char = glob[i]; + if (char === "*") { + if (glob[i + 1] === "*") { + source += ".*"; + i++; + } else { + source += "[^/]*"; + } + } else if (char === "?") { + source += "[^/]"; + } else { + source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + } + } + return new RegExp(`^${source}$`); +} + +/** + * Minimal gitignore-style matcher for .assetsignore. Supports exact names, + * `*`/`**` globs, directory patterns (trailing `/`), and root-anchored + * patterns (containing `/`). Negation (`!`) is not supported. + */ +function createIgnoreMatcher(lines: string[]): IgnoreMatcher { + const rules = lines + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")) + .map((line) => { + const isDirOnly = line.endsWith("/"); + let pattern = isDirOnly ? line.slice(0, -1) : line; + const anchored = pattern.includes("/"); + pattern = pattern.replace(/^\//, ""); + return { regex: globToRegExp(pattern), anchored, isDirOnly }; + }); + + return (relativePath, isDirectory) => { + const segments = relativePath.split("/"); + return rules.some((rule) => { + if (rule.anchored) { + if (rule.isDirOnly ? isDirectory : true) { + if (rule.regex.test(relativePath)) return true; + } + // A directory pattern also ignores everything under the directory; + // matching directories are pruned during the walk. + return false; + } + // Unanchored: match the basename (and for dir-only rules, any segment — + // but directories are pruned during the walk, so files only need their + // own basename checked). + const basename = segments[segments.length - 1]; + if (rule.isDirOnly && !isDirectory) return false; + return rule.regex.test(basename); + }); + }; +} + +async function loadIgnoreMatcher(assetsDir: string): Promise { + const ignorePath = join(assetsDir, ASSETS_IGNORE_FILE); + if (!(await pathExists(ignorePath))) { + return () => false; + } + const content = await readTextFile(ignorePath); + return createIgnoreMatcher(content.split(/\r?\n/)); +} + +/** + * Walk the assets directory and build the deployment asset manifest. + * Honors `.assetsignore` at the assets root, always skips `.assetsignore`, + * `wrangler.json`, and `.dev.vars`, rejects files larger than 25 MiB, and + * caps the total file count at 100,000. + */ +export async function buildAssetManifest( + assetsDir: string, + appId: string, +): Promise { + const isIgnored = await loadIgnoreMatcher(assetsDir); + const manifest: Record = {}; + const filesByHash = new Map(); + + const relativeFilePaths = await collectFilePaths(assetsDir, "", isIgnored); + + if (relativeFilePaths.length > MAX_ASSET_COUNT) { + throw new InvalidInputError( + `Too many static assets: found ${relativeFilePaths.length}, the limit is ${MAX_ASSET_COUNT} files.`, + ); + } + + for (const relativePath of relativeFilePaths.sort()) { + const absolutePath = join(assetsDir, ...relativePath.split("/")); + const { size } = await stat(absolutePath); + if (size > MAX_ASSET_SIZE_BYTES) { + throw new InvalidInputError( + `Static asset "${relativePath}" is ${size} bytes, which exceeds the 25 MiB per-file limit.`, + ); + } + + const content = await readFile(absolutePath); + const hash = hashAsset(appId, content); + + manifest[`/${relativePath}`] = { hash, size }; + if (!filesByHash.has(hash)) { + filesByHash.set(hash, { absolutePath, hash, size }); + } + } + + return { manifest, filesByHash }; +} + +async function collectFilePaths( + dir: string, + relativeDir: string, + isIgnored: IgnoreMatcher, +): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const results: string[] = []; + + for (const entry of entries) { + const relativePath = relativeDir + ? `${relativeDir}/${entry.name}` + : entry.name; + + if (entry.isDirectory()) { + if (isIgnored(relativePath, true)) continue; + results.push( + ...(await collectFilePaths( + join(dir, entry.name), + relativePath, + isIgnored, + )), + ); + continue; + } + + if (!entry.isFile()) continue; + if (ALWAYS_SKIPPED_FILES.has(entry.name)) continue; + if (isIgnored(relativePath, false)) continue; + + results.push(relativePath); + } + + return results; +} diff --git a/packages/cli/src/core/deployments/schema.ts b/packages/cli/src/core/deployments/schema.ts new file mode 100644 index 000000000..e9469d0e6 --- /dev/null +++ b/packages/cli/src/core/deployments/schema.ts @@ -0,0 +1,141 @@ +import { z } from "zod"; + +// ─── SHARED ────────────────────────────────────────────────── + +/** Manifest entry keyed by URL-ish path ("/index.html"). */ +export interface AssetManifestEntry { + hash: string; + size: number; +} + +/** A static asset discovered in the assets directory, keyed by hash. */ +export interface AssetFile { + /** Absolute path on disk. */ + absolutePath: string; + hash: string; + size: number; +} + +export interface AssetManifestResult { + /** URL path → { hash, size }, ready for the create-deployment payload. */ + manifest: Record; + /** Hash → file info, used to serve the requested uploads. */ + filesByHash: Map; +} + +/** Progress of an in-flight asset upload set. */ +export interface AssetUploadProgress { + uploadedFiles: number; + totalFiles: number; +} + +/** Progress callbacks a deploy fires as it moves through its stages. */ +export interface DeploymentProgress { + /** Fired for non-fatal issues worth surfacing to the user. */ + onWarning?: (message: string) => void; + /** Fired after the deployment is created: total assets and how many need uploading. */ + onAssets?: (info: { totalAssets: number; newAssets: number }) => void; + /** Fired after each asset upload completes. */ + onAssetUpload?: (progress: AssetUploadProgress) => void; +} + +/** + * A deployment is addressed by the commit that produced it: the server derives + * the deployment id from `git_hash`, so one commit means one deployment and + * re-deploying a commit is idempotent. Same pattern the server validates. + */ +export const GIT_HASH_PATTERN = /^[a-fA-F0-9]{7,64}$/; + +/** + * Request payload for POST deployments (sent as snake_case JSON). A request + * without a worker config is a static-site deployment — the server answers + * it with the `s3` arm of the create response. + */ +export interface CreateDeploymentRequest { + git_hash: string; + asset_manifest: Record; +} + +// ─── RESPONSES ─────────────────────────────────────────────── + +/** A static asset the server wants uploaded, with its presigned S3 URL. */ +export interface PresignedAssetUpload { + /** Manifest path of the asset ("/assets/app.js"). */ + path: string; + /** Content-Type signed into the URL — the PUT must send it verbatim. */ + contentType: string; + /** Byte count signed into the URL — the PUT body must be exactly this long. */ + contentLength: number; + /** Presigned S3 URL — the URL itself is the credential. */ + url: string; +} + +interface S3AssetUploads { + type: "s3"; + uploads: PresignedAssetUpload[]; +} + +/** + * POST deployments answers `{deployment_id, asset_uploads}` where + * `asset_uploads` says where the assets still owed should go, discriminated + * on `type` — a config-less (static-site) request is always answered with + * the `s3` arm: direct presigned PUTs, always excluding `/index.html` + * (finalize carries it) — and is null when nothing is owed (no assets, or + * the build already exists). + */ +export const CreateDeploymentResponseSchema = z + .object({ + deployment_id: z.string(), + asset_uploads: z + .object({ + type: z.literal("s3"), + uploads: z.array( + z.object({ + path: z.string(), + content_type: z.string(), + content_length: z.number(), + url: z.string(), + }), + ), + }) + .nullable() + .optional(), + }) + .transform( + ( + data, + ): { + deploymentId: string; + assetUploads: S3AssetUploads | null; + } => ({ + deploymentId: data.deployment_id, + assetUploads: + data.asset_uploads == null + ? null + : { + type: "s3", + uploads: data.asset_uploads.uploads.map((upload) => ({ + path: upload.path, + contentType: upload.content_type, + contentLength: upload.content_length, + url: upload.url, + })), + }, + }), + ); + +export type CreateDeploymentResponse = z.infer< + typeof CreateDeploymentResponseSchema +>; + +export const FinalizeDeploymentResponseSchema = z + .object({ + deployment_id: z.string(), + }) + .transform((data) => ({ + deploymentId: data.deployment_id, + })); + +export type FinalizeDeploymentResponse = z.infer< + typeof FinalizeDeploymentResponseSchema +>; diff --git a/packages/cli/src/core/deployments/static-site.ts b/packages/cli/src/core/deployments/static-site.ts new file mode 100644 index 000000000..4bbde5979 --- /dev/null +++ b/packages/cli/src/core/deployments/static-site.ts @@ -0,0 +1,79 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { InvalidInputError } from "@/core/errors.js"; +import { getAppContext } from "@/core/project/app-config.js"; +import { createDeployment, finalizeStaticDeployment } from "./api.js"; +import { buildAssetManifest } from "./manifest.js"; +import type { DeploymentProgress } from "./schema.js"; +import { uploadPresignedAssets } from "./upload.js"; + +/** + * Internal gate for the experimental static-site deployments-API lane. Not + * user-facing yet: when set to "1" or "true", `base44 deploy` sends the + * configured site output through the deployments API instead of the legacy + * tar.gz site upload. + */ +const STATIC_DEPLOYMENTS_ENV = "BASE44_STATIC_DEPLOYMENTS"; + +export function staticDeploymentsEnabled( + env: NodeJS.ProcessEnv = process.env, +): boolean { + const value = env[STATIC_DEPLOYMENTS_ENV]; + return value === "1" || value === "true"; +} + +/** + * Deploy a static site build through the deployments API: hash the output + * directory into an asset manifest and create the deployment at the commit's + * address with no worker config — which the server answers with the `s3` arm + * of the discriminated create response — then PUT the requested files + * directly to their presigned URLs and finalize with the index.html bytes. + * + * This is the progressive-upgrade path: when the app later adopts a server + * framework, the create request carries its worker config and the server + * answers with the `cf` arm instead — same CLI protocol, zero CLI change. + */ +export async function deployStaticSite(options: { + outputDir: string; + gitHash: string; + progress?: DeploymentProgress; +}): Promise<{ deploymentId: string; gitHash: string }> { + const { outputDir, gitHash, progress } = options; + + const assets = await buildAssetManifest(outputDir, getAppContext().id); + // Finalize carries the index.html bytes by contract, so its absence is a + // broken build (or a wrong outputDirectory) — fail before any upload. + if (!assets.manifest["/index.html"]) { + throw new InvalidInputError( + `No index.html found in "${outputDir}" — a static site needs one at the output directory root.`, + ); + } + + const created = await createDeployment({ + git_hash: gitHash, + asset_manifest: assets.manifest, + }); + // The uploads always exclude index.html; null means every asset is already + // stored (re-deploying a commit is idempotent). + const totalAssets = Object.keys(assets.manifest).length; + progress?.onAssets?.({ + totalAssets, + newAssets: created.assetUploads?.uploads.length ?? 0, + }); + + if (created.assetUploads) { + await uploadPresignedAssets( + created.assetUploads.uploads, + assets, + progress?.onAssetUpload, + ); + } + + const indexHtml = await readFile(join(outputDir, "index.html")); + const finalized = await finalizeStaticDeployment( + created.deploymentId, + new Uint8Array(indexHtml), + ); + + return { deploymentId: finalized.deploymentId, gitHash }; +} diff --git a/packages/cli/src/core/deployments/upload.ts b/packages/cli/src/core/deployments/upload.ts new file mode 100644 index 000000000..4f0e4e673 --- /dev/null +++ b/packages/cli/src/core/deployments/upload.ts @@ -0,0 +1,83 @@ +import { readFile } from "node:fs/promises"; +import ky from "ky"; +import { ApiError, InternalError } from "@/core/errors.js"; +import type { + AssetManifestResult, + AssetUploadProgress, + PresignedAssetUpload, +} from "./schema.js"; + +const UPLOAD_CONCURRENCY = 3; +const MAX_ATTEMPTS_PER_UPLOAD = 3; +const RETRY_BASE_DELAY_MS = 500; + +/** + * PUT static assets directly to their presigned S3 URLs (the `s3` create + * arm). A presigned URL carries its own authorization in the query string, so + * each request is a plain fetch — never the app client, never an + * Authorization header. Uploads run with concurrency 3; + * each file gets 3 attempts with exponential backoff. + */ +export async function uploadPresignedAssets( + uploads: PresignedAssetUpload[], + assets: AssetManifestResult, + onProgress?: (progress: AssetUploadProgress) => void, +): Promise { + let uploadedFiles = 0; + + let nextUpload = 0; + const worker = async (): Promise => { + while (nextUpload < uploads.length) { + const upload = uploads[nextUpload++]; + await uploadPresignedAssetWithRetry(upload, assets); + uploadedFiles++; + onProgress?.({ uploadedFiles, totalFiles: uploads.length }); + } + }; + + await Promise.all( + Array.from( + { length: Math.min(UPLOAD_CONCURRENCY, uploads.length) }, + worker, + ), + ); +} + +async function uploadPresignedAssetWithRetry( + upload: PresignedAssetUpload, + assets: AssetManifestResult, +): Promise { + const entry = assets.manifest[upload.path]; + const file = entry && assets.filesByHash.get(entry.hash); + if (!file) { + throw new InternalError( + `Server requested upload of unknown asset path: ${upload.path}`, + ); + } + const content = await readFile(file.absolutePath); + + let lastError: unknown; + for (let attempt = 0; attempt < MAX_ATTEMPTS_PER_UPLOAD; attempt++) { + if (attempt > 0) { + await sleep(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1)); + } + try { + await ky.put(upload.url, { + body: new Uint8Array(content), + // The server signed this exact Content-Type into the URL — deriving + // our own value would 403 on any mapping difference. + headers: { "Content-Type": upload.contentType }, + timeout: 120_000, + retry: 0, + }); + return; + } catch (error) { + lastError = error; + } + } + throw await ApiError.fromHttpError(lastError, "uploading static assets"); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/cli/src/core/index.ts b/packages/cli/src/core/index.ts index b6b6250d7..cb9e329b6 100644 --- a/packages/cli/src/core/index.ts +++ b/packages/cli/src/core/index.ts @@ -2,6 +2,7 @@ export * from "./auth/index.js"; export * from "./clients/index.js"; export * from "./config.js"; export * from "./consts.js"; +export * from "./deployments/index.js"; export * from "./errors.js"; export * from "./project/index.js"; export * from "./resources/index.js"; diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 99ff0ef95..58a55000a 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -33,7 +33,11 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { connectors, authConfig, } = projectData; - const hasSite = Boolean(project.site?.outputDirectory); + // A build command counts: a full-stack project may configure nothing but + // the build, and a generated artifact won't be on disk until it has run. + const hasSite = Boolean( + project.site?.outputDirectory || project.site?.buildCommand, + ); const hasEntities = entities.length > 0; const hasFunctions = functions.length > 0; const hasAgents = agents.length > 0; @@ -71,6 +75,13 @@ interface DeployAllResult { interface DeployAllOptions { onFunctionStart?: (names: string[]) => void; onFunctionResult?: (result: SingleFunctionDeployResult) => void; + /** + * Deploy the legacy static site (tar.gz upload) when configured. + * The unified deploy command passes false and handles the site itself, + * so full-stack (Workers) projects can take the deployments path instead. + * @default true + */ + site?: boolean; onVisibilitySet?: (visibility: Visibility) => void; } @@ -116,7 +127,7 @@ export async function deployAll( ? [] : (await pushConnectors(connectors)).results; - if (project.site?.outputDirectory) { + if ((options?.site ?? true) && project.site?.outputDirectory) { const outputDir = resolve(project.root, project.site.outputDirectory); const { appUrl } = await deploySite(outputDir); return { appUrl, connectorResults }; diff --git a/packages/cli/src/core/site/deploy-app.ts b/packages/cli/src/core/site/deploy-app.ts new file mode 100644 index 000000000..b9f5e1fbf --- /dev/null +++ b/packages/cli/src/core/site/deploy-app.ts @@ -0,0 +1,83 @@ +import { resolve } from "node:path"; +import type { DeploymentProgress } from "@/core/deployments/index.js"; +import { + deployStaticSite, + resolveGitHash, + staticDeploymentsEnabled, +} from "@/core/deployments/index.js"; +import { deploySite } from "@/core/site/deploy.js"; + +/** The project fields an app deploy reads. */ +export interface AppSiteTarget { + root: string; + site?: { outputDirectory?: string }; +} + +/** Which transport ships this project's built output. */ +type AppDeployKind = "static-deployment" | "static" | "none"; + +export type AppDeployResult = + | { kind: "static-deployment"; deploymentId: string; gitHash: string } + | { kind: "static"; appUrl: string } + | { kind: "none" }; + +type AppDeployPlan = + | { kind: "static-deployment"; outputDir: string } + | { kind: "static"; outputDir: string } + | { kind: "none" }; + +/** + * A static output ships through the deployments API when the lane is + * enabled, and as the legacy tar.gz upload otherwise. + */ +async function planAppDeploy(target: AppSiteTarget): Promise { + const outputDirectory = target.site?.outputDirectory; + if (!outputDirectory) { + return { kind: "none" }; + } + const outputDir = resolve(target.root, outputDirectory); + return staticDeploymentsEnabled() + ? { kind: "static-deployment", outputDir } + : { kind: "static", outputDir }; +} + +/** + * How the project's built output would ship right now. This only answers for + * the current state of the tree — call it again after any build step. + */ +export async function detectAppDeployKind( + target: AppSiteTarget, +): Promise { + return (await planAppDeploy(target)).kind; +} + +/** + * Deploy the project's built output over whichever transport applies — + * a deployments-API static deployment when the lane is enabled, the legacy + * tar.gz upload otherwise. Returns `{ kind: "none" }` when the project has + * nothing to ship. + */ +export async function deployAppSite( + target: AppSiteTarget, + options: { gitHash?: string; progress?: DeploymentProgress } = {}, +): Promise { + const plan = await planAppDeploy(target); + + switch (plan.kind) { + case "static-deployment": { + const gitHash = await resolveGitHash(target.root, options.gitHash); + const { deploymentId } = await deployStaticSite({ + outputDir: plan.outputDir, + gitHash, + progress: options.progress, + }); + return { kind: "static-deployment", deploymentId, gitHash }; + } + case "static": { + const { appUrl } = await deploySite(plan.outputDir); + return { kind: "static", appUrl }; + } + case "none": + return { kind: "none" }; + } +} diff --git a/packages/cli/src/core/site/index.ts b/packages/cli/src/core/site/index.ts index 676ceabda..9012035ad 100644 --- a/packages/cli/src/core/site/index.ts +++ b/packages/cli/src/core/site/index.ts @@ -1,4 +1,5 @@ export * from "./api.js"; export * from "./config.js"; export * from "./deploy.js"; +export * from "./deploy-app.js"; export * from "./schema.js"; diff --git a/packages/cli/tests/cli/static_site_deployments.spec.ts b/packages/cli/tests/cli/static_site_deployments.spec.ts new file mode 100644 index 000000000..3134bd3ab --- /dev/null +++ b/packages/cli/tests/cli/static_site_deployments.spec.ts @@ -0,0 +1,185 @@ +import { readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +/** The commit the fixture "build" came from (the fixture is not a git repo). */ +const GIT_HASH = "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c"; +const DEPLOYMENT_ID = "test-app-git-0f1e2d3c4b5a"; + +/** Server-side content types differ from the CLI's own mapping on purpose — + * the tests prove the signed value wins. */ +const SIGNED_CONTENT_TYPES: Record = { + "/main.js": "application/javascript", + "/styles.css": "text/css", +}; + +/** Byte counts the server signs into the URLs (from the real fixture files). */ +const FIXTURE_SIZES: Record = Object.fromEntries( + ["/main.js", "/styles.css"].map((path) => [ + path, + readFileSync(join(fixture("with-site"), "site-output", path.slice(1))) + .length, + ]), +); + +interface CreateBody { + git_hash: string; + asset_manifest: Record; +} + +describe("deploy command (static site through the deployments API, env-gated)", () => { + const t = setupCLITests(); + + /** Mocks hit by the unified deploy's resource-push phase (no resources). */ + function mockResourcePushes() { + t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); + } + + /** The s3 create arm: presigned PUT targets for the requested paths. */ + function mockStaticCreate(uploadPaths: string[]) { + t.api.mockDeploymentCreate({ + deployment_id: DEPLOYMENT_ID, + asset_uploads: + uploadPaths.length === 0 + ? null + : { + type: "s3" as const, + uploads: uploadPaths.map((path) => ({ + path, + // The server derives this server-side and signs it into the URL; the + // CLI must echo it verbatim rather than derive its own. + content_type: `${SIGNED_CONTENT_TYPES[path]}; charset=utf-8`, + content_length: FIXTURE_SIZES[path], + url: `${t.api.baseUrl}/presigned${path}`, + })), + }, + }); + for (const path of uploadPaths) { + t.api.mockPresignedUpload(path); + } + } + + async function readSiteFile(name: string): Promise { + return await readFile(join(fixture("with-site"), "site-output", name)); + } + + it("keeps the legacy tar.gz site upload when the gate is off", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + mockResourcePushes(); + t.api.mockSiteDeploy({ app_url: "https://legacy.example.com" }); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("https://legacy.example.com"); + expect(t.api.deploymentCreateRequests).toHaveLength(0); + }); + + it("deploys the site output through the deployments API when gated on", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); + mockResourcePushes(); + mockStaticCreate(["/main.js", "/styles.css"]); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + + const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Found 3 static assets (2 new)"); + t.expectResult(result).toContain("Site deployed"); + t.expectResult(result).toContain(`Deployment: ${DEPLOYMENT_ID}`); + + // Create request: the commit address, NO config field at all (that is + // what selects the static arm), and index.html IS in the manifest — it + // is only ever excluded from the uploads. + expect(t.api.deploymentCreateRequests).toHaveLength(1); + const body = t.api.deploymentCreateRequests[0] as CreateBody; + expect(body.git_hash).toBe(GIT_HASH); + expect(body).not.toHaveProperty("config"); + expect(Object.keys(body.asset_manifest).sort()).toEqual([ + "/index.html", + "/main.js", + "/styles.css", + ]); + + // Raw bytes PUT directly to the presigned URLs: the computed content + // type, no auth header (the URL itself is the credential). + expect(t.api.presignedUploadRequests).toHaveLength(2); + const byPath = new Map( + t.api.presignedUploadRequests.map((r) => [r.path, r]), + ); + const mainJs = byPath.get("/main.js"); + expect(mainJs?.data.equals(await readSiteFile("main.js"))).toBe(true); + expect(mainJs?.contentType).toBe("application/javascript; charset=utf-8"); + expect(mainJs?.authorization).toBeUndefined(); + const styles = byPath.get("/styles.css"); + expect(styles?.data.equals(await readSiteFile("styles.css"))).toBe(true); + expect(styles?.contentType).toBe("text/css; charset=utf-8"); + expect(styles?.authorization).toBeUndefined(); + + // Finalize: exactly one file part — the index.html bytes. No payload, + // no modules. + expect(t.api.finalizeRequests).toHaveLength(1); + const fields = t.api.finalizeRequests[0]; + expect(fields.map((f) => f.name)).toEqual(["index.html"]); + expect(fields[0].data.equals(await readSiteFile("index.html"))).toBe(true); + // Bun's compiled binary normalizes Blob types to include the charset. + expect(fields[0].contentType).toMatch(/^text\/html(;\s*charset=utf-8)?$/i); + }); + + it("sends no PUTs and still finalizes when every asset is already stored", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "true" }); + mockResourcePushes(); + mockStaticCreate([]); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + + const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Found 3 static assets (0 new)"); + expect(t.api.presignedUploadRequests).toHaveLength(0); + expect(t.api.finalizeRequests).toHaveLength(1); + expect(t.api.finalizeRequests[0].map((f) => f.name)).toEqual([ + "index.html", + ]); + }); + + it("emits a single JSON document with --json", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); + mockResourcePushes(); + mockStaticCreate(["/main.js", "/styles.css"]); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + + const result = await t.run( + "deploy", + "-y", + "--git-hash", + GIT_HASH, + "--json", + ); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual({ + deploymentId: DEPLOYMENT_ID, + gitHash: GIT_HASH, + }); + }); + + it("requires a commit hash outside a git checkout", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); + mockResourcePushes(); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("--git-hash"); + expect(t.api.deploymentCreateRequests).toHaveLength(0); + }); +}); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index d210e5e2e..9779f6c10 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -202,6 +202,88 @@ interface CreateAppResponse { name: string; } +// ─── DEPLOYMENTS TYPES ────────────────────────────────────── + +interface DeploymentCreateResponse { + deployment_id: string; + /** Where the assets still owed should go; null/omitted = nothing owed. */ + asset_uploads?: { + type: "s3"; + uploads: Array<{ + path: string; + content_type: string; + content_length: number; + url: string; + }>; + } | null; +} + +interface DeploymentFinalizeResponse { + deployment_id: string; +} + +/** A parsed part of a multipart/form-data request body. */ +interface MultipartField { + name: string; + filename?: string; + contentType?: string; + data: Buffer; +} + +/** + * Minimal multipart/form-data parser for captured raw request bodies + * (the global express.raw middleware buffers multipart bodies as-is). + */ +function parseMultipart( + body: Buffer, + contentTypeHeader: string, +): MultipartField[] { + const boundaryMatch = /boundary=(?:"([^"]+)"|([^;]+))/.exec( + contentTypeHeader, + ); + if (!boundaryMatch) { + throw new Error(`No multipart boundary in: ${contentTypeHeader}`); + } + const boundary = `--${boundaryMatch[1] ?? boundaryMatch[2]}`; + + const fields: MultipartField[] = []; + const raw = body.toString("binary"); + const sections = raw.split(boundary).slice(1, -1); // drop preamble + closing "--" + + for (const section of sections) { + const part = section.replace(/^\r\n/, ""); + const headerEnd = part.indexOf("\r\n\r\n"); + if (headerEnd === -1) continue; + + const headerBlock = part.slice(0, headerEnd); + const data = Buffer.from( + part.slice(headerEnd + 4).replace(/\r\n$/, ""), + "binary", + ); + + const nameMatch = /name="([^"]*)"/.exec(headerBlock); + const filenameMatch = /filename="([^"]*)"/.exec(headerBlock); + const typeMatch = /content-type:\s*([^\r\n]+)/i.exec(headerBlock); + + fields.push({ + name: nameMatch?.[1] ?? "", + filename: filenameMatch?.[1], + contentType: typeMatch?.[1].trim(), + data, + }); + } + + return fields; +} + +/** A captured presigned-style asset PUT. */ +interface CapturedPresignedUpload { + path: string; + authorization?: string; + contentType?: string; + data: Buffer; +} + interface ListProjectsResponse { id: string; name: string; @@ -569,6 +651,72 @@ export class TestAPIServer { ); } + // ─── DEPLOYMENT ENDPOINTS ───────────────────────────────── + + /** Captured JSON bodies of POST deployments requests. */ + readonly deploymentCreateRequests: unknown[] = []; + /** Captured presigned-style asset PUTs (see mockPresignedUpload). */ + readonly presignedUploadRequests: CapturedPresignedUpload[] = []; + /** Captured multipart fields of finalize requests. */ + readonly finalizeRequests: MultipartField[][] = []; + + /** + * Mock POST /api/apps/{appId}/deployments. Captures the JSON request body + * in `deploymentCreateRequests`. + */ + mockDeploymentCreate(response: DeploymentCreateResponse): this { + this.pendingRoutes.push({ + method: "POST", + path: `/api/apps/${this.appId}/deployments`, + handler: (req, res) => { + this.deploymentCreateRequests.push(req.body); + res.status(200).json(response); + }, + }); + return this; + } + + /** + * Register a presigned-style PUT target for a static asset: serves + * PUT /presigned{path} — point `asset_uploads[].url` at + * `${baseUrl}/presigned{path}` — capturing the raw body, Content-Type, + * and any Authorization header in `presignedUploadRequests`. + */ + mockPresignedUpload(path: string): this { + this.pendingRoutes.push({ + method: "PUT", + path: `/presigned${path}`, + handler: (req, res) => { + this.presignedUploadRequests.push({ + path, + authorization: req.headers.authorization, + contentType: req.headers["content-type"], + data: req.body as Buffer, + }); + res.status(200).end(); + }, + }); + return this; + } + + /** + * Mock POST /api/apps/{appId}/deployments/{id}/finalize. Captures the + * multipart fields in `finalizeRequests`. + */ + mockDeploymentFinalize(response: DeploymentFinalizeResponse): this { + this.pendingRoutes.push({ + method: "POST", + path: `/api/apps/${this.appId}/deployments/:deploymentId/finalize`, + handler: (req, res) => { + this.finalizeRequests.push( + parseMultipart(req.body as Buffer, req.headers["content-type"] ?? ""), + ); + res.status(200).json(response); + }, + }); + return this; + } + // ─── SECRETS ENDPOINTS ─────────────────────────────────── mockSecretsList(response: SecretsListResponse): this { diff --git a/packages/cli/tests/core/deployments-manifest.spec.ts b/packages/cli/tests/core/deployments-manifest.spec.ts new file mode 100644 index 000000000..09d92ff35 --- /dev/null +++ b/packages/cli/tests/core/deployments-manifest.spec.ts @@ -0,0 +1,121 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, rm, truncate, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { buildAssetManifest, hashAsset } from "@/core/deployments/manifest.js"; + +describe("hashAsset", () => { + it("computes the first 32 hex chars of sha256(utf8(app_id) || bytes)", () => { + // Known vector: sha256("test-app-id" + "hello world") = + // b24ad526981fbac802de45c88c134ba4... (first 32 hex chars) + expect(hashAsset("test-app-id", Buffer.from("hello world"))).toBe( + "b24ad526981fbac802de45c88c134ba4", + ); + }); + + it("matches a locally computed sha256 over the concatenated bytes", () => { + const expected = createHash("sha256") + .update(Buffer.concat([Buffer.from("app-1"), Buffer.from("content")])) + .digest("hex") + .slice(0, 32); + expect(hashAsset("app-1", Buffer.from("content"))).toBe(expected); + }); + + it("salts with the app id so tenants can only collide with themselves", () => { + const content = Buffer.from("hello world"); + expect(hashAsset("test-app-id", content)).not.toBe( + hashAsset("other-app", content), + ); + }); +}); + +describe("buildAssetManifest", () => { + let assetsDir: string; + + beforeEach(async () => { + assetsDir = await mkdtemp(join(tmpdir(), "b44-assets-")); + }); + + afterEach(async () => { + await rm(assetsDir, { recursive: true, force: true }); + }); + + it("builds manifest keys as /-prefixed forward-slash paths with hash and size", async () => { + await writeFile(join(assetsDir, "index.html"), "

Hello

\n"); + await mkdir(join(assetsDir, "assets")); + await writeFile(join(assetsDir, "assets", "app.js"), "console.log(1);"); + + const { manifest, filesByHash } = await buildAssetManifest( + assetsDir, + "test-app-id", + ); + + expect(Object.keys(manifest).sort()).toEqual([ + "/assets/app.js", + "/index.html", + ]); + expect(manifest["/index.html"]).toEqual({ + hash: hashAsset("test-app-id", Buffer.from("

Hello

\n")), + size: 15, + }); + const entry = manifest["/assets/app.js"]; + expect(filesByHash.get(entry.hash)?.size).toBe(entry.size); + }); + + it("honors .assetsignore patterns (exact names, * globs, directory patterns)", async () => { + await writeFile( + join(assetsDir, ".assetsignore"), + ["secret.txt", "*.log", "private/", "# a comment", ""].join("\n"), + ); + await writeFile(join(assetsDir, "keep.txt"), "keep"); + await writeFile(join(assetsDir, "secret.txt"), "drop"); + await writeFile(join(assetsDir, "debug.log"), "drop"); + await mkdir(join(assetsDir, "private")); + await writeFile(join(assetsDir, "private", "notes.txt"), "drop"); + await mkdir(join(assetsDir, "nested")); + await writeFile(join(assetsDir, "nested", "secret.txt"), "drop"); + await writeFile(join(assetsDir, "nested", "keep.js"), "keep"); + + const { manifest } = await buildAssetManifest(assetsDir, "test-app-id"); + + expect(Object.keys(manifest).sort()).toEqual([ + "/keep.txt", + "/nested/keep.js", + ]); + }); + + it("always skips .assetsignore, wrangler.json, and .dev.vars", async () => { + await writeFile(join(assetsDir, "index.html"), "hi"); + await writeFile(join(assetsDir, "wrangler.json"), "{}"); + await writeFile(join(assetsDir, ".dev.vars"), "SECRET=1"); + + const { manifest } = await buildAssetManifest(assetsDir, "test-app-id"); + + expect(Object.keys(manifest)).toEqual(["/index.html"]); + }); + + it("rejects files larger than 25 MiB with a per-file error", async () => { + const bigFile = join(assetsDir, "big.bin"); + await writeFile(bigFile, ""); + await truncate(bigFile, 25 * 1024 * 1024 + 1); + + await expect(buildAssetManifest(assetsDir, "test-app-id")).rejects.toThrow( + /"big\.bin".*exceeds the 25 MiB per-file limit/, + ); + }); + + it("dedupes identical files by hash in filesByHash", async () => { + await writeFile(join(assetsDir, "a.txt"), "same"); + await writeFile(join(assetsDir, "b.txt"), "same"); + + const { manifest, filesByHash } = await buildAssetManifest( + assetsDir, + "test-app-id", + ); + + expect(Object.keys(manifest)).toHaveLength(2); + expect(manifest["/a.txt"].hash).toBe(manifest["/b.txt"].hash); + expect(filesByHash.size).toBe(1); + }); +}); From a04181c49c2ffdbf450041e956d722aebd4f9513 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 4 Aug 2026 07:23:03 +0300 Subject: [PATCH 02/12] wip --- bun.lock | 6 +- docs/deployments.md | 18 +- docs/resources.md | 2 +- packages/cli/package.json | 2 +- .../cli/src/cli/commands/project/deploy.ts | 19 +- packages/cli/src/cli/commands/site/deploy.ts | 38 ++-- packages/cli/src/cli/commands/site/index.ts | 4 +- .../src/cli/commands/site/run-app-deploy.ts | 12 +- packages/cli/src/cli/index.ts | 3 + packages/cli/src/cli/program.ts | 2 +- packages/cli/src/cli/types.ts | 7 + packages/cli/src/core/deployments/api.ts | 85 -------- packages/cli/src/core/deployments/git-hash.ts | 42 ---- packages/cli/src/core/deployments/index.ts | 6 - packages/cli/src/core/deployments/manifest.ts | 183 ------------------ packages/cli/src/core/deployments/schema.ts | 141 -------------- packages/cli/src/core/index.ts | 1 - packages/cli/src/core/site/api.ts | 90 ++++++++- packages/cli/src/core/site/deploy-app.ts | 44 +++-- packages/cli/src/core/site/gate.ts | 18 ++ packages/cli/src/core/site/index.ts | 4 + packages/cli/src/core/site/manifest.ts | 97 ++++++++++ packages/cli/src/core/site/schema.ts | 139 +++++++++++++ .../core/{deployments => site}/static-site.ts | 50 +++-- .../src/core/{deployments => site}/upload.ts | 0 packages/cli/src/core/utils/git.ts | 21 ++ packages/cli/src/core/utils/index.ts | 1 + .../tests/cli/static_site_deployments.spec.ts | 77 +++++++- ...manifest.spec.ts => site-manifest.spec.ts} | 81 +++++++- 29 files changed, 636 insertions(+), 557 deletions(-) delete mode 100644 packages/cli/src/core/deployments/api.ts delete mode 100644 packages/cli/src/core/deployments/git-hash.ts delete mode 100644 packages/cli/src/core/deployments/index.ts delete mode 100644 packages/cli/src/core/deployments/manifest.ts delete mode 100644 packages/cli/src/core/deployments/schema.ts create mode 100644 packages/cli/src/core/site/gate.ts create mode 100644 packages/cli/src/core/site/manifest.ts rename packages/cli/src/core/{deployments => site}/static-site.ts (69%) rename packages/cli/src/core/{deployments => site}/upload.ts (100%) create mode 100644 packages/cli/src/core/utils/git.ts rename packages/cli/tests/core/{deployments-manifest.spec.ts => site-manifest.spec.ts} (56%) diff --git a/bun.lock b/bun.lock index 82ffd1bb8..cd7aecae7 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,7 @@ }, "packages/cli": { "name": "base44", - "version": "0.1.5", + "version": "0.1.7", "bin": { "base44": "./bin/run.js", }, @@ -49,7 +49,7 @@ "express": "^5.0.1", "front-matter": "^4.0.2", "get-port": "^7.1.0", - "globby": "^16.1.0", + "globby": "^16.2.2", "http-proxy-middleware": "^3.0.5", "json-schema-to-typescript": "^15.0.4", "json5": "^2.2.3", @@ -658,7 +658,7 @@ "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "globby": ["globby@16.1.0", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "fast-glob": "^3.3.3", "ignore": "^7.0.5", "is-path-inside": "^4.0.0", "slash": "^5.1.0", "unicorn-magic": "^0.4.0" } }, "sha512-+A4Hq7m7Ze592k9gZRy4gJ27DrXRNnC1vPjxTt1qQxEY8RxagBkBxivkCwg7FxSTG0iLLEMaUx13oOr0R2/qcQ=="], + "globby": ["globby@16.2.2", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "fast-glob": "^3.3.3", "ignore": "^7.0.5", "is-path-inside": "^4.0.0", "slash": "^5.1.0", "unicorn-magic": "^0.4.0" } }, "sha512-NLvV9ubZ6NDsJaOpKPy3cQeJpKi9DcWiyCiFUpJPA0YihRqiE6RWaLUmgNNPr8MgPpLZjnBjSmou7uZBRJv9wA=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], diff --git a/docs/deployments.md b/docs/deployments.md index 9e95e2058..d022d4a6e 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -2,13 +2,13 @@ **Keywords:** deployments, static site, asset manifest, hash, git hash, commit, presigned, S3, finalize, index.html sentinel, BASE44_STATIC_DEPLOYMENTS, upload -Deployments ship an app's built output addressed by the commit that produced it. The core module is `src/core/deployments/` (`git-hash.ts`, `manifest.ts`, `static-site.ts`, `upload.ts`, `api.ts`, `schema.ts`). Today it carries the env-gated static-site lane; the create response is an ADT designed so a worker (`cf`) arm can slot in next to the static (`s3`) arm without protocol changes — that is the progressive-upgrade path for full-stack apps. +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/`: `gate.ts` (the env gate), `manifest.ts` (asset walk + hashing), `static-site.ts` (the flow), `upload.ts` (presigned PUTs), with the requests and responses in the shared `api.ts` / `schema.ts` next to the legacy tar.gz upload. Today it carries the env-gated static-site lane; the create response is an ADT designed so a worker (`cf`) arm can slot in next to the static (`s3`) arm without protocol changes — that is the progressive-upgrade path for full-stack apps. **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. ## 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) or a non-hex value fails fast with guidance. Pattern: `^[a-fA-F0-9]{7,64}$` (same validation as the server). +`deployStaticSite()` resolves its own address: an explicit `--git-hash` wins, otherwise the checked-out commit at the project root. No hash (not a git checkout, no flag) or a non-hex value fails fast with guidance. The git plumbing is general-purpose and lives in `src/core/utils/git.ts` — `getGitHead(cwd)` and `isGitCommitHash(value)` (pattern `^[a-fA-F0-9]{7,64}$`, the same validation as the server). ## API Contract (app-scoped, via `getAppClient()`) @@ -20,21 +20,23 @@ Deployments ship an app's built output addressed by the commit that produced it. ## 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. +`hash = first 32 hex chars of sha256(utf8(app_id) || raw file bytes)` — see `hashAsset()` in `src/core/site/manifest.ts`. The app-id salt is a cache-poisoning defense: a tenant can only produce hash collisions with its own files. -The output directory is walked recursively. `.assetsignore` at the 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. +The output directory is walked with `globby` (`**/*`, dotfiles included, symlinks not followed). `.assetsignore` at the root is honored via globby's `ignoreFiles`, which parses it with the `ignore` package — the same library wrangler uses — so it gets real gitignore semantics: anchoring, directory patterns, `**`, literal braces/extglobs, and **negation** (`!.dev.vars.example` after `.dev.vars*`). Do not translate the patterns by hand, and do not pass globby's `ignore` option alongside `ignoreFiles`: globby globs for ignore files using that option, so it would then find none and silently apply no patterns at all. `.assetsignore` itself, `wrangler.json`, and `.dev.vars` are dropped from the results by name instead. 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. Content types are deliberately **not** derived client-side: the server decides each asset's Content-Type, signs it into the presigned URL, and the CLI echoes it verbatim — deriving our own value would 403 on any mapping difference. ## The Static Lane (experimental, env-gated) -With `BASE44_STATIC_DEPLOYMENTS=1` (or `true`; internal gate, not user-facing yet), a project with `site.outputDirectory` deploys through the deployments API instead of the legacy tar.gz upload: the output directory becomes the asset manifest (index.html included — it is only ever excluded from uploads), 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 command, same `--git-hash` addressing, same `--json` output (`{deploymentId, gitHash}`). +`staticDeploymentsEnabled()` in `gate.ts` is the switch, and it is read in exactly one place: `runCLI()` resolves it into `CLIContext.staticDeployments` after `.env` files load, and every layer below is *told* the answer (`deployAppSite({staticDeployments})`, `getSiteDeployCommand(staticDeployments)`). Core never reads the environment, so the flag registration and the transport choice cannot disagree. With `BASE44_STATIC_DEPLOYMENTS=1` (or `true`; internal gate, not user-facing yet), a project with `site.outputDirectory` deploys through the deployments API instead of the legacy tar.gz upload: the output directory becomes the asset manifest (index.html included — it is only ever excluded from uploads), 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 commands, same `--json` output (`{deploymentId, gitHash}`). -Both `base44 deploy` and `base44 site deploy` route through `deployAppSite()` in `core/site/` (see [resources.md](resources.md#site-module-not-a-resource)), which picks the transport (`static-deployment` vs legacy `static`). The primary automated consumer is the platform's build/deploy sandbox, which runs `base44 deploy -y --json --git-hash ` with a scoped `apps:deploy` workspace key — so the sandbox and a human at a terminal go through the exact same door. +Both `base44 deploy` and `base44 site deploy` route through `deployAppSite()` in `core/site/` (see [resources.md](resources.md#site-module-not-a-resource)), which picks the transport (`static-deployment` vs legacy `static`). + +**`--git-hash` exists only on `base44 site deploy`, and only when the gate is on.** That is the command the build sandbox drives — it ships the site, not the whole project — so the commit override lives there and nowhere else. It is registered inside `getSiteDeployCommand()` when `staticDeployments` is true; otherwise it is absent from `--help` and rejected as an unknown option, so a released CLI carrying this lane looks unchanged to users. `base44 deploy` still takes the lane when the gate is on, but always addresses the checkout's `HEAD`. The sandbox runs `base44 site deploy -y --json --git-hash ` with a scoped `apps:deploy` workspace key. ## Testing -`TestAPIServer` mocks: `mockDeploymentCreate` (captures the JSON body in `deploymentCreateRequests`; echoes whatever response shape you pass — `asset_uploads` is `{type: "s3", ...}` or `null`), `mockPresignedUpload(path)` (serves a presigned-style `PUT /presigned{path}` target, captures body/Content-Type/Authorization in `presignedUploadRequests`), `mockDeploymentFinalize` (captures multipart fields in `finalizeRequests`). Fixture: `tests/fixtures/with-site/` (static output dir) — not a git repo, so specs pass `--git-hash`. Unit tests live in `tests/core/deployments-*.spec.ts`. +`TestAPIServer` mocks: `mockDeploymentCreate` (captures the JSON body in `deploymentCreateRequests`; echoes whatever response shape you pass — `asset_uploads` is `{type: "s3", ...}` or `null`), `mockPresignedUpload(path)` (serves a presigned-style `PUT /presigned{path}` target, captures body/Content-Type/Authorization in `presignedUploadRequests`), `mockDeploymentFinalize` (captures multipart fields in `finalizeRequests`). Fixture: `tests/fixtures/with-site/` (static output dir) — not a git repo, so specs pass `--git-hash`. Manifest and ignore-pattern unit tests live in `tests/core/site-manifest.spec.ts`. ## Rules (Deployments-Specific) @@ -42,4 +44,4 @@ Both `base44 deploy` and `base44 site deploy` route through `deployAppSite()` in - **Never derive an upload's Content-Type client-side** — the server signs it into the presigned URL; echo the signed value verbatim - **Presigned PUTs carry no auth headers and never use the app client** — the URL itself is the scoped credential - **`git_hash` is required** — a build with no commit behind it has no address and could never be published -- **Legacy behavior stays identical** when the gate is off — the tar.gz site path must not change +- **Legacy behavior stays identical** when the gate is off — the tar.gz site path must not change, and nothing about the lane (flags, help text, output) may surface diff --git a/docs/resources.md b/docs/resources.md index 4c7b01718..8896a88b6 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -80,7 +80,7 @@ The site module at `packages/cli/src/core/site/` handles deploying an app's buil It owns **which transport ships the build**. `deployAppSite()` in `deploy-app.ts` is the single entry point both `base44 deploy` and `base44 site deploy` call: -- `site.outputDirectory` ships as a static site: through the deployments API when the env-gated lane is enabled (see [deployments.md](deployments.md)), else the legacy path — tar.gz the built files and upload via `POST /api/apps/{app_id}/deploy-dist`. +- `site.outputDirectory` ships as a static site: through the deployments API when the env-gated lane is enabled (`gate.ts`, `manifest.ts`, `static-site.ts`, `upload.ts` — see [deployments.md](deployments.md)), else the legacy path, tar.gz the built files and upload via `POST /api/apps/{app_id}/deploy-dist`. Both transports share the module's `api.ts` and `schema.ts`. - No `site.outputDirectory` → `{ kind: "none" }`. ```typescript diff --git a/packages/cli/package.json b/packages/cli/package.json index b5d867d9b..2c6913f0e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -67,7 +67,7 @@ "express": "^5.0.1", "front-matter": "^4.0.2", "get-port": "^7.1.0", - "globby": "^16.1.0", + "globby": "^16.2.2", "http-proxy-middleware": "^3.0.5", "json-schema-to-typescript": "^15.0.4", "json5": "^2.2.3", diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index 5c3aabc35..676a046b9 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -25,13 +25,11 @@ import type { ConnectorSyncResult, StripeSyncResult, } from "@/core/resources/connector/index.js"; -import { detectAppDeployKind } from "@/core/site/index.js"; interface DeployOptions { yes?: boolean; build?: boolean; projectRoot?: string; - gitHash?: string; } export async function deployAction( @@ -47,12 +45,7 @@ export async function deployAction( const { project, entities, functions, agents, connectors, authConfig } = projectData; - // Best-effort pre-build look at what the site step would ship, for the - // summary and the no-resources check. The build below can change the - // answer, so the deploy itself decides again. - const plannedSite = await detectAppDeployKind(project); - - if (!hasResourcesToDeploy(projectData) && plannedSite === "none") { + if (!hasResourcesToDeploy(projectData)) { return { outroMessage: "No resources found to deploy", }; @@ -133,9 +126,9 @@ export async function deployAction( }, }); - const siteResult = await runAppSiteDeploy(ctx, project, { - gitHash: options.gitHash, - }); + // No `--git-hash` here: the commit override is only exposed on + // `base44 site deploy`, so a unified deploy always addresses HEAD. + const siteResult = await runAppSiteDeploy(ctx, project); // Handle connector-specific post-deploy flows const connectorResults = result.connectorResults ?? []; @@ -192,10 +185,6 @@ export function getDeployCommand(): Command { "Deploy all project resources (entities, functions, agents, connectors, and site)", ) .option("-y, --yes", "Skip confirmation prompt") - .option( - "--git-hash ", - "Commit the build came from (defaults to the checkout's HEAD)", - ) .option("--build", "Build the site before deploying (skips the prompt)") .option("--no-build", "Deploy without building (skips the prompt)") .action(deployAction); diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index 38e1e300b..a88c499c2 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -1,11 +1,11 @@ import { confirm, isCancel } from "@clack/prompts"; import type { Command } from "commander"; +import { Option } from "commander"; import { maybeBuildBeforeDeploy } from "@/cli/commands/project/site-build.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { ConfigNotFoundError, InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/project/index.js"; -import { detectAppDeployKind } from "@/core/site/index.js"; import { runAppSiteDeploy } from "./run-app-deploy.js"; interface DeployOptions { @@ -25,9 +25,9 @@ async function deployAction( const { project } = await readProjectConfig(); - const kind = await detectAppDeployKind(project); + const outputDirectory = project.site?.outputDirectory; - if (kind === "none") { + if (!outputDirectory) { throw new ConfigNotFoundError("No site configuration found.", { hints: [ { @@ -40,7 +40,7 @@ async function deployAction( if (!options.yes) { const shouldDeploy = await confirm({ - message: `Deploy site from ${project.site?.outputDirectory}?`, + message: `Deploy site from ${outputDirectory}?`, }); if (isCancel(shouldDeploy) || !shouldDeploy) { @@ -76,15 +76,27 @@ async function deployAction( return { outroMessage: "Nothing to deploy" }; } -export function getSiteDeployCommand(): Command { - return new Base44Command("deploy") - .description("Deploy the built site to Base44 hosting") +export function getSiteDeployCommand(staticDeployments = false): Command { + const command = new Base44Command("deploy") + .description("Deploy built site files to Base44 hosting") .option("-y, --yes", "Skip confirmation prompt") - .option( - "--git-hash ", - "Commit the build came from (defaults to the checkout's HEAD)", - ) .option("--build", "Build the site before deploying (skips the prompt)") - .option("--no-build", "Deploy without building (skips the prompt)") - .action(deployAction); + .option("--no-build", "Deploy without building (skips the prompt)"); + + // `--git-hash` addresses a deployments-API deploy by the commit that produced + // the build, so it only means anything on that lane — and that lane is + // internal until the server side ships. Registering it only when the lane is + // on keeps it out of `--help` and makes it an unknown option otherwise, as if + // it did not exist. This is the command the build sandbox drives, so it is + // the only one that exposes the override. + if (staticDeployments) { + command.addOption( + new Option( + "--git-hash ", + "Commit the build came from (defaults to the checkout's HEAD)", + ), + ); + } + + return command.action(deployAction); } diff --git a/packages/cli/src/cli/commands/site/index.ts b/packages/cli/src/cli/commands/site/index.ts index 2850e3602..031c717c1 100644 --- a/packages/cli/src/cli/commands/site/index.ts +++ b/packages/cli/src/cli/commands/site/index.ts @@ -2,9 +2,9 @@ import { Command } from "commander"; import { getSiteDeployCommand } from "./deploy.js"; import { getSiteOpenCommand } from "./open.js"; -export function getSiteCommand(): Command { +export function getSiteCommand(staticDeployments = false): Command { return new Command("site") .description("Manage app site (frontend app)") - .addCommand(getSiteDeployCommand()) + .addCommand(getSiteDeployCommand(staticDeployments)) .addCommand(getSiteOpenCommand()); } diff --git a/packages/cli/src/cli/commands/site/run-app-deploy.ts b/packages/cli/src/cli/commands/site/run-app-deploy.ts index a402b9758..066429653 100644 --- a/packages/cli/src/cli/commands/site/run-app-deploy.ts +++ b/packages/cli/src/cli/commands/site/run-app-deploy.ts @@ -22,26 +22,23 @@ const TASK_LABELS = { * only to pick the messages; `deployAppSite` decides for itself what to ship. */ export async function runAppSiteDeploy( - { runTask, log }: CLIContext, + { runTask, log, staticDeployments }: CLIContext, target: AppSiteTarget, options: { gitHash?: string } = {}, ): Promise { - const kind = await detectAppDeployKind(target); + const kind = detectAppDeployKind(target, { staticDeployments }); if (kind === "none") return { kind: "none" }; const labels = TASK_LABELS[kind]; const progressLines: string[] = []; - const warnings: string[] = []; const result = await runTask( labels.start, async (updateMessage) => await deployAppSite(target, { + staticDeployments, gitHash: options.gitHash, progress: { - onWarning: (message) => { - warnings.push(message); - }, onAssets: ({ totalAssets, newAssets }) => { const line = `Found ${totalAssets} static assets (${newAssets} new)`; progressLines.push(line); @@ -61,9 +58,6 @@ export async function runAppSiteDeploy( for (const line of progressLines) { log.message(theme.styles.dim(line)); } - for (const warning of warnings) { - log.warn(warning); - } return result; } diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index 3d8d5f0f2..e7a6352f0 100644 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -6,6 +6,7 @@ import { ClackLogger, SimpleLogger } from "@base44-cli/logger"; import { createProgram } from "@/cli/program.js"; import { ensureNpmAssets } from "@/core/assets.js"; import { readAuth } from "@/core/auth/index.js"; +import { staticDeploymentsEnabled } from "@/core/site/index.js"; import { CLIExitError } from "./errors.js"; import { ErrorReporter } from "./telemetry/error-reporter.js"; import { addCommandInfoToErrorReporter } from "./telemetry/index.js"; @@ -44,6 +45,8 @@ async function runCLI(options?: RunCLIOptions): Promise { errorReporter, isNonInteractive, jsonMode, + // Read the lane gate once, after bootstrap-env has loaded .env files. + staticDeployments: staticDeploymentsEnabled(), distribution: options?.distribution ?? "npm", log, runTask, diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 04e1c33e6..0c36e23fd 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -104,7 +104,7 @@ export function createProgram(context: CLIContext): Command { program.addCommand(getAuthCommand()); // Register site commands - program.addCommand(getSiteCommand()); + program.addCommand(getSiteCommand(context.staticDeployments)); // Register types command program.addCommand(getTypesCommand()); diff --git a/packages/cli/src/cli/types.ts b/packages/cli/src/cli/types.ts index efb23a242..a82a14eac 100644 --- a/packages/cli/src/cli/types.ts +++ b/packages/cli/src/cli/types.ts @@ -14,6 +14,13 @@ export interface CLIContext { * the lifecycle keeps stdout pure (status/logs are routed to stderr). */ jsonMode: boolean; + /** + * The experimental static-site deployments lane is on for this process + * (`BASE44_STATIC_DEPLOYMENTS`). This is the only place the gate is read — + * commands pass it down, so no layer below re-reads the environment and the + * whole run agrees on one answer. + */ + staticDeployments: boolean; distribution: Distribution; log: Logger; runTask: RunTaskFn; diff --git a/packages/cli/src/core/deployments/api.ts b/packages/cli/src/core/deployments/api.ts deleted file mode 100644 index 311a92092..000000000 --- a/packages/cli/src/core/deployments/api.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { KyResponse } from "ky"; -import { getAppClient } from "@/core/clients/index.js"; -import { ApiError, SchemaValidationError } from "@/core/errors.js"; -import type { - CreateDeploymentRequest, - CreateDeploymentResponse, - FinalizeDeploymentResponse, -} from "./schema.js"; -import { - CreateDeploymentResponseSchema, - FinalizeDeploymentResponseSchema, -} from "./schema.js"; - -export async function createDeployment( - request: CreateDeploymentRequest, -): Promise { - const appClient = getAppClient(); - - let response: KyResponse; - try { - response = await appClient.post("deployments", { - json: request, - timeout: 120_000, - }); - } catch (error) { - throw await ApiError.fromHttpError(error, "creating deployment"); - } - - const result = CreateDeploymentResponseSchema.safeParse( - await response.json(), - ); - if (!result.success) { - throw new SchemaValidationError( - "Invalid response from server", - result.error, - ); - } - return result.data; -} - -/** - * Finalize a static-site (s3-target) deployment. The form carries exactly one - * file part — `index.html` — and nothing else (no `payload`, no modules): - * index.html is always excluded from the presigned uploads and travels - * through finalize as the sentinel that completes the deployment. - */ -export async function finalizeStaticDeployment( - deploymentId: string, - indexHtml: Uint8Array, -): Promise { - const formData = new FormData(); - formData.append( - "index.html", - new File([indexHtml], "index.html", { type: "text/html" }), - ); - return await postFinalize(deploymentId, formData); -} - -async function postFinalize( - deploymentId: string, - formData: FormData, -): Promise { - const appClient = getAppClient(); - - let response: KyResponse; - try { - response = await appClient.post( - `deployments/${encodeURIComponent(deploymentId)}/finalize`, - { body: formData, timeout: 180_000 }, - ); - } catch (error) { - throw await ApiError.fromHttpError(error, "finalizing deployment"); - } - - const result = FinalizeDeploymentResponseSchema.safeParse( - await response.json(), - ); - if (!result.success) { - throw new SchemaValidationError( - "Invalid response from server", - result.error, - ); - } - return result.data; -} diff --git a/packages/cli/src/core/deployments/git-hash.ts b/packages/cli/src/core/deployments/git-hash.ts deleted file mode 100644 index 3910429cd..000000000 --- a/packages/cli/src/core/deployments/git-hash.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { execa } from "execa"; -import { InvalidInputError } from "@/core/errors.js"; -import { GIT_HASH_PATTERN } from "./schema.js"; - -/** - * The commit this build came from — a deployment is addressed by it, so the - * hash is required. An explicit hash (flag/automation) wins; otherwise it - * comes from the git checkout at the project root. - */ -export async function resolveGitHash( - projectRoot: string, - explicit?: string, -): Promise { - const hash = explicit ?? (await gitHead(projectRoot)); - if (!hash || !GIT_HASH_PATTERN.test(hash)) { - throw new InvalidInputError( - explicit - ? `'${explicit}' is not a git commit hash.` - : "Deployments are addressed by the commit that produced the build, and no git commit was found.", - { - hints: [ - { - message: - "Run the deploy from a git checkout, or pass the commit explicitly with --git-hash.", - }, - ], - }, - ); - } - return hash; -} - -async function gitHead(projectRoot: string): Promise { - try { - const { stdout } = await execa("git", ["rev-parse", "HEAD"], { - cwd: projectRoot, - }); - return stdout.trim(); - } catch { - return null; - } -} diff --git a/packages/cli/src/core/deployments/index.ts b/packages/cli/src/core/deployments/index.ts deleted file mode 100644 index 0a3e99d49..000000000 --- a/packages/cli/src/core/deployments/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./api.js"; -export * from "./git-hash.js"; -export * from "./manifest.js"; -export * from "./schema.js"; -export * from "./static-site.js"; -export * from "./upload.js"; diff --git a/packages/cli/src/core/deployments/manifest.ts b/packages/cli/src/core/deployments/manifest.ts deleted file mode 100644 index 86653dbed..000000000 --- a/packages/cli/src/core/deployments/manifest.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { createHash } from "node:crypto"; -import { readdir, readFile, stat } from "node:fs/promises"; -import { join } from "node:path"; -import { InvalidInputError } from "@/core/errors.js"; -import { pathExists, readTextFile } from "@/core/utils/fs.js"; -import type { - AssetFile, - AssetManifestEntry, - AssetManifestResult, -} from "./schema.js"; - -const MAX_ASSET_SIZE_BYTES = 25 * 1024 * 1024; // 25 MiB -const MAX_ASSET_COUNT = 100_000; - -const ASSETS_IGNORE_FILE = ".assetsignore"; - -/** Files never uploaded as assets, regardless of .assetsignore. */ -const ALWAYS_SKIPPED_FILES = new Set([ - ASSETS_IGNORE_FILE, - "wrangler.json", - ".dev.vars", -]); - -/** - * Content-addressed asset hash: first 32 hex chars of - * sha256(utf8(app_id) || raw file bytes). Salting with the app id means a - * tenant can only produce hash collisions with their own files, so a - * malicious upload cannot poison another app's asset cache. - */ -export function hashAsset(appId: string, content: Buffer): string { - return createHash("sha256") - .update(Buffer.from(appId, "utf8")) - .update(content) - .digest("hex") - .slice(0, 32); -} - -type IgnoreMatcher = (relativePath: string, isDirectory: boolean) => boolean; - -function globToRegExp(glob: string): RegExp { - let source = ""; - for (let i = 0; i < glob.length; i++) { - const char = glob[i]; - if (char === "*") { - if (glob[i + 1] === "*") { - source += ".*"; - i++; - } else { - source += "[^/]*"; - } - } else if (char === "?") { - source += "[^/]"; - } else { - source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&"); - } - } - return new RegExp(`^${source}$`); -} - -/** - * Minimal gitignore-style matcher for .assetsignore. Supports exact names, - * `*`/`**` globs, directory patterns (trailing `/`), and root-anchored - * patterns (containing `/`). Negation (`!`) is not supported. - */ -function createIgnoreMatcher(lines: string[]): IgnoreMatcher { - const rules = lines - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !line.startsWith("#")) - .map((line) => { - const isDirOnly = line.endsWith("/"); - let pattern = isDirOnly ? line.slice(0, -1) : line; - const anchored = pattern.includes("/"); - pattern = pattern.replace(/^\//, ""); - return { regex: globToRegExp(pattern), anchored, isDirOnly }; - }); - - return (relativePath, isDirectory) => { - const segments = relativePath.split("/"); - return rules.some((rule) => { - if (rule.anchored) { - if (rule.isDirOnly ? isDirectory : true) { - if (rule.regex.test(relativePath)) return true; - } - // A directory pattern also ignores everything under the directory; - // matching directories are pruned during the walk. - return false; - } - // Unanchored: match the basename (and for dir-only rules, any segment — - // but directories are pruned during the walk, so files only need their - // own basename checked). - const basename = segments[segments.length - 1]; - if (rule.isDirOnly && !isDirectory) return false; - return rule.regex.test(basename); - }); - }; -} - -async function loadIgnoreMatcher(assetsDir: string): Promise { - const ignorePath = join(assetsDir, ASSETS_IGNORE_FILE); - if (!(await pathExists(ignorePath))) { - return () => false; - } - const content = await readTextFile(ignorePath); - return createIgnoreMatcher(content.split(/\r?\n/)); -} - -/** - * Walk the assets directory and build the deployment asset manifest. - * Honors `.assetsignore` at the assets root, always skips `.assetsignore`, - * `wrangler.json`, and `.dev.vars`, rejects files larger than 25 MiB, and - * caps the total file count at 100,000. - */ -export async function buildAssetManifest( - assetsDir: string, - appId: string, -): Promise { - const isIgnored = await loadIgnoreMatcher(assetsDir); - const manifest: Record = {}; - const filesByHash = new Map(); - - const relativeFilePaths = await collectFilePaths(assetsDir, "", isIgnored); - - if (relativeFilePaths.length > MAX_ASSET_COUNT) { - throw new InvalidInputError( - `Too many static assets: found ${relativeFilePaths.length}, the limit is ${MAX_ASSET_COUNT} files.`, - ); - } - - for (const relativePath of relativeFilePaths.sort()) { - const absolutePath = join(assetsDir, ...relativePath.split("/")); - const { size } = await stat(absolutePath); - if (size > MAX_ASSET_SIZE_BYTES) { - throw new InvalidInputError( - `Static asset "${relativePath}" is ${size} bytes, which exceeds the 25 MiB per-file limit.`, - ); - } - - const content = await readFile(absolutePath); - const hash = hashAsset(appId, content); - - manifest[`/${relativePath}`] = { hash, size }; - if (!filesByHash.has(hash)) { - filesByHash.set(hash, { absolutePath, hash, size }); - } - } - - return { manifest, filesByHash }; -} - -async function collectFilePaths( - dir: string, - relativeDir: string, - isIgnored: IgnoreMatcher, -): Promise { - const entries = await readdir(dir, { withFileTypes: true }); - const results: string[] = []; - - for (const entry of entries) { - const relativePath = relativeDir - ? `${relativeDir}/${entry.name}` - : entry.name; - - if (entry.isDirectory()) { - if (isIgnored(relativePath, true)) continue; - results.push( - ...(await collectFilePaths( - join(dir, entry.name), - relativePath, - isIgnored, - )), - ); - continue; - } - - if (!entry.isFile()) continue; - if (ALWAYS_SKIPPED_FILES.has(entry.name)) continue; - if (isIgnored(relativePath, false)) continue; - - results.push(relativePath); - } - - return results; -} diff --git a/packages/cli/src/core/deployments/schema.ts b/packages/cli/src/core/deployments/schema.ts deleted file mode 100644 index e9469d0e6..000000000 --- a/packages/cli/src/core/deployments/schema.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { z } from "zod"; - -// ─── SHARED ────────────────────────────────────────────────── - -/** Manifest entry keyed by URL-ish path ("/index.html"). */ -export interface AssetManifestEntry { - hash: string; - size: number; -} - -/** A static asset discovered in the assets directory, keyed by hash. */ -export interface AssetFile { - /** Absolute path on disk. */ - absolutePath: string; - hash: string; - size: number; -} - -export interface AssetManifestResult { - /** URL path → { hash, size }, ready for the create-deployment payload. */ - manifest: Record; - /** Hash → file info, used to serve the requested uploads. */ - filesByHash: Map; -} - -/** Progress of an in-flight asset upload set. */ -export interface AssetUploadProgress { - uploadedFiles: number; - totalFiles: number; -} - -/** Progress callbacks a deploy fires as it moves through its stages. */ -export interface DeploymentProgress { - /** Fired for non-fatal issues worth surfacing to the user. */ - onWarning?: (message: string) => void; - /** Fired after the deployment is created: total assets and how many need uploading. */ - onAssets?: (info: { totalAssets: number; newAssets: number }) => void; - /** Fired after each asset upload completes. */ - onAssetUpload?: (progress: AssetUploadProgress) => void; -} - -/** - * A deployment is addressed by the commit that produced it: the server derives - * the deployment id from `git_hash`, so one commit means one deployment and - * re-deploying a commit is idempotent. Same pattern the server validates. - */ -export const GIT_HASH_PATTERN = /^[a-fA-F0-9]{7,64}$/; - -/** - * Request payload for POST deployments (sent as snake_case JSON). A request - * without a worker config is a static-site deployment — the server answers - * it with the `s3` arm of the create response. - */ -export interface CreateDeploymentRequest { - git_hash: string; - asset_manifest: Record; -} - -// ─── RESPONSES ─────────────────────────────────────────────── - -/** A static asset the server wants uploaded, with its presigned S3 URL. */ -export interface PresignedAssetUpload { - /** Manifest path of the asset ("/assets/app.js"). */ - path: string; - /** Content-Type signed into the URL — the PUT must send it verbatim. */ - contentType: string; - /** Byte count signed into the URL — the PUT body must be exactly this long. */ - contentLength: number; - /** Presigned S3 URL — the URL itself is the credential. */ - url: string; -} - -interface S3AssetUploads { - type: "s3"; - uploads: PresignedAssetUpload[]; -} - -/** - * POST deployments answers `{deployment_id, asset_uploads}` where - * `asset_uploads` says where the assets still owed should go, discriminated - * on `type` — a config-less (static-site) request is always answered with - * the `s3` arm: direct presigned PUTs, always excluding `/index.html` - * (finalize carries it) — and is null when nothing is owed (no assets, or - * the build already exists). - */ -export const CreateDeploymentResponseSchema = z - .object({ - deployment_id: z.string(), - asset_uploads: z - .object({ - type: z.literal("s3"), - uploads: z.array( - z.object({ - path: z.string(), - content_type: z.string(), - content_length: z.number(), - url: z.string(), - }), - ), - }) - .nullable() - .optional(), - }) - .transform( - ( - data, - ): { - deploymentId: string; - assetUploads: S3AssetUploads | null; - } => ({ - deploymentId: data.deployment_id, - assetUploads: - data.asset_uploads == null - ? null - : { - type: "s3", - uploads: data.asset_uploads.uploads.map((upload) => ({ - path: upload.path, - contentType: upload.content_type, - contentLength: upload.content_length, - url: upload.url, - })), - }, - }), - ); - -export type CreateDeploymentResponse = z.infer< - typeof CreateDeploymentResponseSchema ->; - -export const FinalizeDeploymentResponseSchema = z - .object({ - deployment_id: z.string(), - }) - .transform((data) => ({ - deploymentId: data.deployment_id, - })); - -export type FinalizeDeploymentResponse = z.infer< - typeof FinalizeDeploymentResponseSchema ->; diff --git a/packages/cli/src/core/index.ts b/packages/cli/src/core/index.ts index cb9e329b6..b6b6250d7 100644 --- a/packages/cli/src/core/index.ts +++ b/packages/cli/src/core/index.ts @@ -2,7 +2,6 @@ export * from "./auth/index.js"; export * from "./clients/index.js"; export * from "./config.js"; export * from "./consts.js"; -export * from "./deployments/index.js"; export * from "./errors.js"; export * from "./project/index.js"; export * from "./resources/index.js"; diff --git a/packages/cli/src/core/site/api.ts b/packages/cli/src/core/site/api.ts index 9683316e2..a6144c789 100644 --- a/packages/cli/src/core/site/api.ts +++ b/packages/cli/src/core/site/api.ts @@ -1,10 +1,21 @@ import type { KyResponse } from "ky"; import { getAppClient } from "@/core/clients/index.js"; import { ApiError, SchemaValidationError } from "@/core/errors.js"; -import type { DeployResponse } from "@/core/site/schema.js"; -import { DeployResponseSchema } from "@/core/site/schema.js"; +import type { + CreateDeploymentRequest, + CreateDeploymentResponse, + DeployResponse, + FinalizeDeploymentResponse, +} from "@/core/site/schema.js"; +import { + CreateDeploymentResponseSchema, + DeployResponseSchema, + FinalizeDeploymentResponseSchema, +} from "@/core/site/schema.js"; import { readFile } from "@/core/utils/fs.js"; +// ─── LEGACY TAR.GZ UPLOAD ──────────────────────────────────── + /** * Uploads a tar.gz archive file to the Base44 hosting API. * @@ -40,3 +51,78 @@ export async function uploadSite(archivePath: string): Promise { return result.data; } + +// ─── DEPLOYMENTS API ───────────────────────────────────────── + +export async function createDeployment( + request: CreateDeploymentRequest, +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.post("deployments", { + json: request, + timeout: 120_000, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "creating deployment"); + } + + const result = CreateDeploymentResponseSchema.safeParse( + await response.json(), + ); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +/** + * Finalize a static-site (s3-target) deployment. The form carries exactly one + * file part — `index.html` — and nothing else (no `payload`, no modules): + * index.html is always excluded from the presigned uploads and travels + * through finalize as the sentinel that completes the deployment. + */ +export async function finalizeStaticDeployment( + deploymentId: string, + indexHtml: Uint8Array, +): Promise { + const formData = new FormData(); + formData.append( + "index.html", + new File([indexHtml], "index.html", { type: "text/html" }), + ); + return await postFinalize(deploymentId, formData); +} + +async function postFinalize( + deploymentId: string, + formData: FormData, +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.post( + `deployments/${encodeURIComponent(deploymentId)}/finalize`, + { body: formData, timeout: 180_000 }, + ); + } catch (error) { + throw await ApiError.fromHttpError(error, "finalizing deployment"); + } + + const result = FinalizeDeploymentResponseSchema.safeParse( + await response.json(), + ); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} diff --git a/packages/cli/src/core/site/deploy-app.ts b/packages/cli/src/core/site/deploy-app.ts index b9f5e1fbf..50dedc474 100644 --- a/packages/cli/src/core/site/deploy-app.ts +++ b/packages/cli/src/core/site/deploy-app.ts @@ -1,11 +1,7 @@ import { resolve } from "node:path"; -import type { DeploymentProgress } from "@/core/deployments/index.js"; -import { - deployStaticSite, - resolveGitHash, - staticDeploymentsEnabled, -} from "@/core/deployments/index.js"; import { deploySite } from "@/core/site/deploy.js"; +import type { DeploymentProgress } from "./schema.js"; +import { deployStaticSite } from "./static-site.js"; /** The project fields an app deploy reads. */ export interface AppSiteTarget { @@ -21,6 +17,15 @@ export type AppDeployResult = | { kind: "static"; appUrl: string } | { kind: "none" }; +/** + * Whether the deployments-API lane is on for this run. The gate is read once + * at the CLI edge (`CLIContext.staticDeployments`) and passed down — core is + * told which transport to use and never reads the environment itself. + */ +interface StaticDeploymentsOption { + staticDeployments?: boolean; +} + type AppDeployPlan = | { kind: "static-deployment"; outputDir: string } | { kind: "static"; outputDir: string } @@ -30,13 +35,16 @@ type AppDeployPlan = * A static output ships through the deployments API when the lane is * enabled, and as the legacy tar.gz upload otherwise. */ -async function planAppDeploy(target: AppSiteTarget): Promise { +function planAppDeploy( + target: AppSiteTarget, + staticDeployments: boolean, +): AppDeployPlan { const outputDirectory = target.site?.outputDirectory; if (!outputDirectory) { return { kind: "none" }; } const outputDir = resolve(target.root, outputDirectory); - return staticDeploymentsEnabled() + return staticDeployments ? { kind: "static-deployment", outputDir } : { kind: "static", outputDir }; } @@ -45,10 +53,11 @@ async function planAppDeploy(target: AppSiteTarget): Promise { * How the project's built output would ship right now. This only answers for * the current state of the tree — call it again after any build step. */ -export async function detectAppDeployKind( +export function detectAppDeployKind( target: AppSiteTarget, -): Promise { - return (await planAppDeploy(target)).kind; + options: StaticDeploymentsOption = {}, +): AppDeployKind { + return planAppDeploy(target, options.staticDeployments ?? false).kind; } /** @@ -59,16 +68,19 @@ export async function detectAppDeployKind( */ export async function deployAppSite( target: AppSiteTarget, - options: { gitHash?: string; progress?: DeploymentProgress } = {}, + options: StaticDeploymentsOption & { + gitHash?: string; + progress?: DeploymentProgress; + } = {}, ): Promise { - const plan = await planAppDeploy(target); + const plan = planAppDeploy(target, options.staticDeployments ?? false); switch (plan.kind) { case "static-deployment": { - const gitHash = await resolveGitHash(target.root, options.gitHash); - const { deploymentId } = await deployStaticSite({ + const { deploymentId, gitHash } = await deployStaticSite({ outputDir: plan.outputDir, - gitHash, + projectRoot: target.root, + gitHash: options.gitHash, progress: options.progress, }); return { kind: "static-deployment", deploymentId, gitHash }; diff --git a/packages/cli/src/core/site/gate.ts b/packages/cli/src/core/site/gate.ts new file mode 100644 index 000000000..7455f1797 --- /dev/null +++ b/packages/cli/src/core/site/gate.ts @@ -0,0 +1,18 @@ +/** + * Internal gate for the experimental static-site deployments-API lane. Read + * exactly once, where the CLI builds its context (`CLIContext.staticDeployments`), + * and passed down from there — no layer below the CLI edge consults the + * environment, so a run cannot disagree with itself about which lane it is on. + * + * Nothing about this lane is user-facing yet: with the gate off the site deploy + * keeps taking the legacy tar.gz path and `--git-hash` is not even registered, + * so `--help` looks exactly as it did before this lane existed. + */ +const STATIC_DEPLOYMENTS_ENV = "BASE44_STATIC_DEPLOYMENTS"; + +export function staticDeploymentsEnabled( + env: NodeJS.ProcessEnv = process.env, +): boolean { + const value = env[STATIC_DEPLOYMENTS_ENV]; + return value === "1" || value === "true"; +} diff --git a/packages/cli/src/core/site/index.ts b/packages/cli/src/core/site/index.ts index 9012035ad..f7c6d3822 100644 --- a/packages/cli/src/core/site/index.ts +++ b/packages/cli/src/core/site/index.ts @@ -2,4 +2,8 @@ export * from "./api.js"; export * from "./config.js"; export * from "./deploy.js"; export * from "./deploy-app.js"; +export * from "./gate.js"; +export * from "./manifest.js"; export * from "./schema.js"; +export * from "./static-site.js"; +export * from "./upload.js"; diff --git a/packages/cli/src/core/site/manifest.ts b/packages/cli/src/core/site/manifest.ts new file mode 100644 index 000000000..8df4715ed --- /dev/null +++ b/packages/cli/src/core/site/manifest.ts @@ -0,0 +1,97 @@ +import { createHash } from "node:crypto"; +import { readFile, stat } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { globby } from "globby"; +import { InvalidInputError } from "@/core/errors.js"; +import type { + AssetFile, + AssetManifestEntry, + AssetManifestResult, +} from "./schema.js"; + +const MAX_ASSET_SIZE_BYTES = 25 * 1024 * 1024; // 25 MiB +const MAX_ASSET_COUNT = 100_000; + +const ASSETS_IGNORE_FILE = ".assetsignore"; + +/** Files never uploaded as assets, regardless of .assetsignore. */ +const ALWAYS_IGNORED = new Set([ + ASSETS_IGNORE_FILE, + "wrangler.json", + ".dev.vars", +]); + +/** + * Content-addressed asset hash: first 32 hex chars of + * sha256(utf8(app_id) || raw file bytes). Salting with the app id means a + * tenant can only produce hash collisions with their own files, so a + * malicious upload cannot poison another app's asset cache. + */ +export function hashAsset(appId: string, content: Buffer): string { + return createHash("sha256") + .update(Buffer.from(appId, "utf8")) + .update(content) + .digest("hex") + .slice(0, 32); +} + +/** + * Walk the assets directory and build the deployment asset manifest. + * Honors `.assetsignore` at the assets root with full gitignore semantics + * (globby reads it through the `ignore` package, so negation works), always + * skips `.assetsignore`, `wrangler.json`, and `.dev.vars`, rejects files + * larger than 25 MiB, and caps the total file count at 100,000. + */ +export async function buildAssetManifest( + assetsDir: string, + appId: string, +): Promise { + const manifest: Record = {}; + const filesByHash = new Map(); + + // globby returns forward-slash paths on every platform, which is exactly + // how the manifest keys them. + const found = await globby("**/*", { + cwd: assetsDir, + dot: true, + onlyFiles: true, + followSymbolicLinks: false, + // `ignoreFiles` hands .assetsignore to the `ignore` package — real + // gitignore semantics, negation included. Do NOT also pass `ignore` to + // hide the file itself: globby globs for ignore files using that same + // option, so it would then find none and silently apply no patterns at + // all. Drop the always-ignored names from the results instead. + ignoreFiles: [ASSETS_IGNORE_FILE], + }); + const relativeFilePaths = found.filter( + (path) => !ALWAYS_IGNORED.has(basename(path)), + ); + + if (relativeFilePaths.length > MAX_ASSET_COUNT) { + throw new InvalidInputError( + `Too many static assets: found ${relativeFilePaths.length}, the limit is ${MAX_ASSET_COUNT} files.`, + ); + } + + for (const relativePath of relativeFilePaths.sort()) { + const absolutePath = join(assetsDir, ...relativePath.split("/")); + // Stat before read: the size limit exists so a huge file never has to be + // pulled into memory. + const { size } = await stat(absolutePath); + if (size > MAX_ASSET_SIZE_BYTES) { + throw new InvalidInputError( + `Static asset "${relativePath}" is ${size} bytes, which exceeds the 25 MiB per-file limit.`, + ); + } + + const content = await readFile(absolutePath); + const hash = hashAsset(appId, content); + + manifest[`/${relativePath}`] = { hash, size }; + if (!filesByHash.has(hash)) { + filesByHash.set(hash, { absolutePath, hash, size }); + } + } + + return { manifest, filesByHash }; +} diff --git a/packages/cli/src/core/site/schema.ts b/packages/cli/src/core/site/schema.ts index 52afcd312..fee429a3f 100644 --- a/packages/cli/src/core/site/schema.ts +++ b/packages/cli/src/core/site/schema.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +// ─── LEGACY TAR.GZ UPLOAD ──────────────────────────────────── + /** * Response from the deploy API endpoint. */ @@ -16,3 +18,140 @@ export type DeployResponse = z.infer; export const PublishedUrlResponseSchema = z.object({ url: z.string(), }); + +// ─── ASSETS (DEPLOYMENTS API) ──────────────────────────────── + +/** Manifest entry keyed by URL-ish path ("/index.html"). */ +export interface AssetManifestEntry { + hash: string; + size: number; +} + +/** A static asset discovered in the assets directory, keyed by hash. */ +export interface AssetFile { + /** Absolute path on disk. */ + absolutePath: string; + hash: string; + size: number; +} + +export interface AssetManifestResult { + /** URL path → { hash, size }, ready for the create-deployment payload. */ + manifest: Record; + /** Hash → file info, used to serve the requested uploads. */ + filesByHash: Map; +} + +/** Progress of an in-flight asset upload set. */ +export interface AssetUploadProgress { + uploadedFiles: number; + totalFiles: number; +} + +/** Progress callbacks a deploy fires as it moves through its stages. */ +export interface DeploymentProgress { + /** Fired after the deployment is created: total assets and how many need uploading. */ + onAssets?: (info: { totalAssets: number; newAssets: number }) => void; + /** Fired after each asset upload completes. */ + onAssetUpload?: (progress: AssetUploadProgress) => void; +} + +// ─── DEPLOYMENTS API REQUEST ───────────────────────────────── + +/** + * Request payload for POST deployments (sent as snake_case JSON). A request + * without a worker config is a static-site deployment — the server answers + * it with the `s3` arm of the create response. + * + * A deployment is addressed by the commit that produced it: the server derives + * the deployment id from `git_hash`, so one commit means one deployment and + * re-deploying a commit is idempotent. + */ +export interface CreateDeploymentRequest { + git_hash: string; + asset_manifest: Record; +} + +// ─── DEPLOYMENTS API RESPONSES ─────────────────────────────── + +/** A static asset the server wants uploaded, with its presigned S3 URL. */ +export interface PresignedAssetUpload { + /** Manifest path of the asset ("/assets/app.js"). */ + path: string; + /** Content-Type signed into the URL — the PUT must send it verbatim. */ + contentType: string; + /** Byte count signed into the URL — the PUT body must be exactly this long. */ + contentLength: number; + /** Presigned S3 URL — the URL itself is the credential. */ + url: string; +} + +interface S3AssetUploads { + type: "s3"; + uploads: PresignedAssetUpload[]; +} + +/** + * POST deployments answers `{deployment_id, asset_uploads}` where + * `asset_uploads` says where the assets still owed should go, discriminated + * on `type` — a config-less (static-site) request is always answered with + * the `s3` arm: direct presigned PUTs, always excluding `/index.html` + * (finalize carries it) — and is null when nothing is owed (no assets, or + * the build already exists). + */ +export const CreateDeploymentResponseSchema = z + .object({ + deployment_id: z.string(), + asset_uploads: z + .object({ + type: z.literal("s3"), + uploads: z.array( + z.object({ + path: z.string(), + content_type: z.string(), + content_length: z.number(), + url: z.string(), + }), + ), + }) + .nullable() + .optional(), + }) + .transform( + ( + data, + ): { + deploymentId: string; + assetUploads: S3AssetUploads | null; + } => ({ + deploymentId: data.deployment_id, + assetUploads: + data.asset_uploads == null + ? null + : { + type: "s3", + uploads: data.asset_uploads.uploads.map((upload) => ({ + path: upload.path, + contentType: upload.content_type, + contentLength: upload.content_length, + url: upload.url, + })), + }, + }), + ); + +export type CreateDeploymentResponse = z.infer< + typeof CreateDeploymentResponseSchema +>; + +export const FinalizeDeploymentResponseSchema = z + .object({ + deployment_id: z.string(), + }) + .transform((data) => ({ + deploymentId: data.deployment_id, + })); + +export type FinalizeDeploymentResponse = z.infer< + typeof FinalizeDeploymentResponseSchema +>; diff --git a/packages/cli/src/core/deployments/static-site.ts b/packages/cli/src/core/site/static-site.ts similarity index 69% rename from packages/cli/src/core/deployments/static-site.ts rename to packages/cli/src/core/site/static-site.ts index 4bbde5979..f4772ec53 100644 --- a/packages/cli/src/core/deployments/static-site.ts +++ b/packages/cli/src/core/site/static-site.ts @@ -2,26 +2,12 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { InvalidInputError } from "@/core/errors.js"; import { getAppContext } from "@/core/project/app-config.js"; +import { getGitHead, isGitCommitHash } from "@/core/utils/git.js"; import { createDeployment, finalizeStaticDeployment } from "./api.js"; import { buildAssetManifest } from "./manifest.js"; import type { DeploymentProgress } from "./schema.js"; import { uploadPresignedAssets } from "./upload.js"; -/** - * Internal gate for the experimental static-site deployments-API lane. Not - * user-facing yet: when set to "1" or "true", `base44 deploy` sends the - * configured site output through the deployments API instead of the legacy - * tar.gz site upload. - */ -const STATIC_DEPLOYMENTS_ENV = "BASE44_STATIC_DEPLOYMENTS"; - -export function staticDeploymentsEnabled( - env: NodeJS.ProcessEnv = process.env, -): boolean { - const value = env[STATIC_DEPLOYMENTS_ENV]; - return value === "1" || value === "true"; -} - /** * Deploy a static site build through the deployments API: hash the output * directory into an asset manifest and create the deployment at the commit's @@ -35,10 +21,12 @@ export function staticDeploymentsEnabled( */ export async function deployStaticSite(options: { outputDir: string; - gitHash: string; + projectRoot: string; + gitHash?: string; progress?: DeploymentProgress; }): Promise<{ deploymentId: string; gitHash: string }> { - const { outputDir, gitHash, progress } = options; + const { outputDir, projectRoot, progress } = options; + const gitHash = await resolveGitHash(projectRoot, options.gitHash); const assets = await buildAssetManifest(outputDir, getAppContext().id); // Finalize carries the index.html bytes by contract, so its absence is a @@ -77,3 +65,31 @@ export async function deployStaticSite(options: { return { deploymentId: finalized.deploymentId, gitHash }; } + +/** + * The commit this build came from — a deployment is addressed by it, so the + * hash is required. An explicit hash (flag/automation) wins; otherwise it + * comes from the git checkout at the project root. + */ +async function resolveGitHash( + projectRoot: string, + explicit?: string, +): Promise { + const hash = explicit ?? (await getGitHead(projectRoot)); + if (!hash || !isGitCommitHash(hash)) { + throw new InvalidInputError( + explicit + ? `'${explicit}' is not a git commit hash.` + : "Deployments are addressed by the commit that produced the build, and no git commit was found.", + { + hints: [ + { + message: + "Run the deploy from a git checkout, or pass the commit explicitly: base44 site deploy --git-hash .", + }, + ], + }, + ); + } + return hash; +} diff --git a/packages/cli/src/core/deployments/upload.ts b/packages/cli/src/core/site/upload.ts similarity index 100% rename from packages/cli/src/core/deployments/upload.ts rename to packages/cli/src/core/site/upload.ts diff --git a/packages/cli/src/core/utils/git.ts b/packages/cli/src/core/utils/git.ts new file mode 100644 index 000000000..f10c67e55 --- /dev/null +++ b/packages/cli/src/core/utils/git.ts @@ -0,0 +1,21 @@ +import { execa } from "execa"; + +/** + * A git commit hash: 7–64 hex chars, abbreviated or full. The deployments API + * validates commit addresses with the same pattern. + */ +const GIT_HASH_PATTERN = /^[a-fA-F0-9]{7,64}$/; + +export function isGitCommitHash(value: string): boolean { + return GIT_HASH_PATTERN.test(value); +} + +/** The commit checked out at `cwd`, or null when it is not a git checkout. */ +export async function getGitHead(cwd: string): Promise { + try { + const { stdout } = await execa("git", ["rev-parse", "HEAD"], { cwd }); + return stdout.trim(); + } catch { + return null; + } +} diff --git a/packages/cli/src/core/utils/index.ts b/packages/cli/src/core/utils/index.ts index 43158af3c..f7a8c1419 100644 --- a/packages/cli/src/core/utils/index.ts +++ b/packages/cli/src/core/utils/index.ts @@ -1,3 +1,4 @@ export * from "./dependencies.js"; export * from "./env.js"; export * from "./fs.js"; +export * from "./git.js"; diff --git a/packages/cli/tests/cli/static_site_deployments.spec.ts b/packages/cli/tests/cli/static_site_deployments.spec.ts index 3134bd3ab..7c8f642e5 100644 --- a/packages/cli/tests/cli/static_site_deployments.spec.ts +++ b/packages/cli/tests/cli/static_site_deployments.spec.ts @@ -29,7 +29,7 @@ interface CreateBody { asset_manifest: Record; } -describe("deploy command (static site through the deployments API, env-gated)", () => { +describe("site deploy command (static site through the deployments API, env-gated)", () => { const t = setupCLITests(); /** Mocks hit by the unified deploy's resource-push phase (no resources). */ @@ -67,13 +67,73 @@ describe("deploy command (static site through the deployments API, env-gated)", return await readFile(join(fixture("with-site"), "site-output", name)); } - it("keeps the legacy tar.gz site upload when the gate is off", async () => { + // The whole lane is internal until the server side ships: with the gate off + // nothing about it may reach a user, not even a flag in --help. And even with + // the gate on the override lives only on `site deploy` — the command the + // build sandbox drives. + it("keeps --git-hash out of the help while the gate is off", async () => { + const siteDeployHelp = await t.run("site", "deploy", "--help"); + + t.expectResult(siteDeployHelp).toSucceed(); + t.expectResult(siteDeployHelp).toContain("--build"); + t.expectResult(siteDeployHelp).toNotContain("--git-hash"); + }); + + it("rejects --git-hash outright while the gate is off", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("unknown option"); + expect(t.api.deploymentCreateRequests).toHaveLength(0); + }); + + it("shows --git-hash on site deploy once the gate is on", async () => { + t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); + + const result = await t.run("site", "deploy", "--help"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("--git-hash"); + }); + + it("never exposes --git-hash on the unified deploy, gate on or off", async () => { + t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); await t.givenLoggedInWithProject(fixture("with-site")); + + const help = await t.run("deploy", "--help"); + const passed = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(help).toSucceed(); + t.expectResult(help).toNotContain("--git-hash"); + t.expectResult(passed).toFail(); + t.expectResult(passed).toContain("unknown option"); + }); + + it("still routes the unified deploy's site through the lane when gated on", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); mockResourcePushes(); + // Available, and must go untouched: taking the legacy path would succeed. t.api.mockSiteDeploy({ app_url: "https://legacy.example.com" }); + // The fixture is not a git checkout and `base44 deploy` has no override, so + // the lane fails on the missing commit address — which is itself the proof + // that it took the lane instead of the legacy tar.gz upload. const result = await t.run("deploy", "-y"); + t.expectResult(result).toFail(); + t.expectResult(result).toContain("base44 site deploy --git-hash"); + t.expectResult(result).toNotContain("legacy.example.com"); + }); + + it("keeps the legacy tar.gz site upload when the gate is off", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + t.api.mockSiteDeploy({ app_url: "https://legacy.example.com" }); + + const result = await t.run("site", "deploy", "-y"); + t.expectResult(result).toSucceed(); t.expectResult(result).toContain("https://legacy.example.com"); expect(t.api.deploymentCreateRequests).toHaveLength(0); @@ -82,16 +142,15 @@ describe("deploy command (static site through the deployments API, env-gated)", it("deploys the site output through the deployments API when gated on", async () => { await t.givenLoggedInWithProject(fixture("with-site")); t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); - mockResourcePushes(); mockStaticCreate(["/main.js", "/styles.css"]); t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); - const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Found 3 static assets (2 new)"); t.expectResult(result).toContain("Site deployed"); - t.expectResult(result).toContain(`Deployment: ${DEPLOYMENT_ID}`); + t.expectResult(result).toContain(DEPLOYMENT_ID); // Create request: the commit address, NO config field at all (that is // what selects the static arm), and index.html IS in the manifest — it @@ -134,11 +193,10 @@ describe("deploy command (static site through the deployments API, env-gated)", it("sends no PUTs and still finalizes when every asset is already stored", async () => { await t.givenLoggedInWithProject(fixture("with-site")); t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "true" }); - mockResourcePushes(); mockStaticCreate([]); t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); - const result = await t.run("deploy", "-y", "--git-hash", GIT_HASH); + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Found 3 static assets (0 new)"); @@ -152,11 +210,11 @@ describe("deploy command (static site through the deployments API, env-gated)", it("emits a single JSON document with --json", async () => { await t.givenLoggedInWithProject(fixture("with-site")); t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); - mockResourcePushes(); mockStaticCreate(["/main.js", "/styles.css"]); t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); const result = await t.run( + "site", "deploy", "-y", "--git-hash", @@ -174,9 +232,8 @@ describe("deploy command (static site through the deployments API, env-gated)", it("requires a commit hash outside a git checkout", async () => { await t.givenLoggedInWithProject(fixture("with-site")); t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); - mockResourcePushes(); - const result = await t.run("deploy", "-y"); + const result = await t.run("site", "deploy", "-y"); t.expectResult(result).toFail(); t.expectResult(result).toContain("--git-hash"); diff --git a/packages/cli/tests/core/deployments-manifest.spec.ts b/packages/cli/tests/core/site-manifest.spec.ts similarity index 56% rename from packages/cli/tests/core/deployments-manifest.spec.ts rename to packages/cli/tests/core/site-manifest.spec.ts index 09d92ff35..c0e977192 100644 --- a/packages/cli/tests/core/deployments-manifest.spec.ts +++ b/packages/cli/tests/core/site-manifest.spec.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, truncate, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { buildAssetManifest, hashAsset } from "@/core/deployments/manifest.js"; +import { buildAssetManifest, hashAsset } from "@/core/site/manifest.js"; describe("hashAsset", () => { it("computes the first 32 hex chars of sha256(utf8(app_id) || bytes)", () => { @@ -85,6 +85,85 @@ describe("buildAssetManifest", () => { ]); }); + it("honors .assetsignore negation", async () => { + // The shape wrangler's own generated .assetsignore uses — re-including a + // committed example file next to the secrets it excludes. + await writeFile( + join(assetsDir, ".assetsignore"), + [ + ".dev.vars*", + "!.dev.vars.example", + "secrets/", + "!secrets/public.txt", + ].join("\n"), + ); + await writeFile(join(assetsDir, "index.html"), "hi"); + await writeFile(join(assetsDir, ".dev.vars.local"), "drop"); + await writeFile(join(assetsDir, ".dev.vars.example"), "keep"); + await mkdir(join(assetsDir, "secrets")); + await writeFile(join(assetsDir, "secrets", "key.pem"), "drop"); + await writeFile(join(assetsDir, "secrets", "public.txt"), "drop"); + + const { manifest } = await buildAssetManifest(assetsDir, "test-app-id"); + + // `!.dev.vars.example` rescues the file; `!secrets/public.txt` does not, + // because git cannot re-include a file under an excluded directory. + expect(Object.keys(manifest).sort()).toEqual([ + "/.dev.vars.example", + "/index.html", + ]); + }); + + it("treats braces and extglobs as literal names, like gitignore", async () => { + await writeFile( + join(assetsDir, ".assetsignore"), + ["{foo,bar}.js", "+(baz|qux).js"].join("\n"), + ); + await writeFile(join(assetsDir, "foo.js"), "keep"); + await writeFile(join(assetsDir, "baz.js"), "keep"); + await writeFile(join(assetsDir, "{foo,bar}.js"), "drop"); + + const { manifest } = await buildAssetManifest(assetsDir, "test-app-id"); + + // A glob library would expand these and drop foo.js / baz.js; gitignore + // semantics match the literal filename instead. + expect(Object.keys(manifest).sort()).toEqual(["/baz.js", "/foo.js"]); + }); + + it("anchors .assetsignore patterns that contain a slash", async () => { + await writeFile( + join(assetsDir, ".assetsignore"), + ["/root-only.txt", "nested/drop.txt", "**/*.map"].join("\n"), + ); + await writeFile(join(assetsDir, "root-only.txt"), "drop"); + await mkdir(join(assetsDir, "nested", "root-only.txt"), { + recursive: true, + }); + await writeFile(join(assetsDir, "nested", "root-only.txt", "keep"), "keep"); + await writeFile(join(assetsDir, "nested", "drop.txt"), "drop"); + await writeFile(join(assetsDir, "nested", "keep.txt"), "keep"); + await writeFile(join(assetsDir, "app.js.map"), "drop"); + await writeFile(join(assetsDir, "nested", "app.js.map"), "drop"); + + const { manifest } = await buildAssetManifest(assetsDir, "test-app-id"); + + // An anchored pattern only matches at the assets root — the same name + // deeper in the tree survives; `**` still crosses directories. + expect(Object.keys(manifest).sort()).toEqual([ + "/nested/keep.txt", + "/nested/root-only.txt/keep", + ]); + }); + + it("includes dotfiles that are not ignored", async () => { + await mkdir(join(assetsDir, ".well-known")); + await writeFile(join(assetsDir, ".well-known", "security.txt"), "contact"); + + const { manifest } = await buildAssetManifest(assetsDir, "test-app-id"); + + expect(Object.keys(manifest)).toEqual(["/.well-known/security.txt"]); + }); + it("always skips .assetsignore, wrangler.json, and .dev.vars", async () => { await writeFile(join(assetsDir, "index.html"), "hi"); await writeFile(join(assetsDir, "wrangler.json"), "{}"); From 12704f3ccabf97450798ea7393144fd9df95d49a Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 4 Aug 2026 07:31:52 +0300 Subject: [PATCH 03/12] less comments --- .../cli/src/cli/commands/project/deploy.ts | 6 +-- packages/cli/src/cli/commands/site/deploy.ts | 8 +--- .../src/cli/commands/site/run-app-deploy.ts | 3 +- packages/cli/src/cli/index.ts | 1 - packages/cli/src/cli/types.ts | 5 +-- packages/cli/src/core/project/deploy.ts | 5 +-- packages/cli/src/core/site/api.ts | 10 +---- packages/cli/src/core/site/deploy-app.ts | 19 ++-------- packages/cli/src/core/site/gate.ts | 11 ++---- packages/cli/src/core/site/manifest.ts | 29 ++++++-------- packages/cli/src/core/site/schema.ts | 38 +++---------------- packages/cli/src/core/site/static-site.ts | 22 +++-------- packages/cli/src/core/site/upload.ts | 8 ++-- packages/cli/src/core/utils/git.ts | 5 +-- .../tests/cli/static_site_deployments.spec.ts | 19 +--------- packages/cli/tests/core/site-manifest.spec.ts | 12 +----- 16 files changed, 47 insertions(+), 154 deletions(-) diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index 676a046b9..5fb9d0cfa 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -102,8 +102,8 @@ export async function deployAction( await maybeBuildBeforeDeploy(ctx, project, options.build); - // Deploy resources with per-function progress. The site ships below, - // from whatever the build produced. + // Deploy resources with per-function progress; the site ships below, from + // whatever the build produced. let functionCompleted = 0; const functionTotal = functions.length; @@ -126,8 +126,6 @@ export async function deployAction( }, }); - // No `--git-hash` here: the commit override is only exposed on - // `base44 site deploy`, so a unified deploy always addresses HEAD. const siteResult = await runAppSiteDeploy(ctx, project); // Handle connector-specific post-deploy flows diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index a88c499c2..0e37cfcb8 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -83,12 +83,8 @@ export function getSiteDeployCommand(staticDeployments = false): Command { .option("--build", "Build the site before deploying (skips the prompt)") .option("--no-build", "Deploy without building (skips the prompt)"); - // `--git-hash` addresses a deployments-API deploy by the commit that produced - // the build, so it only means anything on that lane — and that lane is - // internal until the server side ships. Registering it only when the lane is - // on keeps it out of `--help` and makes it an unknown option otherwise, as if - // it did not exist. This is the command the build sandbox drives, so it is - // the only one that exposes the override. + // Only registered on the enabled lane, so with the gate off the flag is + // absent from --help and rejected as an unknown option. if (staticDeployments) { command.addOption( new Option( diff --git a/packages/cli/src/cli/commands/site/run-app-deploy.ts b/packages/cli/src/cli/commands/site/run-app-deploy.ts index 066429653..a4b81702c 100644 --- a/packages/cli/src/cli/commands/site/run-app-deploy.ts +++ b/packages/cli/src/cli/commands/site/run-app-deploy.ts @@ -18,8 +18,7 @@ const TASK_LABELS = { /** * Run the project's site deploy behind a spinner, adapting the labels and the - * progress stream to whichever transport applies. The kind is detected here - * only to pick the messages; `deployAppSite` decides for itself what to ship. + * progress stream to whichever transport applies. */ export async function runAppSiteDeploy( { runTask, log, staticDeployments }: CLIContext, diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index e7a6352f0..f48466ebe 100644 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -45,7 +45,6 @@ async function runCLI(options?: RunCLIOptions): Promise { errorReporter, isNonInteractive, jsonMode, - // Read the lane gate once, after bootstrap-env has loaded .env files. staticDeployments: staticDeploymentsEnabled(), distribution: options?.distribution ?? "npm", log, diff --git a/packages/cli/src/cli/types.ts b/packages/cli/src/cli/types.ts index a82a14eac..8a21d2b5f 100644 --- a/packages/cli/src/cli/types.ts +++ b/packages/cli/src/cli/types.ts @@ -16,9 +16,8 @@ export interface CLIContext { jsonMode: boolean; /** * The experimental static-site deployments lane is on for this process - * (`BASE44_STATIC_DEPLOYMENTS`). This is the only place the gate is read — - * commands pass it down, so no layer below re-reads the environment and the - * whole run agrees on one answer. + * (`BASE44_STATIC_DEPLOYMENTS`). The only place that gate is read; commands + * pass it down rather than re-reading the environment. */ staticDeployments: boolean; distribution: Distribution; diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 58a55000a..aa8fd3576 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -76,9 +76,8 @@ interface DeployAllOptions { onFunctionStart?: (names: string[]) => void; onFunctionResult?: (result: SingleFunctionDeployResult) => void; /** - * Deploy the legacy static site (tar.gz upload) when configured. - * The unified deploy command passes false and handles the site itself, - * so full-stack (Workers) projects can take the deployments path instead. + * Deploy the legacy static site (tar.gz upload) when configured. The unified + * deploy command passes false and handles the site itself. * @default true */ site?: boolean; diff --git a/packages/cli/src/core/site/api.ts b/packages/cli/src/core/site/api.ts index a6144c789..bdffd2291 100644 --- a/packages/cli/src/core/site/api.ts +++ b/packages/cli/src/core/site/api.ts @@ -14,8 +14,6 @@ import { } from "@/core/site/schema.js"; import { readFile } from "@/core/utils/fs.js"; -// ─── LEGACY TAR.GZ UPLOAD ──────────────────────────────────── - /** * Uploads a tar.gz archive file to the Base44 hosting API. * @@ -52,8 +50,6 @@ export async function uploadSite(archivePath: string): Promise { return result.data; } -// ─── DEPLOYMENTS API ───────────────────────────────────────── - export async function createDeployment( request: CreateDeploymentRequest, ): Promise { @@ -82,10 +78,8 @@ export async function createDeployment( } /** - * Finalize a static-site (s3-target) deployment. The form carries exactly one - * file part — `index.html` — and nothing else (no `payload`, no modules): - * index.html is always excluded from the presigned uploads and travels - * through finalize as the sentinel that completes the deployment. + * The form carries exactly one file part — `index.html`, the sentinel that + * completes the deployment — and nothing else. */ export async function finalizeStaticDeployment( deploymentId: string, diff --git a/packages/cli/src/core/site/deploy-app.ts b/packages/cli/src/core/site/deploy-app.ts index 50dedc474..779f4e65d 100644 --- a/packages/cli/src/core/site/deploy-app.ts +++ b/packages/cli/src/core/site/deploy-app.ts @@ -9,7 +9,6 @@ export interface AppSiteTarget { site?: { outputDirectory?: string }; } -/** Which transport ships this project's built output. */ type AppDeployKind = "static-deployment" | "static" | "none"; export type AppDeployResult = @@ -17,12 +16,8 @@ export type AppDeployResult = | { kind: "static"; appUrl: string } | { kind: "none" }; -/** - * Whether the deployments-API lane is on for this run. The gate is read once - * at the CLI edge (`CLIContext.staticDeployments`) and passed down — core is - * told which transport to use and never reads the environment itself. - */ interface StaticDeploymentsOption { + /** The gate is read at the CLI edge and passed down; core never reads env. */ staticDeployments?: boolean; } @@ -31,10 +26,6 @@ type AppDeployPlan = | { kind: "static"; outputDir: string } | { kind: "none" }; -/** - * A static output ships through the deployments API when the lane is - * enabled, and as the legacy tar.gz upload otherwise. - */ function planAppDeploy( target: AppSiteTarget, staticDeployments: boolean, @@ -49,10 +40,7 @@ function planAppDeploy( : { kind: "static", outputDir }; } -/** - * How the project's built output would ship right now. This only answers for - * the current state of the tree — call it again after any build step. - */ +/** How the built output would ship right now — a build step invalidates it. */ export function detectAppDeployKind( target: AppSiteTarget, options: StaticDeploymentsOption = {}, @@ -63,8 +51,7 @@ export function detectAppDeployKind( /** * Deploy the project's built output over whichever transport applies — * a deployments-API static deployment when the lane is enabled, the legacy - * tar.gz upload otherwise. Returns `{ kind: "none" }` when the project has - * nothing to ship. + * tar.gz upload otherwise. */ export async function deployAppSite( target: AppSiteTarget, diff --git a/packages/cli/src/core/site/gate.ts b/packages/cli/src/core/site/gate.ts index 7455f1797..bdde48a06 100644 --- a/packages/cli/src/core/site/gate.ts +++ b/packages/cli/src/core/site/gate.ts @@ -1,12 +1,7 @@ /** - * Internal gate for the experimental static-site deployments-API lane. Read - * exactly once, where the CLI builds its context (`CLIContext.staticDeployments`), - * and passed down from there — no layer below the CLI edge consults the - * environment, so a run cannot disagree with itself about which lane it is on. - * - * Nothing about this lane is user-facing yet: with the gate off the site deploy - * keeps taking the legacy tar.gz path and `--git-hash` is not even registered, - * so `--help` looks exactly as it did before this lane existed. + * Internal gate for the experimental static-site deployments-API lane, not + * user-facing yet. Read once into `CLIContext.staticDeployments` and passed + * down from there — no layer below the CLI edge consults the environment. */ const STATIC_DEPLOYMENTS_ENV = "BASE44_STATIC_DEPLOYMENTS"; diff --git a/packages/cli/src/core/site/manifest.ts b/packages/cli/src/core/site/manifest.ts index 8df4715ed..4df018841 100644 --- a/packages/cli/src/core/site/manifest.ts +++ b/packages/cli/src/core/site/manifest.ts @@ -22,10 +22,9 @@ const ALWAYS_IGNORED = new Set([ ]); /** - * Content-addressed asset hash: first 32 hex chars of - * sha256(utf8(app_id) || raw file bytes). Salting with the app id means a - * tenant can only produce hash collisions with their own files, so a - * malicious upload cannot poison another app's asset cache. + * First 32 hex chars of sha256(utf8(app_id) || raw file bytes). The app-id salt + * means a tenant can only collide with their own files, so a malicious upload + * cannot poison another app's asset cache. */ export function hashAsset(appId: string, content: Buffer): string { return createHash("sha256") @@ -36,11 +35,9 @@ export function hashAsset(appId: string, content: Buffer): string { } /** - * Walk the assets directory and build the deployment asset manifest. - * Honors `.assetsignore` at the assets root with full gitignore semantics - * (globby reads it through the `ignore` package, so negation works), always - * skips `.assetsignore`, `wrangler.json`, and `.dev.vars`, rejects files - * larger than 25 MiB, and caps the total file count at 100,000. + * Walk the assets directory and build the deployment asset manifest. Honors + * `.assetsignore` at the assets root with full gitignore semantics, negation + * included. */ export async function buildAssetManifest( assetsDir: string, @@ -49,18 +46,15 @@ export async function buildAssetManifest( const manifest: Record = {}; const filesByHash = new Map(); - // globby returns forward-slash paths on every platform, which is exactly - // how the manifest keys them. + // globby returns forward-slash paths on every platform, which is how the + // manifest keys them. Never pass `ignore` alongside `ignoreFiles`: globby + // globs for ignore files using that option, so it would find none and + // silently apply no patterns — hence the filter below. const found = await globby("**/*", { cwd: assetsDir, dot: true, onlyFiles: true, followSymbolicLinks: false, - // `ignoreFiles` hands .assetsignore to the `ignore` package — real - // gitignore semantics, negation included. Do NOT also pass `ignore` to - // hide the file itself: globby globs for ignore files using that same - // option, so it would then find none and silently apply no patterns at - // all. Drop the always-ignored names from the results instead. ignoreFiles: [ASSETS_IGNORE_FILE], }); const relativeFilePaths = found.filter( @@ -75,8 +69,7 @@ export async function buildAssetManifest( for (const relativePath of relativeFilePaths.sort()) { const absolutePath = join(assetsDir, ...relativePath.split("/")); - // Stat before read: the size limit exists so a huge file never has to be - // pulled into memory. + // Stat before read so an oversized file is never pulled into memory. const { size } = await stat(absolutePath); if (size > MAX_ASSET_SIZE_BYTES) { throw new InvalidInputError( diff --git a/packages/cli/src/core/site/schema.ts b/packages/cli/src/core/site/schema.ts index fee429a3f..9897fa2e9 100644 --- a/packages/cli/src/core/site/schema.ts +++ b/packages/cli/src/core/site/schema.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -// ─── LEGACY TAR.GZ UPLOAD ──────────────────────────────────── - /** * Response from the deploy API endpoint. */ @@ -19,68 +17,47 @@ export const PublishedUrlResponseSchema = z.object({ url: z.string(), }); -// ─── ASSETS (DEPLOYMENTS API) ──────────────────────────────── - /** Manifest entry keyed by URL-ish path ("/index.html"). */ export interface AssetManifestEntry { hash: string; size: number; } -/** A static asset discovered in the assets directory, keyed by hash. */ export interface AssetFile { - /** Absolute path on disk. */ absolutePath: string; hash: string; size: number; } export interface AssetManifestResult { - /** URL path → { hash, size }, ready for the create-deployment payload. */ manifest: Record; - /** Hash → file info, used to serve the requested uploads. */ filesByHash: Map; } -/** Progress of an in-flight asset upload set. */ export interface AssetUploadProgress { uploadedFiles: number; totalFiles: number; } -/** Progress callbacks a deploy fires as it moves through its stages. */ export interface DeploymentProgress { - /** Fired after the deployment is created: total assets and how many need uploading. */ onAssets?: (info: { totalAssets: number; newAssets: number }) => void; - /** Fired after each asset upload completes. */ onAssetUpload?: (progress: AssetUploadProgress) => void; } -// ─── DEPLOYMENTS API REQUEST ───────────────────────────────── - /** - * Request payload for POST deployments (sent as snake_case JSON). A request - * without a worker config is a static-site deployment — the server answers - * it with the `s3` arm of the create response. - * - * A deployment is addressed by the commit that produced it: the server derives - * the deployment id from `git_hash`, so one commit means one deployment and - * re-deploying a commit is idempotent. + * A request without a worker config is a static-site deployment, which the + * server answers with the `s3` arm of the create response. The deployment id + * is derived from `git_hash`, so re-deploying a commit is idempotent. */ export interface CreateDeploymentRequest { git_hash: string; asset_manifest: Record; } -// ─── DEPLOYMENTS API RESPONSES ─────────────────────────────── - -/** A static asset the server wants uploaded, with its presigned S3 URL. */ export interface PresignedAssetUpload { - /** Manifest path of the asset ("/assets/app.js"). */ path: string; /** Content-Type signed into the URL — the PUT must send it verbatim. */ contentType: string; - /** Byte count signed into the URL — the PUT body must be exactly this long. */ contentLength: number; /** Presigned S3 URL — the URL itself is the credential. */ url: string; @@ -92,12 +69,9 @@ interface S3AssetUploads { } /** - * POST deployments answers `{deployment_id, asset_uploads}` where - * `asset_uploads` says where the assets still owed should go, discriminated - * on `type` — a config-less (static-site) request is always answered with - * the `s3` arm: direct presigned PUTs, always excluding `/index.html` - * (finalize carries it) — and is null when nothing is owed (no assets, or - * the build already exists). + * `asset_uploads` says where the assets still owed should go, discriminated on + * `type`. The `s3` arm always excludes `/index.html` (finalize carries it), and + * the whole field is null when nothing is owed. */ export const CreateDeploymentResponseSchema = z .object({ diff --git a/packages/cli/src/core/site/static-site.ts b/packages/cli/src/core/site/static-site.ts index f4772ec53..bc12bb3d0 100644 --- a/packages/cli/src/core/site/static-site.ts +++ b/packages/cli/src/core/site/static-site.ts @@ -10,14 +10,9 @@ import { uploadPresignedAssets } from "./upload.js"; /** * Deploy a static site build through the deployments API: hash the output - * directory into an asset manifest and create the deployment at the commit's - * address with no worker config — which the server answers with the `s3` arm - * of the discriminated create response — then PUT the requested files - * directly to their presigned URLs and finalize with the index.html bytes. - * - * This is the progressive-upgrade path: when the app later adopts a server - * framework, the create request carries its worker config and the server - * answers with the `cf` arm instead — same CLI protocol, zero CLI change. + * directory into an asset manifest, create the deployment at the commit's + * address with no worker config, PUT the requested files to their presigned + * URLs, and finalize with the index.html bytes. */ export async function deployStaticSite(options: { outputDir: string; @@ -41,11 +36,8 @@ export async function deployStaticSite(options: { git_hash: gitHash, asset_manifest: assets.manifest, }); - // The uploads always exclude index.html; null means every asset is already - // stored (re-deploying a commit is idempotent). - const totalAssets = Object.keys(assets.manifest).length; progress?.onAssets?.({ - totalAssets, + totalAssets: Object.keys(assets.manifest).length, newAssets: created.assetUploads?.uploads.length ?? 0, }); @@ -66,11 +58,7 @@ export async function deployStaticSite(options: { return { deploymentId: finalized.deploymentId, gitHash }; } -/** - * The commit this build came from — a deployment is addressed by it, so the - * hash is required. An explicit hash (flag/automation) wins; otherwise it - * comes from the git checkout at the project root. - */ +/** An explicit hash (flag/automation) wins over the checkout's HEAD. */ async function resolveGitHash( projectRoot: string, explicit?: string, diff --git a/packages/cli/src/core/site/upload.ts b/packages/cli/src/core/site/upload.ts index 4f0e4e673..0a8710b35 100644 --- a/packages/cli/src/core/site/upload.ts +++ b/packages/cli/src/core/site/upload.ts @@ -12,11 +12,9 @@ const MAX_ATTEMPTS_PER_UPLOAD = 3; const RETRY_BASE_DELAY_MS = 500; /** - * PUT static assets directly to their presigned S3 URLs (the `s3` create - * arm). A presigned URL carries its own authorization in the query string, so - * each request is a plain fetch — never the app client, never an - * Authorization header. Uploads run with concurrency 3; - * each file gets 3 attempts with exponential backoff. + * PUT static assets directly to their presigned S3 URLs. A presigned URL + * carries its own authorization in the query string, so each request is a plain + * fetch — never the app client, never an Authorization header. */ export async function uploadPresignedAssets( uploads: PresignedAssetUpload[], diff --git a/packages/cli/src/core/utils/git.ts b/packages/cli/src/core/utils/git.ts index f10c67e55..8e02cfdfc 100644 --- a/packages/cli/src/core/utils/git.ts +++ b/packages/cli/src/core/utils/git.ts @@ -1,9 +1,6 @@ import { execa } from "execa"; -/** - * A git commit hash: 7–64 hex chars, abbreviated or full. The deployments API - * validates commit addresses with the same pattern. - */ +/** Abbreviated or full commit hash — the same pattern the server validates. */ const GIT_HASH_PATTERN = /^[a-fA-F0-9]{7,64}$/; export function isGitCommitHash(value: string): boolean { diff --git a/packages/cli/tests/cli/static_site_deployments.spec.ts b/packages/cli/tests/cli/static_site_deployments.spec.ts index 7c8f642e5..6307280fb 100644 --- a/packages/cli/tests/cli/static_site_deployments.spec.ts +++ b/packages/cli/tests/cli/static_site_deployments.spec.ts @@ -50,8 +50,7 @@ describe("site deploy command (static site through the deployments API, env-gate type: "s3" as const, uploads: uploadPaths.map((path) => ({ path, - // The server derives this server-side and signs it into the URL; the - // CLI must echo it verbatim rather than derive its own. + // Deliberately not what the CLI would derive: the signed value wins. content_type: `${SIGNED_CONTENT_TYPES[path]}; charset=utf-8`, content_length: FIXTURE_SIZES[path], url: `${t.api.baseUrl}/presigned${path}`, @@ -67,10 +66,6 @@ describe("site deploy command (static site through the deployments API, env-gate return await readFile(join(fixture("with-site"), "site-output", name)); } - // The whole lane is internal until the server side ships: with the gate off - // nothing about it may reach a user, not even a flag in --help. And even with - // the gate on the override lives only on `site deploy` — the command the - // build sandbox drives. it("keeps --git-hash out of the help while the gate is off", async () => { const siteDeployHelp = await t.run("site", "deploy", "--help"); @@ -115,12 +110,9 @@ describe("site deploy command (static site through the deployments API, env-gate await t.givenLoggedInWithProject(fixture("with-site")); t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); mockResourcePushes(); - // Available, and must go untouched: taking the legacy path would succeed. + // Must go untouched: taking the legacy path would have succeeded. t.api.mockSiteDeploy({ app_url: "https://legacy.example.com" }); - // The fixture is not a git checkout and `base44 deploy` has no override, so - // the lane fails on the missing commit address — which is itself the proof - // that it took the lane instead of the legacy tar.gz upload. const result = await t.run("deploy", "-y"); t.expectResult(result).toFail(); @@ -152,9 +144,6 @@ describe("site deploy command (static site through the deployments API, env-gate t.expectResult(result).toContain("Site deployed"); t.expectResult(result).toContain(DEPLOYMENT_ID); - // Create request: the commit address, NO config field at all (that is - // what selects the static arm), and index.html IS in the manifest — it - // is only ever excluded from the uploads. expect(t.api.deploymentCreateRequests).toHaveLength(1); const body = t.api.deploymentCreateRequests[0] as CreateBody; expect(body.git_hash).toBe(GIT_HASH); @@ -165,8 +154,6 @@ describe("site deploy command (static site through the deployments API, env-gate "/styles.css", ]); - // Raw bytes PUT directly to the presigned URLs: the computed content - // type, no auth header (the URL itself is the credential). expect(t.api.presignedUploadRequests).toHaveLength(2); const byPath = new Map( t.api.presignedUploadRequests.map((r) => [r.path, r]), @@ -180,8 +167,6 @@ describe("site deploy command (static site through the deployments API, env-gate expect(styles?.contentType).toBe("text/css; charset=utf-8"); expect(styles?.authorization).toBeUndefined(); - // Finalize: exactly one file part — the index.html bytes. No payload, - // no modules. expect(t.api.finalizeRequests).toHaveLength(1); const fields = t.api.finalizeRequests[0]; expect(fields.map((f) => f.name)).toEqual(["index.html"]); diff --git a/packages/cli/tests/core/site-manifest.spec.ts b/packages/cli/tests/core/site-manifest.spec.ts index c0e977192..5974ed2b5 100644 --- a/packages/cli/tests/core/site-manifest.spec.ts +++ b/packages/cli/tests/core/site-manifest.spec.ts @@ -7,8 +7,6 @@ import { buildAssetManifest, hashAsset } from "@/core/site/manifest.js"; describe("hashAsset", () => { it("computes the first 32 hex chars of sha256(utf8(app_id) || bytes)", () => { - // Known vector: sha256("test-app-id" + "hello world") = - // b24ad526981fbac802de45c88c134ba4... (first 32 hex chars) expect(hashAsset("test-app-id", Buffer.from("hello world"))).toBe( "b24ad526981fbac802de45c88c134ba4", ); @@ -86,8 +84,6 @@ describe("buildAssetManifest", () => { }); it("honors .assetsignore negation", async () => { - // The shape wrangler's own generated .assetsignore uses — re-including a - // committed example file next to the secrets it excludes. await writeFile( join(assetsDir, ".assetsignore"), [ @@ -106,8 +102,8 @@ describe("buildAssetManifest", () => { const { manifest } = await buildAssetManifest(assetsDir, "test-app-id"); - // `!.dev.vars.example` rescues the file; `!secrets/public.txt` does not, - // because git cannot re-include a file under an excluded directory. + // `!secrets/public.txt` does not rescue: git cannot re-include a file + // under an excluded directory. expect(Object.keys(manifest).sort()).toEqual([ "/.dev.vars.example", "/index.html", @@ -125,8 +121,6 @@ describe("buildAssetManifest", () => { const { manifest } = await buildAssetManifest(assetsDir, "test-app-id"); - // A glob library would expand these and drop foo.js / baz.js; gitignore - // semantics match the literal filename instead. expect(Object.keys(manifest).sort()).toEqual(["/baz.js", "/foo.js"]); }); @@ -147,8 +141,6 @@ describe("buildAssetManifest", () => { const { manifest } = await buildAssetManifest(assetsDir, "test-app-id"); - // An anchored pattern only matches at the assets root — the same name - // deeper in the tree survives; `**` still crosses directories. expect(Object.keys(manifest).sort()).toEqual([ "/nested/keep.txt", "/nested/root-only.txt/keep", From fa32d5e861a319c5f51dfbc501c53076a3a17e82 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 4 Aug 2026 07:43:21 +0300 Subject: [PATCH 04/12] cleaner --- docs/deployments.md | 4 +- docs/resources.md | 6 +-- .../cli/src/cli/commands/project/deploy.ts | 47 +++---------------- packages/cli/src/core/project/deploy.ts | 14 +----- .../tests/cli/static_site_deployments.spec.ts | 34 -------------- 5 files changed, 14 insertions(+), 91 deletions(-) diff --git a/docs/deployments.md b/docs/deployments.md index d022d4a6e..7ca475bff 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -30,9 +30,9 @@ Content types are deliberately **not** derived client-side: the server decides e `staticDeploymentsEnabled()` in `gate.ts` is the switch, and it is read in exactly one place: `runCLI()` resolves it into `CLIContext.staticDeployments` after `.env` files load, and every layer below is *told* the answer (`deployAppSite({staticDeployments})`, `getSiteDeployCommand(staticDeployments)`). Core never reads the environment, so the flag registration and the transport choice cannot disagree. With `BASE44_STATIC_DEPLOYMENTS=1` (or `true`; internal gate, not user-facing yet), a project with `site.outputDirectory` deploys through the deployments API instead of the legacy tar.gz upload: the output directory becomes the asset manifest (index.html included — it is only ever excluded from uploads), 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 commands, same `--json` output (`{deploymentId, gitHash}`). -Both `base44 deploy` and `base44 site deploy` route through `deployAppSite()` in `core/site/` (see [resources.md](resources.md#site-module-not-a-resource)), which picks the transport (`static-deployment` vs legacy `static`). +**The lane is reachable only from `base44 site deploy`.** That is the command the build sandbox drives — it ships the site, not the whole project — so the whole lane lives behind that one entry point: it routes through `deployAppSite()` (see [resources.md](resources.md#site-module-not-a-resource)), which picks the transport (`static-deployment` vs legacy `static`), and `--git-hash` is registered in `getSiteDeployCommand()` only when `staticDeployments` is true. With the gate off the flag is absent from `--help` and rejected as an unknown option. -**`--git-hash` exists only on `base44 site deploy`, and only when the gate is on.** That is the command the build sandbox drives — it ships the site, not the whole project — so the commit override lives there and nowhere else. It is registered inside `getSiteDeployCommand()` when `staticDeployments` is true; otherwise it is absent from `--help` and rejected as an unknown option, so a released CLI carrying this lane looks unchanged to users. `base44 deploy` still takes the lane when the gate is on, but always addresses the checkout's `HEAD`. The sandbox runs `base44 site deploy -y --json --git-hash ` with a scoped `apps:deploy` workspace key. +`base44 deploy` is untouched by this lane: it keeps shipping the site through `deployAll()`'s legacy tar.gz step exactly as before, gate on or off. The sandbox runs `base44 site deploy -y --json --git-hash ` with a scoped `apps:deploy` workspace key. When the unified deploy should adopt the lane too, that is a deliberate follow-up. ## Testing diff --git a/docs/resources.md b/docs/resources.md index 8896a88b6..e7242d2ec 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -78,7 +78,7 @@ 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 owns **which transport ships the build**. `deployAppSite()` in `deploy-app.ts` is the single entry point both `base44 deploy` and `base44 site deploy` call: +It owns **which transport ships the build**. `deployAppSite()` in `deploy-app.ts` is the entry point `base44 site deploy` calls (`base44 deploy` still goes through `deployAll()`'s legacy site step): - `site.outputDirectory` ships as a static site: through the deployments API when the env-gated lane is enabled (`gate.ts`, `manifest.ts`, `static-site.ts`, `upload.ts` — see [deployments.md](deployments.md)), else the legacy path, tar.gz the built files and upload via `POST /api/apps/{app_id}/deploy-dist`. Both transports share the module's `api.ts` and `schema.ts`. - No `site.outputDirectory` → `{ kind: "none" }`. @@ -91,7 +91,7 @@ const result = await deployAppSite(project, { gitHash }); // | { kind: "static", appUrl } | { kind: "none" } ``` -`detectAppDeployKind()` answers what would ship right now — used for the deploy summary and spinner labels. It answers for the current state of the tree, so a build step invalidates it. +`detectAppDeployKind()` answers what would ship right now — used to pick the spinner labels. It answers for the current state of the tree, so a build step invalidates it. ### Deploy Flow @@ -121,7 +121,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 — via `deployAppSite()`, which picks the transport (see [Site Module](#site-module-not-a-resource)). The deploy command passes `site: false` to `deployAll()` and handles this step itself, after the optional build step has produced whatever the site ships. +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)). ```bash base44 deploy # With confirmation prompt diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index 5fb9d0cfa..986c01ffc 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -7,7 +7,6 @@ import { } from "@/cli/commands/connectors/oauth-prompt.js"; import { formatDeployResult } from "@/cli/commands/functions/formatDeployResult.js"; import { maybeBuildBeforeDeploy } from "@/cli/commands/project/site-build.js"; -import { runAppSiteDeploy } from "@/cli/commands/site/run-app-deploy.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command, @@ -42,8 +41,6 @@ export async function deployAction( } const projectData = await readProjectConfig(options.projectRoot); - const { project, entities, functions, agents, connectors, authConfig } = - projectData; if (!hasResourcesToDeploy(projectData)) { return { @@ -51,6 +48,9 @@ export async function deployAction( }; } + const { project, entities, functions, agents, connectors, authConfig } = + projectData; + // Build summary of what will be deployed const summaryLines: string[] = []; if (entities.length > 0) { @@ -102,13 +102,11 @@ export async function deployAction( await maybeBuildBeforeDeploy(ctx, project, options.build); - // Deploy resources with per-function progress; the site ships below, from - // whatever the build produced. + // Deploy resources with per-function progress let functionCompleted = 0; const functionTotal = functions.length; const result = await deployAll(projectData, { - site: false, onVisibilitySet: (level) => { log.success(`App visibility set to ${level}`); }, @@ -126,8 +124,6 @@ export async function deployAction( }, }); - const siteResult = await runAppSiteDeploy(ctx, project); - // Handle connector-specific post-deploy flows const connectorResults = result.connectorResults ?? []; await handleOAuthConnectors(connectorResults, isNonInteractive, options, log); @@ -139,42 +135,13 @@ export async function deployAction( log.message( `${theme.styles.header("Dashboard")}: ${theme.colors.links(getDashboardUrl())}`, ); - if (siteResult.kind === "static") { + if (result.appUrl) { log.message( - `${theme.styles.header("App URL")}: ${theme.colors.links(siteResult.appUrl)}`, + `${theme.styles.header("App URL")}: ${theme.colors.links(result.appUrl)}`, ); } - const deployment = - siteResult.kind === "static-deployment" ? siteResult : undefined; - if (deployment) { - printDeploymentSummary(deployment, log); - } - - return { - outroMessage: "App deployed successfully", - stdout: - ctx.jsonMode && deployment - ? `${JSON.stringify( - { - deploymentId: deployment.deploymentId, - gitHash: deployment.gitHash, - }, - null, - 2, - )}\n` - : undefined, - }; -} -function printDeploymentSummary( - deployment: { deploymentId: string; gitHash: string }, - log: Logger, -): void { - // 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. - log.message( - `${theme.styles.header("Deployment")}: ${deployment.deploymentId} ${theme.styles.dim(`(commit ${deployment.gitHash.slice(0, 12)})`)}`, - ); + return { outroMessage: "App deployed successfully" }; } export function getDeployCommand(): Command { diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index aa8fd3576..99ff0ef95 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -33,11 +33,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { connectors, authConfig, } = projectData; - // A build command counts: a full-stack project may configure nothing but - // the build, and a generated artifact won't be on disk until it has run. - const hasSite = Boolean( - project.site?.outputDirectory || project.site?.buildCommand, - ); + const hasSite = Boolean(project.site?.outputDirectory); const hasEntities = entities.length > 0; const hasFunctions = functions.length > 0; const hasAgents = agents.length > 0; @@ -75,12 +71,6 @@ interface DeployAllResult { interface DeployAllOptions { onFunctionStart?: (names: string[]) => void; onFunctionResult?: (result: SingleFunctionDeployResult) => void; - /** - * Deploy the legacy static site (tar.gz upload) when configured. The unified - * deploy command passes false and handles the site itself. - * @default true - */ - site?: boolean; onVisibilitySet?: (visibility: Visibility) => void; } @@ -126,7 +116,7 @@ export async function deployAll( ? [] : (await pushConnectors(connectors)).results; - if ((options?.site ?? true) && project.site?.outputDirectory) { + if (project.site?.outputDirectory) { const outputDir = resolve(project.root, project.site.outputDirectory); const { appUrl } = await deploySite(outputDir); return { appUrl, connectorResults }; diff --git a/packages/cli/tests/cli/static_site_deployments.spec.ts b/packages/cli/tests/cli/static_site_deployments.spec.ts index 6307280fb..16eca768d 100644 --- a/packages/cli/tests/cli/static_site_deployments.spec.ts +++ b/packages/cli/tests/cli/static_site_deployments.spec.ts @@ -32,13 +32,6 @@ interface CreateBody { describe("site deploy command (static site through the deployments API, env-gated)", () => { const t = setupCLITests(); - /** Mocks hit by the unified deploy's resource-push phase (no resources). */ - function mockResourcePushes() { - t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); - t.api.mockConnectorsList({ integrations: [] }); - t.api.mockStripeStatus({ stripe_mode: null }); - } - /** The s3 create arm: presigned PUT targets for the requested paths. */ function mockStaticCreate(uploadPaths: string[]) { t.api.mockDeploymentCreate({ @@ -93,33 +86,6 @@ describe("site deploy command (static site through the deployments API, env-gate t.expectResult(result).toContain("--git-hash"); }); - it("never exposes --git-hash on the unified deploy, gate on or off", async () => { - t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); - await t.givenLoggedInWithProject(fixture("with-site")); - - const help = await t.run("deploy", "--help"); - const passed = await t.run("deploy", "-y", "--git-hash", GIT_HASH); - - t.expectResult(help).toSucceed(); - t.expectResult(help).toNotContain("--git-hash"); - t.expectResult(passed).toFail(); - t.expectResult(passed).toContain("unknown option"); - }); - - it("still routes the unified deploy's site through the lane when gated on", async () => { - await t.givenLoggedInWithProject(fixture("with-site")); - t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); - mockResourcePushes(); - // Must go untouched: taking the legacy path would have succeeded. - t.api.mockSiteDeploy({ app_url: "https://legacy.example.com" }); - - const result = await t.run("deploy", "-y"); - - t.expectResult(result).toFail(); - t.expectResult(result).toContain("base44 site deploy --git-hash"); - t.expectResult(result).toNotContain("legacy.example.com"); - }); - it("keeps the legacy tar.gz site upload when the gate is off", async () => { await t.givenLoggedInWithProject(fixture("with-site")); t.api.mockSiteDeploy({ app_url: "https://legacy.example.com" }); From 5f166c08d197e46ef6c6d4c3d820a1ec3fffdba1 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 4 Aug 2026 07:45:20 +0300 Subject: [PATCH 05/12] simpler --- packages/cli/src/cli/commands/site/deploy.ts | 5 +++-- packages/cli/src/cli/commands/site/index.ts | 4 ++-- packages/cli/src/cli/index.ts | 1 - packages/cli/src/cli/program.ts | 2 +- packages/cli/src/cli/types.ts | 1 - 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index 0e37cfcb8..c8d27b352 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -7,6 +7,7 @@ import { Base44Command } from "@/cli/utils/index.js"; import { ConfigNotFoundError, InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/project/index.js"; import { runAppSiteDeploy } from "./run-app-deploy.js"; +import { staticDeploymentsEnabled } from "@/core/site/index.js"; interface DeployOptions { yes?: boolean; @@ -76,7 +77,7 @@ async function deployAction( return { outroMessage: "Nothing to deploy" }; } -export function getSiteDeployCommand(staticDeployments = false): Command { +export function getSiteDeployCommand(): Command { const command = new Base44Command("deploy") .description("Deploy built site files to Base44 hosting") .option("-y, --yes", "Skip confirmation prompt") @@ -85,7 +86,7 @@ export function getSiteDeployCommand(staticDeployments = false): Command { // Only registered on the enabled lane, so with the gate off the flag is // absent from --help and rejected as an unknown option. - if (staticDeployments) { + if (staticDeploymentsEnabled()) { command.addOption( new Option( "--git-hash ", diff --git a/packages/cli/src/cli/commands/site/index.ts b/packages/cli/src/cli/commands/site/index.ts index 031c717c1..2850e3602 100644 --- a/packages/cli/src/cli/commands/site/index.ts +++ b/packages/cli/src/cli/commands/site/index.ts @@ -2,9 +2,9 @@ import { Command } from "commander"; import { getSiteDeployCommand } from "./deploy.js"; import { getSiteOpenCommand } from "./open.js"; -export function getSiteCommand(staticDeployments = false): Command { +export function getSiteCommand(): Command { return new Command("site") .description("Manage app site (frontend app)") - .addCommand(getSiteDeployCommand(staticDeployments)) + .addCommand(getSiteDeployCommand()) .addCommand(getSiteOpenCommand()); } diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index f48466ebe..4f499b53c 100644 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -45,7 +45,6 @@ async function runCLI(options?: RunCLIOptions): Promise { errorReporter, isNonInteractive, jsonMode, - staticDeployments: staticDeploymentsEnabled(), distribution: options?.distribution ?? "npm", log, runTask, diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 0c36e23fd..04e1c33e6 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -104,7 +104,7 @@ export function createProgram(context: CLIContext): Command { program.addCommand(getAuthCommand()); // Register site commands - program.addCommand(getSiteCommand(context.staticDeployments)); + program.addCommand(getSiteCommand()); // Register types command program.addCommand(getTypesCommand()); diff --git a/packages/cli/src/cli/types.ts b/packages/cli/src/cli/types.ts index 8a21d2b5f..4a18f79af 100644 --- a/packages/cli/src/cli/types.ts +++ b/packages/cli/src/cli/types.ts @@ -19,7 +19,6 @@ export interface CLIContext { * (`BASE44_STATIC_DEPLOYMENTS`). The only place that gate is read; commands * pass it down rather than re-reading the environment. */ - staticDeployments: boolean; distribution: Distribution; log: Logger; runTask: RunTaskFn; From 0a3997c12b9590d9e2006c7cd738c51be8bb6f77 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 4 Aug 2026 07:45:46 +0300 Subject: [PATCH 06/12] n --- packages/cli/src/cli/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index 4f499b53c..3d8d5f0f2 100644 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -6,7 +6,6 @@ import { ClackLogger, SimpleLogger } from "@base44-cli/logger"; import { createProgram } from "@/cli/program.js"; import { ensureNpmAssets } from "@/core/assets.js"; import { readAuth } from "@/core/auth/index.js"; -import { staticDeploymentsEnabled } from "@/core/site/index.js"; import { CLIExitError } from "./errors.js"; import { ErrorReporter } from "./telemetry/error-reporter.js"; import { addCommandInfoToErrorReporter } from "./telemetry/index.js"; From 6d0573c8c314b5c6fc43f8b4a1287feba1a28212 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 4 Aug 2026 07:46:06 +0300 Subject: [PATCH 07/12] b --- packages/cli/src/cli/types.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/cli/src/cli/types.ts b/packages/cli/src/cli/types.ts index 4a18f79af..efb23a242 100644 --- a/packages/cli/src/cli/types.ts +++ b/packages/cli/src/cli/types.ts @@ -14,11 +14,6 @@ export interface CLIContext { * the lifecycle keeps stdout pure (status/logs are routed to stderr). */ jsonMode: boolean; - /** - * The experimental static-site deployments lane is on for this process - * (`BASE44_STATIC_DEPLOYMENTS`). The only place that gate is read; commands - * pass it down rather than re-reading the environment. - */ distribution: Distribution; log: Logger; runTask: RunTaskFn; From 848e0f712265c620d80eda98511216c057892054 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 4 Aug 2026 08:00:49 +0300 Subject: [PATCH 08/12] maybe simple --- docs/deployments.md | 12 ++- docs/resources.md | 17 ++-- packages/cli/src/cli/commands/site/deploy.ts | 94 ++++++++++++++----- .../src/cli/commands/site/run-app-deploy.ts | 62 ------------ packages/cli/src/core/site/deploy-app.ts | 82 ---------------- packages/cli/src/core/site/gate.ts | 4 +- packages/cli/src/core/site/index.ts | 1 - packages/cli/src/core/site/static-site.ts | 39 ++------ packages/cli/src/core/utils/git.ts | 12 --- .../tests/cli/static_site_deployments.spec.ts | 18 +++- 10 files changed, 109 insertions(+), 232 deletions(-) delete mode 100644 packages/cli/src/cli/commands/site/run-app-deploy.ts delete mode 100644 packages/cli/src/core/site/deploy-app.ts diff --git a/docs/deployments.md b/docs/deployments.md index 7ca475bff..c8a2cf8b5 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -6,9 +6,9 @@ Deployments ship an app's built output addressed by the commit that produced it. **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. -## Git Hash Resolution +## The Commit Address -`deployStaticSite()` resolves its own address: an explicit `--git-hash` wins, otherwise the checked-out commit at the project root. No hash (not a git checkout, no flag) or a non-hex value fails fast with guidance. The git plumbing is general-purpose and lives in `src/core/utils/git.ts` — `getGitHead(cwd)` and `isGitCommitHash(value)` (pattern `^[a-fA-F0-9]{7,64}$`, the same validation as the server). +`--git-hash` carries it, and there is no fallback: the flag is what selects this lane, so a deploy without one is the legacy tar.gz upload. `deployStaticSite()` rejects a value that is not hex — `isGitCommitHash()` in `src/core/utils/git.ts`, pattern `^[a-fA-F0-9]{7,64}$`, the same validation as the server. ## API Contract (app-scoped, via `getAppClient()`) @@ -28,15 +28,17 @@ Content types are deliberately **not** derived client-side: the server decides e ## The Static Lane (experimental, env-gated) -`staticDeploymentsEnabled()` in `gate.ts` is the switch, and it is read in exactly one place: `runCLI()` resolves it into `CLIContext.staticDeployments` after `.env` files load, and every layer below is *told* the answer (`deployAppSite({staticDeployments})`, `getSiteDeployCommand(staticDeployments)`). Core never reads the environment, so the flag registration and the transport choice cannot disagree. With `BASE44_STATIC_DEPLOYMENTS=1` (or `true`; internal gate, not user-facing yet), a project with `site.outputDirectory` deploys through the deployments API instead of the legacy tar.gz upload: the output directory becomes the asset manifest (index.html included — it is only ever excluded from uploads), 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 commands, same `--json` output (`{deploymentId, gitHash}`). +Two switches, in order. **`BASE44_STATIC_DEPLOYMENTS=1`** (or `true`) is the release gate, read by `staticDeploymentsEnabled()` in `gate.ts` and consulted in exactly one place — `getSiteDeployCommand()`, which registers `--git-hash` only when it is set. **`--git-hash `** is then the runtime switch: passing it deploys through the deployments API, omitting it takes the legacy tar.gz upload. So with the gate off the flag does not exist and the lane is unreachable; with it on, the flow is chosen per invocation. -**The lane is reachable only from `base44 site deploy`.** That is the command the build sandbox drives — it ships the site, not the whole project — so the whole lane lives behind that one entry point: it routes through `deployAppSite()` (see [resources.md](resources.md#site-module-not-a-resource)), which picks the transport (`static-deployment` vs legacy `static`), and `--git-hash` is registered in `getSiteDeployCommand()` only when `staticDeployments` is true. With the gate off the flag is absent from `--help` and rejected as an unknown option. +On the lane, the output directory becomes the asset manifest (index.html included — it is only ever excluded from uploads), 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. `--json` emits `{deploymentId, gitHash}`. + +**The lane is reachable only from `base44 site deploy`** — the command the build sandbox drives, since it ships the site rather than the whole project. The fork is a plain `if` in that command's action: `options.gitHash` present → `deployStaticSite()`, absent → `deploySite()` (the tar.gz upload). There is no transport-abstraction layer between the command and the two flows; each branch owns its spinner labels and its result shape. `base44 deploy` is untouched by this lane: it keeps shipping the site through `deployAll()`'s legacy tar.gz step exactly as before, gate on or off. The sandbox runs `base44 site deploy -y --json --git-hash ` with a scoped `apps:deploy` workspace key. When the unified deploy should adopt the lane too, that is a deliberate follow-up. ## Testing -`TestAPIServer` mocks: `mockDeploymentCreate` (captures the JSON body in `deploymentCreateRequests`; echoes whatever response shape you pass — `asset_uploads` is `{type: "s3", ...}` or `null`), `mockPresignedUpload(path)` (serves a presigned-style `PUT /presigned{path}` target, captures body/Content-Type/Authorization in `presignedUploadRequests`), `mockDeploymentFinalize` (captures multipart fields in `finalizeRequests`). Fixture: `tests/fixtures/with-site/` (static output dir) — not a git repo, so specs pass `--git-hash`. Manifest and ignore-pattern unit tests live in `tests/core/site-manifest.spec.ts`. +`TestAPIServer` mocks: `mockDeploymentCreate` (captures the JSON body in `deploymentCreateRequests`; echoes whatever response shape you pass — `asset_uploads` is `{type: "s3", ...}` or `null`), `mockPresignedUpload(path)` (serves a presigned-style `PUT /presigned{path}` target, captures body/Content-Type/Authorization in `presignedUploadRequests`), `mockDeploymentFinalize` (captures multipart fields in `finalizeRequests`). Fixture: `tests/fixtures/with-site/` (static output dir); specs pass `--git-hash` to select the lane. Manifest and ignore-pattern unit tests live in `tests/core/site-manifest.spec.ts`. ## Rules (Deployments-Specific) diff --git a/docs/resources.md b/docs/resources.md index e7242d2ec..0932813bf 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -78,20 +78,19 @@ 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 owns **which transport ships the build**. `deployAppSite()` in `deploy-app.ts` is the entry point `base44 site deploy` calls (`base44 deploy` still goes through `deployAll()`'s legacy site step): - -- `site.outputDirectory` ships as a static site: through the deployments API when the env-gated lane is enabled (`gate.ts`, `manifest.ts`, `static-site.ts`, `upload.ts` — see [deployments.md](deployments.md)), else the legacy path, tar.gz the built files and upload via `POST /api/apps/{app_id}/deploy-dist`. Both transports share the module's `api.ts` and `schema.ts`. -- No `site.outputDirectory` → `{ kind: "none" }`. +It exposes **two ways to ship `site.outputDirectory`**, and the caller picks: ```typescript -import { deployAppSite } from "@/core/site/index.js"; +import { deploySite, deployStaticSite } from "@/core/site/index.js"; + +// Legacy: tar.gz the built files, POST /api/apps/{app_id}/deploy-dist +const { appUrl } = await deploySite(outputDir); -const result = await deployAppSite(project, { gitHash }); -// { kind: "static-deployment", deploymentId, gitHash } -// | { kind: "static", appUrl } | { kind: "none" } +// Deployments API (env-gated lane, see deployments.md) +const { deploymentId } = await deployStaticSite({ outputDir, gitHash }); ``` -`detectAppDeployKind()` answers what would ship right now — used to pick the spinner labels. It answers for the current state of the tree, so a build step invalidates it. +`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`. ### Deploy Flow diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index c8d27b352..eab7e8980 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -1,13 +1,17 @@ +import { resolve } from "node:path"; import { confirm, isCancel } from "@clack/prompts"; import type { Command } from "commander"; import { Option } from "commander"; import { maybeBuildBeforeDeploy } from "@/cli/commands/project/site-build.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; -import { Base44Command } from "@/cli/utils/index.js"; +import { Base44Command, theme } from "@/cli/utils/index.js"; import { ConfigNotFoundError, InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/project/index.js"; -import { runAppSiteDeploy } from "./run-app-deploy.js"; -import { staticDeploymentsEnabled } from "@/core/site/index.js"; +import { + deploySite, + deployStaticSite, + staticDeploymentsEnabled, +} from "@/core/site/index.js"; interface DeployOptions { yes?: boolean; @@ -51,30 +55,70 @@ async function deployAction( await maybeBuildBeforeDeploy(ctx, project, options.build); - const result = await runAppSiteDeploy(ctx, project, { - gitHash: options.gitHash, - }); - - if (result.kind === "static-deployment") { - // 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 ${result.deploymentId} (commit ${result.gitHash.slice(0, 12)})`, - stdout: ctx.jsonMode - ? `${JSON.stringify( - { deploymentId: result.deploymentId, gitHash: result.gitHash }, - null, - 2, - )}\n` - : undefined, - }; - } + 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. + return options.gitHash + ? await deployToDeploymentsApi(ctx, outputDir, options.gitHash) + : await deployTarball(ctx, outputDir); +} - if (result.kind === "static") { - return { outroMessage: `Visit your site at: ${result.appUrl}` }; +async function deployToDeploymentsApi( + { runTask, log, jsonMode }: CLIContext, + outputDir: string, + gitHash: string, +): Promise { + const progressLines: string[] = []; + + const { deploymentId } = await runTask( + "Deploying site...", + async (updateMessage) => + await deployStaticSite({ + outputDir, + gitHash, + 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`); + }, + }, + }), + { successMessage: "Site deployed", errorMessage: "Site deploy failed" }, + ); + + for (const line of progressLines) { + log.message(theme.styles.dim(line)); } - return { outroMessage: "Nothing to deploy" }; + // 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, + }; +} + +async function deployTarball( + { runTask }: CLIContext, + outputDir: string, +): Promise { + const { appUrl } = await runTask( + "Creating archive and deploying site...", + async () => await deploySite(outputDir), + { + successMessage: "Site deployed successfully", + errorMessage: "Deployment failed", + }, + ); + + return { outroMessage: `Visit your site at: ${appUrl}` }; } export function getSiteDeployCommand(): Command { @@ -90,7 +134,7 @@ export function getSiteDeployCommand(): Command { command.addOption( new Option( "--git-hash ", - "Commit the build came from (defaults to the checkout's HEAD)", + "Commit the build came from — deploys through the deployments API", ), ); } diff --git a/packages/cli/src/cli/commands/site/run-app-deploy.ts b/packages/cli/src/cli/commands/site/run-app-deploy.ts deleted file mode 100644 index a4b81702c..000000000 --- a/packages/cli/src/cli/commands/site/run-app-deploy.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { CLIContext } from "@/cli/types.js"; -import { theme } from "@/cli/utils/index.js"; -import type { AppDeployResult, AppSiteTarget } from "@/core/site/index.js"; -import { deployAppSite, detectAppDeployKind } from "@/core/site/index.js"; - -const TASK_LABELS = { - "static-deployment": { - start: "Deploying site...", - success: "Site deployed", - error: "Site deploy failed", - }, - static: { - start: "Creating archive and deploying site...", - success: "Site deployed successfully", - error: "Deployment failed", - }, -} as const; - -/** - * Run the project's site deploy behind a spinner, adapting the labels and the - * progress stream to whichever transport applies. - */ -export async function runAppSiteDeploy( - { runTask, log, staticDeployments }: CLIContext, - target: AppSiteTarget, - options: { gitHash?: string } = {}, -): Promise { - const kind = detectAppDeployKind(target, { staticDeployments }); - if (kind === "none") return { kind: "none" }; - - const labels = TASK_LABELS[kind]; - const progressLines: string[] = []; - - const result = await runTask( - labels.start, - async (updateMessage) => - await deployAppSite(target, { - staticDeployments, - gitHash: options.gitHash, - 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`); - }, - }, - }), - { - successMessage: labels.success, - errorMessage: labels.error, - }, - ); - - for (const line of progressLines) { - log.message(theme.styles.dim(line)); - } - - return result; -} diff --git a/packages/cli/src/core/site/deploy-app.ts b/packages/cli/src/core/site/deploy-app.ts deleted file mode 100644 index 779f4e65d..000000000 --- a/packages/cli/src/core/site/deploy-app.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { resolve } from "node:path"; -import { deploySite } from "@/core/site/deploy.js"; -import type { DeploymentProgress } from "./schema.js"; -import { deployStaticSite } from "./static-site.js"; - -/** The project fields an app deploy reads. */ -export interface AppSiteTarget { - root: string; - site?: { outputDirectory?: string }; -} - -type AppDeployKind = "static-deployment" | "static" | "none"; - -export type AppDeployResult = - | { kind: "static-deployment"; deploymentId: string; gitHash: string } - | { kind: "static"; appUrl: string } - | { kind: "none" }; - -interface StaticDeploymentsOption { - /** The gate is read at the CLI edge and passed down; core never reads env. */ - staticDeployments?: boolean; -} - -type AppDeployPlan = - | { kind: "static-deployment"; outputDir: string } - | { kind: "static"; outputDir: string } - | { kind: "none" }; - -function planAppDeploy( - target: AppSiteTarget, - staticDeployments: boolean, -): AppDeployPlan { - const outputDirectory = target.site?.outputDirectory; - if (!outputDirectory) { - return { kind: "none" }; - } - const outputDir = resolve(target.root, outputDirectory); - return staticDeployments - ? { kind: "static-deployment", outputDir } - : { kind: "static", outputDir }; -} - -/** How the built output would ship right now — a build step invalidates it. */ -export function detectAppDeployKind( - target: AppSiteTarget, - options: StaticDeploymentsOption = {}, -): AppDeployKind { - return planAppDeploy(target, options.staticDeployments ?? false).kind; -} - -/** - * Deploy the project's built output over whichever transport applies — - * a deployments-API static deployment when the lane is enabled, the legacy - * tar.gz upload otherwise. - */ -export async function deployAppSite( - target: AppSiteTarget, - options: StaticDeploymentsOption & { - gitHash?: string; - progress?: DeploymentProgress; - } = {}, -): Promise { - const plan = planAppDeploy(target, options.staticDeployments ?? false); - - switch (plan.kind) { - case "static-deployment": { - const { deploymentId, gitHash } = await deployStaticSite({ - outputDir: plan.outputDir, - projectRoot: target.root, - gitHash: options.gitHash, - progress: options.progress, - }); - return { kind: "static-deployment", deploymentId, gitHash }; - } - case "static": { - const { appUrl } = await deploySite(plan.outputDir); - return { kind: "static", appUrl }; - } - case "none": - return { kind: "none" }; - } -} diff --git a/packages/cli/src/core/site/gate.ts b/packages/cli/src/core/site/gate.ts index bdde48a06..67f3c1267 100644 --- a/packages/cli/src/core/site/gate.ts +++ b/packages/cli/src/core/site/gate.ts @@ -1,7 +1,7 @@ /** * Internal gate for the experimental static-site deployments-API lane, not - * user-facing yet. Read once into `CLIContext.staticDeployments` and passed - * down from there — no layer below the CLI edge consults the environment. + * user-facing yet. It decides whether `--git-hash` is registered at all; the + * flag being passed is what routes a deploy through the deployments API. */ const STATIC_DEPLOYMENTS_ENV = "BASE44_STATIC_DEPLOYMENTS"; diff --git a/packages/cli/src/core/site/index.ts b/packages/cli/src/core/site/index.ts index f7c6d3822..91bffc223 100644 --- a/packages/cli/src/core/site/index.ts +++ b/packages/cli/src/core/site/index.ts @@ -1,7 +1,6 @@ export * from "./api.js"; export * from "./config.js"; export * from "./deploy.js"; -export * from "./deploy-app.js"; export * from "./gate.js"; export * from "./manifest.js"; export * from "./schema.js"; diff --git a/packages/cli/src/core/site/static-site.ts b/packages/cli/src/core/site/static-site.ts index bc12bb3d0..cc0f64348 100644 --- a/packages/cli/src/core/site/static-site.ts +++ b/packages/cli/src/core/site/static-site.ts @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { InvalidInputError } from "@/core/errors.js"; import { getAppContext } from "@/core/project/app-config.js"; -import { getGitHead, isGitCommitHash } from "@/core/utils/git.js"; +import { isGitCommitHash } from "@/core/utils/git.js"; import { createDeployment, finalizeStaticDeployment } from "./api.js"; import { buildAssetManifest } from "./manifest.js"; import type { DeploymentProgress } from "./schema.js"; @@ -16,12 +16,13 @@ import { uploadPresignedAssets } from "./upload.js"; */ export async function deployStaticSite(options: { outputDir: string; - projectRoot: string; - gitHash?: string; + gitHash: string; progress?: DeploymentProgress; -}): Promise<{ deploymentId: string; gitHash: string }> { - const { outputDir, projectRoot, progress } = options; - const gitHash = await resolveGitHash(projectRoot, options.gitHash); +}): Promise<{ deploymentId: string }> { + const { outputDir, gitHash, progress } = options; + if (!isGitCommitHash(gitHash)) { + throw new InvalidInputError(`'${gitHash}' is not a git commit hash.`); + } const assets = await buildAssetManifest(outputDir, getAppContext().id); // Finalize carries the index.html bytes by contract, so its absence is a @@ -55,29 +56,5 @@ export async function deployStaticSite(options: { new Uint8Array(indexHtml), ); - return { deploymentId: finalized.deploymentId, gitHash }; -} - -/** An explicit hash (flag/automation) wins over the checkout's HEAD. */ -async function resolveGitHash( - projectRoot: string, - explicit?: string, -): Promise { - const hash = explicit ?? (await getGitHead(projectRoot)); - if (!hash || !isGitCommitHash(hash)) { - throw new InvalidInputError( - explicit - ? `'${explicit}' is not a git commit hash.` - : "Deployments are addressed by the commit that produced the build, and no git commit was found.", - { - hints: [ - { - message: - "Run the deploy from a git checkout, or pass the commit explicitly: base44 site deploy --git-hash .", - }, - ], - }, - ); - } - return hash; + return { deploymentId: finalized.deploymentId }; } diff --git a/packages/cli/src/core/utils/git.ts b/packages/cli/src/core/utils/git.ts index 8e02cfdfc..f3a5be13b 100644 --- a/packages/cli/src/core/utils/git.ts +++ b/packages/cli/src/core/utils/git.ts @@ -1,18 +1,6 @@ -import { execa } from "execa"; - /** Abbreviated or full commit hash — the same pattern the server validates. */ const GIT_HASH_PATTERN = /^[a-fA-F0-9]{7,64}$/; export function isGitCommitHash(value: string): boolean { return GIT_HASH_PATTERN.test(value); } - -/** The commit checked out at `cwd`, or null when it is not a git checkout. */ -export async function getGitHead(cwd: string): Promise { - try { - const { stdout } = await execa("git", ["rev-parse", "HEAD"], { cwd }); - return stdout.trim(); - } catch { - return null; - } -} diff --git a/packages/cli/tests/cli/static_site_deployments.spec.ts b/packages/cli/tests/cli/static_site_deployments.spec.ts index 16eca768d..4a1ec4cce 100644 --- a/packages/cli/tests/cli/static_site_deployments.spec.ts +++ b/packages/cli/tests/cli/static_site_deployments.spec.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { fixture, setupCLITests } from "./testkit/index.js"; -/** The commit the fixture "build" came from (the fixture is not a git repo). */ +/** The commit the fixture "build" came from. */ const GIT_HASH = "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c"; const DEPLOYMENT_ID = "test-app-git-0f1e2d3c4b5a"; @@ -180,14 +180,26 @@ describe("site deploy command (static site through the deployments API, env-gate }); }); - it("requires a commit hash outside a git checkout", async () => { + it("takes the legacy path when the gate is on but no commit is passed", async () => { await t.givenLoggedInWithProject(fixture("with-site")); t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); + t.api.mockSiteDeploy({ app_url: "https://legacy.example.com" }); const result = await t.run("site", "deploy", "-y"); + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("https://legacy.example.com"); + expect(t.api.deploymentCreateRequests).toHaveLength(0); + }); + + it("rejects a --git-hash that is not a commit hash", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); + + const result = await t.run("site", "deploy", "-y", "--git-hash", "nope"); + t.expectResult(result).toFail(); - t.expectResult(result).toContain("--git-hash"); + t.expectResult(result).toContain("not a git commit hash"); expect(t.api.deploymentCreateRequests).toHaveLength(0); }); }); From cb8bfa0497b519a5749db932c2e655397b1d44b2 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 4 Aug 2026 08:07:25 +0300 Subject: [PATCH 09/12] arg parser --- packages/cli/src/cli/commands/site/deploy.ts | 12 ++++++++++-- packages/cli/src/core/site/static-site.ts | 4 ---- .../cli/tests/cli/static_site_deployments.spec.ts | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index eab7e8980..1e86f7bf8 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -1,7 +1,7 @@ import { resolve } from "node:path"; import { confirm, isCancel } from "@clack/prompts"; import type { Command } from "commander"; -import { Option } from "commander"; +import { InvalidArgumentError, Option } from "commander"; import { maybeBuildBeforeDeploy } from "@/cli/commands/project/site-build.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command, theme } from "@/cli/utils/index.js"; @@ -12,6 +12,7 @@ import { deployStaticSite, staticDeploymentsEnabled, } from "@/core/site/index.js"; +import { isGitCommitHash } from "@/core/utils/git.js"; interface DeployOptions { yes?: boolean; @@ -135,7 +136,14 @@ export function getSiteDeployCommand(): Command { new Option( "--git-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; + }), ); } diff --git a/packages/cli/src/core/site/static-site.ts b/packages/cli/src/core/site/static-site.ts index cc0f64348..3a5510d3f 100644 --- a/packages/cli/src/core/site/static-site.ts +++ b/packages/cli/src/core/site/static-site.ts @@ -2,7 +2,6 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { InvalidInputError } from "@/core/errors.js"; import { getAppContext } from "@/core/project/app-config.js"; -import { isGitCommitHash } from "@/core/utils/git.js"; import { createDeployment, finalizeStaticDeployment } from "./api.js"; import { buildAssetManifest } from "./manifest.js"; import type { DeploymentProgress } from "./schema.js"; @@ -20,9 +19,6 @@ export async function deployStaticSite(options: { progress?: DeploymentProgress; }): Promise<{ deploymentId: string }> { const { outputDir, gitHash, progress } = options; - if (!isGitCommitHash(gitHash)) { - throw new InvalidInputError(`'${gitHash}' is not a git commit hash.`); - } const assets = await buildAssetManifest(outputDir, getAppContext().id); // Finalize carries the index.html bytes by contract, so its absence is a diff --git a/packages/cli/tests/cli/static_site_deployments.spec.ts b/packages/cli/tests/cli/static_site_deployments.spec.ts index 4a1ec4cce..8ec80ea08 100644 --- a/packages/cli/tests/cli/static_site_deployments.spec.ts +++ b/packages/cli/tests/cli/static_site_deployments.spec.ts @@ -199,7 +199,7 @@ describe("site deploy command (static site through the deployments API, env-gate const result = await t.run("site", "deploy", "-y", "--git-hash", "nope"); t.expectResult(result).toFail(); - t.expectResult(result).toContain("not a git commit hash"); + t.expectResult(result).toContain("Expected a git commit hash"); expect(t.api.deploymentCreateRequests).toHaveLength(0); }); }); From df28ea4f08fb404180d8a2b54326832d00fb22f0 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 4 Aug 2026 08:22:44 +0300 Subject: [PATCH 10/12] simpler --- bun.lock | 3 ++ docs/deployments.md | 2 +- packages/cli/package.json | 1 + packages/cli/src/cli/commands/site/deploy.ts | 30 +++++++++++++- packages/cli/src/core/site/static-site.ts | 12 +++--- packages/cli/src/core/site/upload.ts | 35 ++++++++-------- .../tests/cli/static_site_deployments.spec.ts | 40 +++++++++++++++++++ 7 files changed, 96 insertions(+), 27 deletions(-) diff --git a/bun.lock b/bun.lock index cd7aecae7..d7e535e73 100644 --- a/bun.lock +++ b/bun.lock @@ -61,6 +61,7 @@ "nanoid": "^5.1.6", "open": "^11.0.0", "outdent": "^0.8.0", + "p-map": "^7.0.6", "p-wait-for": "^6.0.0", "posthog-node": "5.21.2", "qs": "^6.12.3", @@ -836,6 +837,8 @@ "oxc-resolver": ["oxc-resolver@11.17.1", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.17.1", "@oxc-resolver/binding-android-arm64": "11.17.1", "@oxc-resolver/binding-darwin-arm64": "11.17.1", "@oxc-resolver/binding-darwin-x64": "11.17.1", "@oxc-resolver/binding-freebsd-x64": "11.17.1", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.17.1", "@oxc-resolver/binding-linux-arm-musleabihf": "11.17.1", "@oxc-resolver/binding-linux-arm64-gnu": "11.17.1", "@oxc-resolver/binding-linux-arm64-musl": "11.17.1", "@oxc-resolver/binding-linux-ppc64-gnu": "11.17.1", "@oxc-resolver/binding-linux-riscv64-gnu": "11.17.1", "@oxc-resolver/binding-linux-riscv64-musl": "11.17.1", "@oxc-resolver/binding-linux-s390x-gnu": "11.17.1", "@oxc-resolver/binding-linux-x64-gnu": "11.17.1", "@oxc-resolver/binding-linux-x64-musl": "11.17.1", "@oxc-resolver/binding-openharmony-arm64": "11.17.1", "@oxc-resolver/binding-wasm32-wasi": "11.17.1", "@oxc-resolver/binding-win32-arm64-msvc": "11.17.1", "@oxc-resolver/binding-win32-ia32-msvc": "11.17.1", "@oxc-resolver/binding-win32-x64-msvc": "11.17.1" } }, "sha512-pyRXK9kH81zKlirHufkFhOFBZRks8iAMLwPH8gU7lvKFiuzUH9L8MxDEllazwOb8fjXMcWjY1PMDfMJ2/yh5cw=="], + "p-map": ["p-map@7.0.6", "", {}, "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg=="], + "p-wait-for": ["p-wait-for@6.0.0", "", {}, "sha512-2kKzMtjS8TVcpCOU/gr3vZ4K/WIyS1AsEFXFWapM/0lERCdyTbB6ZeuCIp+cL1aeLZfQoMdZFCBTHiK4I9UtOw=="], "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], diff --git a/docs/deployments.md b/docs/deployments.md index c8a2cf8b5..e84c4dcb8 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -15,7 +15,7 @@ Deployments ship an app's built output addressed by the commit that produced it. 1. `POST deployments` — JSON body: `git_hash` (required) and `asset_manifest` (`{"/path": {hash, size}}`). The response is `{deployment_id, asset_uploads}` where `deployment_id` is a handle for the rest of the flow and `asset_uploads` says where the assets still owed should go, discriminated on `type`: - `{type: "s3", uploads: [{path, content_type, content_length, url}]}` — one presigned S3 PUT per asset still to upload, **always excluding `/index.html`** (finalize carries it). - `null` — nothing owed: no assets, or the build already exists (re-deploying a commit is idempotent). -2. **Asset upload — bytes never pass through the backend.** Each upload's raw file bytes are `PUT` directly to its presigned `url` with the signed `content_type` sent verbatim (the URL also signs `content_length`, so the body must be exactly the declared bytes). The URL itself is the credential, so no auth headers and never the app client. Per file: 3 attempts with exponential backoff, concurrency 3. +2. **Asset upload — bytes never pass through the backend.** Each upload's raw file bytes are `PUT` directly to its presigned `url` with the signed `content_type` sent verbatim (the URL also signs `content_length`, so the body must be exactly the declared bytes). The URL itself is the credential, so no auth headers and never the app client. Per file: 3 attempts with exponential backoff. Concurrency defaults to `DEFAULT_UPLOAD_CONCURRENCY` (3) and is overridable with `--concurrency `, capped at `MAX_UPLOAD_CONCURRENCY` (50) because each worker holds a whole file in memory. 3. `POST deployments/{id}/finalize` — multipart with exactly one file field named `index.html` carrying the index.html bytes (contentType `text/html`) — no other fields. index.html is the sentinel that completes the deployment, which is also why it never appears in the uploads. Returns `{deployment_id}`. ## Asset Manifest & Hashing diff --git a/packages/cli/package.json b/packages/cli/package.json index 2c6913f0e..aa004a9de 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -79,6 +79,7 @@ "nanoid": "^5.1.6", "open": "^11.0.0", "outdent": "^0.8.0", + "p-map": "^7.0.6", "p-wait-for": "^6.0.0", "posthog-node": "5.21.2", "qs": "^6.12.3", diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index 1e86f7bf8..34184fca9 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -8,8 +8,10 @@ import { Base44Command, theme } from "@/cli/utils/index.js"; import { ConfigNotFoundError, InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/project/index.js"; import { + DEFAULT_UPLOAD_CONCURRENCY, deploySite, deployStaticSite, + MAX_UPLOAD_CONCURRENCY, staticDeploymentsEnabled, } from "@/core/site/index.js"; import { isGitCommitHash } from "@/core/utils/git.js"; @@ -18,6 +20,7 @@ interface DeployOptions { yes?: boolean; build?: boolean; gitHash?: string; + concurrency?: number; } async function deployAction( @@ -60,8 +63,10 @@ async function deployAction( // 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. - return options.gitHash - ? await deployToDeploymentsApi(ctx, outputDir, options.gitHash) + const { gitHash, concurrency } = options; + + return gitHash + ? await deployToDeploymentsApi(ctx, outputDir, gitHash, concurrency) : await deployTarball(ctx, outputDir); } @@ -69,6 +74,7 @@ async function deployToDeploymentsApi( { runTask, log, jsonMode }: CLIContext, outputDir: string, gitHash: string, + concurrency?: number, ): Promise { const progressLines: string[] = []; @@ -78,6 +84,7 @@ async function deployToDeploymentsApi( await deployStaticSite({ outputDir, gitHash, + concurrency, progress: { onAssets: ({ totalAssets, newAssets }) => { const line = `Found ${totalAssets} static assets (${newAssets} new)`; @@ -145,7 +152,26 @@ export function getSiteDeployCommand(): Command { return value; }), ); + command.addOption( + new Option("--concurrency ", "Parallel asset uploads") + .default(DEFAULT_UPLOAD_CONCURRENCY) + .argParser(parseConcurrency), + ); } return command.action(deployAction); } + +function parseConcurrency(value: string): number { + const parsed = Number(value); + if ( + !Number.isInteger(parsed) || + parsed < 1 || + parsed > MAX_UPLOAD_CONCURRENCY + ) { + throw new InvalidArgumentError( + `Expected a whole number between 1 and ${MAX_UPLOAD_CONCURRENCY}.`, + ); + } + return parsed; +} diff --git a/packages/cli/src/core/site/static-site.ts b/packages/cli/src/core/site/static-site.ts index 3a5510d3f..0ab1f3067 100644 --- a/packages/cli/src/core/site/static-site.ts +++ b/packages/cli/src/core/site/static-site.ts @@ -16,9 +16,10 @@ import { uploadPresignedAssets } from "./upload.js"; export async function deployStaticSite(options: { outputDir: string; gitHash: string; + concurrency?: number; progress?: DeploymentProgress; }): Promise<{ deploymentId: string }> { - const { outputDir, gitHash, progress } = options; + const { outputDir, gitHash, concurrency, progress } = options; const assets = await buildAssetManifest(outputDir, getAppContext().id); // Finalize carries the index.html bytes by contract, so its absence is a @@ -39,11 +40,10 @@ export async function deployStaticSite(options: { }); if (created.assetUploads) { - await uploadPresignedAssets( - created.assetUploads.uploads, - assets, - progress?.onAssetUpload, - ); + await uploadPresignedAssets(created.assetUploads.uploads, assets, { + concurrency, + onProgress: progress?.onAssetUpload, + }); } const indexHtml = await readFile(join(outputDir, "index.html")); diff --git a/packages/cli/src/core/site/upload.ts b/packages/cli/src/core/site/upload.ts index 0a8710b35..1b9e2f04d 100644 --- a/packages/cli/src/core/site/upload.ts +++ b/packages/cli/src/core/site/upload.ts @@ -1,5 +1,7 @@ import { readFile } from "node:fs/promises"; +import { setTimeout as sleep } from "node:timers/promises"; import ky from "ky"; +import pMap from "p-map"; import { ApiError, InternalError } from "@/core/errors.js"; import type { AssetManifestResult, @@ -7,7 +9,11 @@ import type { PresignedAssetUpload, } from "./schema.js"; -const UPLOAD_CONCURRENCY = 3; +export const DEFAULT_UPLOAD_CONCURRENCY = 3; + +/** Each worker holds a whole file in memory, so the ceiling is a memory bound. */ +export const MAX_UPLOAD_CONCURRENCY = 50; + const MAX_ATTEMPTS_PER_UPLOAD = 3; const RETRY_BASE_DELAY_MS = 500; @@ -19,25 +25,22 @@ const RETRY_BASE_DELAY_MS = 500; export async function uploadPresignedAssets( uploads: PresignedAssetUpload[], assets: AssetManifestResult, - onProgress?: (progress: AssetUploadProgress) => void, + options: { + concurrency?: number; + onProgress?: (progress: AssetUploadProgress) => void; + } = {}, ): Promise { + const { concurrency = DEFAULT_UPLOAD_CONCURRENCY, onProgress } = options; let uploadedFiles = 0; - let nextUpload = 0; - const worker = async (): Promise => { - while (nextUpload < uploads.length) { - const upload = uploads[nextUpload++]; + await pMap( + uploads, + async (upload) => { await uploadPresignedAssetWithRetry(upload, assets); uploadedFiles++; onProgress?.({ uploadedFiles, totalFiles: uploads.length }); - } - }; - - await Promise.all( - Array.from( - { length: Math.min(UPLOAD_CONCURRENCY, uploads.length) }, - worker, - ), + }, + { concurrency }, ); } @@ -75,7 +78,3 @@ async function uploadPresignedAssetWithRetry( } throw await ApiError.fromHttpError(lastError, "uploading static assets"); } - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/packages/cli/tests/cli/static_site_deployments.spec.ts b/packages/cli/tests/cli/static_site_deployments.spec.ts index 8ec80ea08..4d892e089 100644 --- a/packages/cli/tests/cli/static_site_deployments.spec.ts +++ b/packages/cli/tests/cli/static_site_deployments.spec.ts @@ -202,4 +202,44 @@ describe("site deploy command (static site through the deployments API, env-gate t.expectResult(result).toContain("Expected a git commit hash"); expect(t.api.deploymentCreateRequests).toHaveLength(0); }); + + it("uploads every asset under a --concurrency override", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); + mockStaticCreate(["/main.js", "/styles.css"]); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + + const result = await t.run( + "site", + "deploy", + "-y", + "--git-hash", + GIT_HASH, + "--concurrency", + "1", + ); + + t.expectResult(result).toSucceed(); + expect(t.api.presignedUploadRequests).toHaveLength(2); + }); + + it("rejects a --concurrency outside the allowed range", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + t.givenEnv({ BASE44_STATIC_DEPLOYMENTS: "1" }); + + const zero = await t.run("site", "deploy", "-y", "--concurrency", "0"); + const huge = await t.run("site", "deploy", "-y", "--concurrency", "999"); + + t.expectResult(zero).toFail(); + t.expectResult(zero).toContain("between 1 and 50"); + t.expectResult(huge).toFail(); + t.expectResult(huge).toContain("between 1 and 50"); + }); + + it("hides --concurrency while the gate is off", async () => { + const result = await t.run("site", "deploy", "--help"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toNotContain("--concurrency"); + }); }); From 256c5ce37a7184a3f9580c0d88e5303bc8f6d4e6 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 4 Aug 2026 08:29:06 +0300 Subject: [PATCH 11/12] simple --- docs/deployments.md | 6 +-- packages/cli/src/cli/commands/site/deploy.ts | 8 +++- packages/cli/src/core/site/gate.ts | 13 ------ packages/cli/src/core/site/index.ts | 1 - packages/cli/src/core/site/upload.ts | 42 +++++++++----------- 5 files changed, 29 insertions(+), 41 deletions(-) delete mode 100644 packages/cli/src/core/site/gate.ts diff --git a/docs/deployments.md b/docs/deployments.md index e84c4dcb8..d31969689 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -2,13 +2,13 @@ **Keywords:** deployments, static site, asset manifest, hash, git hash, commit, presigned, S3, finalize, index.html sentinel, BASE44_STATIC_DEPLOYMENTS, upload -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/`: `gate.ts` (the env gate), `manifest.ts` (asset walk + hashing), `static-site.ts` (the flow), `upload.ts` (presigned PUTs), with the requests and responses in the shared `api.ts` / `schema.ts` next to the legacy tar.gz upload. Today it carries the env-gated static-site lane; the create response is an ADT designed so a worker (`cf`) arm can slot in next to the static (`s3`) arm without protocol changes — that is the progressive-upgrade path for full-stack apps. +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/`: `manifest.ts` (asset walk + hashing), `static-site.ts` (the flow), `upload.ts` (presigned PUTs), with the requests and responses in the shared `api.ts` / `schema.ts` next to the legacy tar.gz upload. Today it carries the env-gated static-site lane; the create response is an ADT designed so a worker (`cf`) arm can slot in next to the static (`s3`) arm without protocol changes — that is the progressive-upgrade path for full-stack apps. **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. ## The Commit Address -`--git-hash` carries it, and there is no fallback: the flag is what selects this lane, so a deploy without one is the legacy tar.gz upload. `deployStaticSite()` rejects a value that is not hex — `isGitCommitHash()` in `src/core/utils/git.ts`, pattern `^[a-fA-F0-9]{7,64}$`, the same validation as the server. +`--git-hash` carries it, and there is no fallback: the flag is what selects this lane, so a deploy without one is the legacy tar.gz upload. A non-hex value is rejected by the option's `argParser` before the action runs — `isGitCommitHash()` in `src/core/utils/git.ts`, pattern `^[a-fA-F0-9]{7,64}$`, the same validation as the server. ## API Contract (app-scoped, via `getAppClient()`) @@ -28,7 +28,7 @@ Content types are deliberately **not** derived client-side: the server decides e ## The Static Lane (experimental, env-gated) -Two switches, in order. **`BASE44_STATIC_DEPLOYMENTS=1`** (or `true`) is the release gate, read by `staticDeploymentsEnabled()` in `gate.ts` and consulted in exactly one place — `getSiteDeployCommand()`, which registers `--git-hash` only when it is set. **`--git-hash `** is then the runtime switch: passing it deploys through the deployments API, omitting it takes the legacy tar.gz upload. So with the gate off the flag does not exist and the lane is unreachable; with it on, the flow is chosen per invocation. +Two switches, in order. **`BASE44_STATIC_DEPLOYMENTS=1`** (or `true`) is the release gate, read by `staticDeploymentsEnabled()` and consulted in exactly one place — `getSiteDeployCommand()`, which registers `--git-hash` only when it is set. **`--git-hash `** is then the runtime switch: passing it deploys through the deployments API, omitting it takes the legacy tar.gz upload. So with the gate off the flag does not exist and the lane is unreachable; with it on, the flow is chosen per invocation. On the lane, the output directory becomes the asset manifest (index.html included — it is only ever excluded from uploads), 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. `--json` emits `{deploymentId, gitHash}`. diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index 34184fca9..e00c45b64 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -12,7 +12,6 @@ import { deploySite, deployStaticSite, MAX_UPLOAD_CONCURRENCY, - staticDeploymentsEnabled, } from "@/core/site/index.js"; import { isGitCommitHash } from "@/core/utils/git.js"; @@ -175,3 +174,10 @@ 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"; +} diff --git a/packages/cli/src/core/site/gate.ts b/packages/cli/src/core/site/gate.ts deleted file mode 100644 index 67f3c1267..000000000 --- a/packages/cli/src/core/site/gate.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Internal gate for the experimental static-site deployments-API lane, not - * user-facing yet. It decides whether `--git-hash` is registered at all; the - * flag being passed is what routes a deploy through the deployments API. - */ -const STATIC_DEPLOYMENTS_ENV = "BASE44_STATIC_DEPLOYMENTS"; - -export function staticDeploymentsEnabled( - env: NodeJS.ProcessEnv = process.env, -): boolean { - const value = env[STATIC_DEPLOYMENTS_ENV]; - return value === "1" || value === "true"; -} diff --git a/packages/cli/src/core/site/index.ts b/packages/cli/src/core/site/index.ts index 91bffc223..523f1faf8 100644 --- a/packages/cli/src/core/site/index.ts +++ b/packages/cli/src/core/site/index.ts @@ -1,7 +1,6 @@ export * from "./api.js"; export * from "./config.js"; export * from "./deploy.js"; -export * from "./gate.js"; export * from "./manifest.js"; export * from "./schema.js"; export * from "./static-site.js"; diff --git a/packages/cli/src/core/site/upload.ts b/packages/cli/src/core/site/upload.ts index 1b9e2f04d..e9ccbaa25 100644 --- a/packages/cli/src/core/site/upload.ts +++ b/packages/cli/src/core/site/upload.ts @@ -1,5 +1,4 @@ import { readFile } from "node:fs/promises"; -import { setTimeout as sleep } from "node:timers/promises"; import ky from "ky"; import pMap from "p-map"; import { ApiError, InternalError } from "@/core/errors.js"; @@ -14,7 +13,7 @@ export const DEFAULT_UPLOAD_CONCURRENCY = 3; /** Each worker holds a whole file in memory, so the ceiling is a memory bound. */ export const MAX_UPLOAD_CONCURRENCY = 50; -const MAX_ATTEMPTS_PER_UPLOAD = 3; +const MAX_UPLOAD_ATTEMPTS = 3; const RETRY_BASE_DELAY_MS = 500; /** @@ -36,7 +35,7 @@ export async function uploadPresignedAssets( await pMap( uploads, async (upload) => { - await uploadPresignedAssetWithRetry(upload, assets); + await uploadPresignedAsset(upload, assets); uploadedFiles++; onProgress?.({ uploadedFiles, totalFiles: uploads.length }); }, @@ -44,7 +43,7 @@ export async function uploadPresignedAssets( ); } -async function uploadPresignedAssetWithRetry( +async function uploadPresignedAsset( upload: PresignedAssetUpload, assets: AssetManifestResult, ): Promise { @@ -57,24 +56,21 @@ async function uploadPresignedAssetWithRetry( } const content = await readFile(file.absolutePath); - let lastError: unknown; - for (let attempt = 0; attempt < MAX_ATTEMPTS_PER_UPLOAD; attempt++) { - if (attempt > 0) { - await sleep(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1)); - } - try { - await ky.put(upload.url, { - body: new Uint8Array(content), - // The server signed this exact Content-Type into the URL — deriving - // our own value would 403 on any mapping difference. - headers: { "Content-Type": upload.contentType }, - timeout: 120_000, - retry: 0, - }); - return; - } catch (error) { - lastError = error; - } + try { + await ky.put(upload.url, { + body: new Uint8Array(content), + // The server signed this exact Content-Type into the URL — deriving + // our own value would 403 on any mapping difference. + headers: { "Content-Type": upload.contentType }, + timeout: 120_000, + // ky retries network errors and 408/429/5xx only, so a 403 from an + // expired URL fails fast instead of burning every attempt. + retry: { + limit: MAX_UPLOAD_ATTEMPTS - 1, + delay: (attempt) => RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), + }, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "uploading static assets"); } - throw await ApiError.fromHttpError(lastError, "uploading static assets"); } From 9742915b5d2ab08661f07092776f7dceb3b3c6d5 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 4 Aug 2026 08:35:26 +0300 Subject: [PATCH 12/12] fix --- packages/cli/src/cli/commands/site/deploy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index e00c45b64..eecdda568 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -178,6 +178,6 @@ function parseConcurrency(value: string): number { function staticDeploymentsEnabled( env: NodeJS.ProcessEnv = process.env, ): boolean { - const value = env["BASE44_STATIC_DEPLOYMENTS"]; + const value = env.BASE44_STATIC_DEPLOYMENTS; return value === "1" || value === "true"; }