From f66a28f5913b44dba72c0bc7495a0f379f865b7f Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Mon, 14 Sep 2026 15:38:59 +0300 Subject: [PATCH 01/21] feat: publish an app as a recorded version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `base44 publish` — build, record the result as a version, serve that version — over the platform's new version endpoints, plus the two halves as commands of their own: base44 build run the build and print its artifact set base44 version create record built output, without deploying base44 version deploy serve a recorded version (also a rollback) base44 publish all three, in order Not `site deploy`. That command drives the legacy full-stack hosting lane, whose own source says nothing there publishes, and whose `deploymentId` means a Cloudflare script. A caller that could not tell them apart would publish by accident, so this is a separate command with separate envelope field names — `versionId`, `manifestHash`, `deploymentId`, `revision`. A file is named by a FULL sha256 over its bytes. That is artifact identity, and the platform signs it into the upload URL so S3 refuses any other body. It is not `hashAsset`, a 32-hex truncation of sha256(app id ‖ bytes) that keys a provider's asset cache — conflating them gives a file that uploads fine and never dedupes, or a digest check that fails on a correct file. Entity and agent payloads go up raw. The platform's extractor and validation are authoritative, and the CLI's own entity schema refuses real Builder apps — which is exactly why `site deploy` reads no resources at all. `uploadPresignedAssets` is shared with the static lane; it now sends the server's `x-amz-checksum-sha256` when the URL carries one. Uploads default to 8 in parallel: measured on the sandbox's pipe, 3 needed ~930s of the ~450s a build leaves, and 16 failed a degraded pipe. A Builder repo carries no CLI config, so one is defaulted — without writing anything. Only a WHOLLY absent config is defaulted; a config that omits a field said so deliberately and still gets today's error. The failing step travels out through the `--json` envelope as `step`. A user's build failing, a rejected artifact set and a lost publication race are three incidents with three responses, and one exit code for all of them is how a sandbox log stops being diagnostic. Co-Authored-By: Claude Opus 5 (1M context) --- .../cli/src/cli/commands/project/build.ts | 22 +-- packages/cli/src/cli/commands/publish.ts | 141 ++++++++++++++ .../cli/src/cli/commands/version/create.ts | 105 +++++++++++ .../cli/src/cli/commands/version/deploy.ts | 44 +++++ .../cli/src/cli/commands/version/index.ts | 10 + packages/cli/src/cli/program.ts | 4 + .../src/cli/utils/command/Base44Command.ts | 7 + packages/cli/src/core/site/git-hash.ts | 19 ++ packages/cli/src/core/site/schema.ts | 7 + packages/cli/src/core/site/upload.ts | 14 +- packages/cli/src/core/version/api.ts | 158 ++++++++++++++++ packages/cli/src/core/version/artifacts.ts | 140 ++++++++++++++ packages/cli/src/core/version/index.ts | 5 + packages/cli/src/core/version/project.ts | 108 +++++++++++ packages/cli/src/core/version/publish.ts | 83 +++++++++ packages/cli/src/core/version/schema.ts | 89 +++++++++ packages/cli/tests/cli/publish.spec.ts | 175 ++++++++++++++++++ .../cli/tests/cli/testkit/TestAPIServer.ts | 78 ++++++++ .../cli/tests/core/version-artifacts.spec.ts | 144 ++++++++++++++ .../cli/tests/core/version-project.spec.ts | 121 ++++++++++++ .../cli/tests/core/version-publish.spec.ts | 52 ++++++ .../publishable/base44/agents/helper.jsonc | 1 + .../fixtures/publishable/base44/config.jsonc | 7 + .../publishable/base44/entities/Todo.jsonc | 8 + .../publishable/site-output/assets/app.js | 1 + .../publishable/site-output/index.html | 1 + 26 files changed, 1528 insertions(+), 16 deletions(-) create mode 100644 packages/cli/src/cli/commands/publish.ts create mode 100644 packages/cli/src/cli/commands/version/create.ts create mode 100644 packages/cli/src/cli/commands/version/deploy.ts create mode 100644 packages/cli/src/cli/commands/version/index.ts create mode 100644 packages/cli/src/core/version/api.ts create mode 100644 packages/cli/src/core/version/artifacts.ts create mode 100644 packages/cli/src/core/version/index.ts create mode 100644 packages/cli/src/core/version/project.ts create mode 100644 packages/cli/src/core/version/publish.ts create mode 100644 packages/cli/src/core/version/schema.ts create mode 100644 packages/cli/tests/cli/publish.spec.ts create mode 100644 packages/cli/tests/core/version-artifacts.spec.ts create mode 100644 packages/cli/tests/core/version-project.spec.ts create mode 100644 packages/cli/tests/core/version-publish.spec.ts create mode 100644 packages/cli/tests/fixtures/publishable/base44/agents/helper.jsonc create mode 100644 packages/cli/tests/fixtures/publishable/base44/config.jsonc create mode 100644 packages/cli/tests/fixtures/publishable/base44/entities/Todo.jsonc create mode 100644 packages/cli/tests/fixtures/publishable/site-output/assets/app.js create mode 100644 packages/cli/tests/fixtures/publishable/site-output/index.html diff --git a/packages/cli/src/cli/commands/project/build.ts b/packages/cli/src/cli/commands/project/build.ts index 747329ce..07bc9e3f 100644 --- a/packages/cli/src/cli/commands/project/build.ts +++ b/packages/cli/src/cli/commands/project/build.ts @@ -2,26 +2,24 @@ import type { Command } from "commander"; import { runSiteBuild } from "@/cli/commands/project/site-build.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command, theme } from "@/cli/utils/index.js"; -import { ConfigInvalidError } from "@/core/errors.js"; -import { readProjectConfig } from "@/core/project/index.js"; +import { resolvePublishTarget } from "@/core/version/index.js"; async function buildAction(ctx: CLIContext): Promise { const { app } = ctx; - if (!app?.projectRoot) { - throw new ConfigInvalidError( - "base44 build requires a linked local project. Run it from a project with base44/.app.jsonc.", - ); - } + // Not readProjectConfig: a Builder repo carries no CLI config at all, and this + // is the step a publish sandbox runs inside one. resolvePublishTarget fills in + // the missing defaults without writing a file; a config that is present still + // wins, and one that omits a build command still gets today's error. + const target = await resolvePublishTarget(app?.projectRoot); - const { project } = await readProjectConfig(app.projectRoot); await runSiteBuild(ctx, { - root: project.root, - buildCommand: project.site?.buildCommand, - appId: app.id, + root: target.root, + buildCommand: target.buildCommand, + appId: app?.id ?? "", }); return { - outroMessage: `Site built with app id ${theme.styles.bold(app.id)}`, + outroMessage: `Site built with app id ${theme.styles.bold(app?.id ?? "")}`, }; } diff --git a/packages/cli/src/cli/commands/publish.ts b/packages/cli/src/cli/commands/publish.ts new file mode 100644 index 00000000..0d29bd92 --- /dev/null +++ b/packages/cli/src/cli/commands/publish.ts @@ -0,0 +1,141 @@ +import type { Command } from "commander"; +import { InvalidArgumentError, Option } from "commander"; +import { runSiteBuild } from "@/cli/commands/project/site-build.js"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command, theme } from "@/cli/utils/index.js"; +import { resolveProvenanceCommit } from "@/core/site/index.js"; +import { isGitCommitHash } from "@/core/utils/git.js"; +import { + collectBuildOutput, + collectResources, + DEFAULT_VERSION_UPLOAD_CONCURRENCY, + MAX_VERSION_UPLOAD_CONCURRENCY, + publishVersion, + requireOutputDir, + resolvePublishTarget, + tagStep, +} from "@/core/version/index.js"; + +interface PublishOptions { + build?: boolean; + outputDir?: string; + gitHash?: string; + target?: string; + concurrency?: number; +} + +/** + * Build, record a version, and serve it — the three steps in order. + * + * NOT `site deploy`. That command drives the legacy full-stack hosting lane, + * whose own source says nothing there publishes; its envelope names a + * `deploymentId` that means a Cloudflare script, not a deployment on this plane. + * A caller that could not tell the two apart would publish by accident, so this + * is a separate command with separate envelope field names. + */ +async function publishAction( + ctx: CLIContext, + options: PublishOptions, +): Promise { + const { runTask, log, jsonMode, app } = ctx; + const target = await resolvePublishTarget(app?.projectRoot, { + outputDir: options.outputDir, + }); + + if (options.build !== false) { + await tagStep("build", () => + runSiteBuild(ctx, { + root: target.root, + buildCommand: target.buildCommand, + appId: app?.id ?? "", + }), + ); + } + + const outputDir = requireOutputDir(target); + const gitHash = await resolveProvenanceCommit(target.root, options.gitHash); + const result = await runTask( + "Publishing...", + async (updateMessage) => { + const artifacts = { + files: await collectBuildOutput(outputDir), + ...(await collectResources(target.configDir, target)), + }; + return await publishVersion(artifacts, { + sourceCommit: gitHash, + target: options.target, + concurrency: options.concurrency, + progress: { + onDeclared: ({ fileCount, owedFiles }) => + updateMessage(`Uploading ${owedFiles} of ${fileCount} files`), + onUpload: ({ uploadedFiles, totalFiles }) => + updateMessage(`Uploaded ${uploadedFiles} of ${totalFiles} files`), + }, + }); + }, + { successMessage: "Published", errorMessage: "Publish failed" }, + ); + + if (!jsonMode) { + log.message( + theme.styles.dim( + `version ${result.versionId}${result.deduplicated ? " (existing content)" : ""}`, + ), + ); + } + return { + outroMessage: `Deployment ${result.deploymentId} at revision ${result.revision}`, + stdout: jsonMode ? `${JSON.stringify(result, null, 2)}\n` : undefined, + }; +} + +export function getPublishCommand(): Command { + return new Base44Command("publish") + .description( + "Build the app, record it as a version, and serve that version", + ) + .option( + "--no-build", + "Publish the existing build output without rebuilding", + ) + .option( + "--output-dir ", + "Build output directory (defaults to the project's, else dist)", + ) + .option("--target ", "Environment to serve the version at") + .addOption( + new Option( + "--git-hash ", + "Commit the build came from (defaults to the checkout's HEAD)", + ).argParser(parseGitHash), + ) + .addOption( + new Option("--concurrency ", "Parallel file uploads") + .default(DEFAULT_VERSION_UPLOAD_CONCURRENCY) + .argParser(parseConcurrency), + ) + .action(publishAction); +} + +function parseGitHash(value: string): string { + if (!isGitCommitHash(value)) { + throw new InvalidArgumentError( + "Expected a git commit hash (7-64 hex chars).", + ); + } + return value; +} + +function parseConcurrency(value: string): number { + const parsed = Number(value); + if ( + !Number.isInteger(parsed) || + parsed < 1 || + parsed > MAX_VERSION_UPLOAD_CONCURRENCY + ) { + throw new InvalidArgumentError( + `Expected a whole number between 1 and ${MAX_VERSION_UPLOAD_CONCURRENCY}.`, + ); + } + return parsed; +} diff --git a/packages/cli/src/cli/commands/version/create.ts b/packages/cli/src/cli/commands/version/create.ts new file mode 100644 index 00000000..99642325 --- /dev/null +++ b/packages/cli/src/cli/commands/version/create.ts @@ -0,0 +1,105 @@ +import type { Command } from "commander"; +import { InvalidArgumentError, Option } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { resolveProvenanceCommit } from "@/core/site/index.js"; +import { isGitCommitHash } from "@/core/utils/git.js"; +import { + collectBuildOutput, + collectResources, + createVersion, + DEFAULT_VERSION_UPLOAD_CONCURRENCY, + MAX_VERSION_UPLOAD_CONCURRENCY, + requireOutputDir, + resolvePublishTarget, +} from "@/core/version/index.js"; + +interface CreateOptions { + outputDir?: string; + gitHash?: string; + concurrency?: number; +} + +/** + * Record a build that already exists. No build of its own and no deploy — + * a version is a blueprint, and one can sit unpublished for as long as it likes. + */ +async function createAction( + { runTask, jsonMode, app }: CLIContext, + options: CreateOptions, +): Promise { + const target = await resolvePublishTarget(app?.projectRoot, { + outputDir: options.outputDir, + }); + const gitHash = await resolveProvenanceCommit(target.root, options.gitHash); + + const version = await runTask( + "Creating version...", + async (updateMessage) => + await createVersion( + { + files: await collectBuildOutput(requireOutputDir(target)), + ...(await collectResources(target.configDir, target)), + }, + { + sourceCommit: gitHash, + concurrency: options.concurrency, + progress: { + onDeclared: ({ fileCount, owedFiles }) => + updateMessage(`Uploading ${owedFiles} of ${fileCount} files`), + onUpload: ({ uploadedFiles, totalFiles }) => + updateMessage(`Uploaded ${uploadedFiles} of ${totalFiles} files`), + }, + }, + ), + { + successMessage: "Version created", + errorMessage: "Create version failed", + }, + ); + + return { + outroMessage: `Version ${version.versionId} (${version.manifestHash})`, + stdout: jsonMode ? `${JSON.stringify(version, null, 2)}\n` : undefined, + }; +} + +export function getVersionCreateCommand(): Command { + return new Base44Command("create") + .description("Record the built output as a version, without deploying it") + .option( + "--output-dir ", + "Build output directory (defaults to the project's, else dist)", + ) + .addOption( + new Option( + "--git-hash ", + "Commit the build came from (defaults to the checkout's HEAD)", + ).argParser((value: string) => { + if (!isGitCommitHash(value)) { + throw new InvalidArgumentError( + "Expected a git commit hash (7-64 hex chars).", + ); + } + return value; + }), + ) + .addOption( + new Option("--concurrency ", "Parallel file uploads") + .default(DEFAULT_VERSION_UPLOAD_CONCURRENCY) + .argParser((value: string) => { + const parsed = Number(value); + if ( + !Number.isInteger(parsed) || + parsed < 1 || + parsed > MAX_VERSION_UPLOAD_CONCURRENCY + ) { + throw new InvalidArgumentError( + `Expected a whole number between 1 and ${MAX_VERSION_UPLOAD_CONCURRENCY}.`, + ); + } + return parsed; + }), + ) + .action(createAction); +} diff --git a/packages/cli/src/cli/commands/version/deploy.ts b/packages/cli/src/cli/commands/version/deploy.ts new file mode 100644 index 00000000..7267ad9a --- /dev/null +++ b/packages/cli/src/cli/commands/version/deploy.ts @@ -0,0 +1,44 @@ +import { randomUUID } from "node:crypto"; +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { deployVersion } from "@/core/version/index.js"; + +interface DeployOptions { + target?: string; +} + +/** + * Serve a version that already exists. No checkout, no build and no upload — + * which is also what a rollback is: the same call with an older version id. + */ +async function deployAction( + { runTask, jsonMode }: CLIContext, + versionId: string, + options: DeployOptions, +): Promise { + const deployment = await runTask( + `Deploying version ${versionId}...`, + async () => + await deployVersion(versionId, { + target: options.target, + idempotencyKey: randomUUID(), + }), + { successMessage: "Version deployed", errorMessage: "Deploy failed" }, + ); + + return { + outroMessage: `Deployment ${deployment.deploymentId} at revision ${deployment.revision}`, + stdout: jsonMode ? `${JSON.stringify(deployment, null, 2)}\n` : undefined, + }; +} + +export function getVersionDeployCommand(): Command { + return new Base44Command("deploy") + .description( + "Serve an already-recorded version (also how a rollback is done)", + ) + .argument("", "The version to serve") + .option("--target ", "Environment to serve the version at") + .action(deployAction); +} diff --git a/packages/cli/src/cli/commands/version/index.ts b/packages/cli/src/cli/commands/version/index.ts new file mode 100644 index 00000000..220f1733 --- /dev/null +++ b/packages/cli/src/cli/commands/version/index.ts @@ -0,0 +1,10 @@ +import { Command } from "commander"; +import { getVersionCreateCommand } from "./create.js"; +import { getVersionDeployCommand } from "./deploy.js"; + +export function getVersionCommand(): Command { + return new Command("version") + .description("Record app versions and serve them") + .addCommand(getVersionCreateCommand()) + .addCommand(getVersionDeployCommand()); +} diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 86ac1ed3..56146f87 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -17,10 +17,12 @@ import { getLinkCommand } from "@/cli/commands/project/link.js"; import { getLogsCommand } from "@/cli/commands/project/logs.js"; import { getScaffoldCommand } from "@/cli/commands/project/scaffold.js"; import { getVisibilityCommand } from "@/cli/commands/project/visibility.js"; +import { getPublishCommand } from "@/cli/commands/publish.js"; import { getSandboxCommand } from "@/cli/commands/sandbox/index.js"; import { getSecretsCommand } from "@/cli/commands/secrets/index.js"; import { getSiteCommand } from "@/cli/commands/site/index.js"; import { getTypesCommand } from "@/cli/commands/types/index.js"; +import { getVersionCommand } from "@/cli/commands/version/index.js"; import { getWorkflowsCommand } from "@/cli/commands/workflows/index.js"; import { getWorkspaceCommand } from "@/cli/commands/workspace/index.js"; import { Base44Command } from "@/cli/utils/index.js"; @@ -115,6 +117,8 @@ export function createProgram(context: CLIContext): Command { // Register site commands program.addCommand(getSiteCommand()); + program.addCommand(getPublishCommand()); + program.addCommand(getVersionCommand()); // Register types command program.addCommand(getTypesCommand()); diff --git a/packages/cli/src/cli/utils/command/Base44Command.ts b/packages/cli/src/cli/utils/command/Base44Command.ts index 5c99e93b..6d6eafd3 100644 --- a/packages/cli/src/cli/utils/command/Base44Command.ts +++ b/packages/cli/src/cli/utils/command/Base44Command.ts @@ -17,6 +17,7 @@ import { } from "@/cli/utils/upgradeNotification.js"; import { ApiError, InvalidInputError, isCLIError } from "@/core/errors.js"; import { resolveBranchName } from "@/core/resources/branch/api.js"; +import { stepOf } from "@/core/version/publish.js"; /** * Write a command result to stdout as a single JSON document (the `--json` @@ -44,6 +45,12 @@ function writeJsonError(error: unknown): void { const envelope: Record = { error: error instanceof Error ? error.message : String(error), }; + // Which step of a multi-step command broke. One exit code for all of them is + // how a sandbox log stops being diagnostic. + const step = stepOf(error); + if (step !== undefined) { + envelope.step = step; + } if (isCLIError(error)) { envelope.code = error.code; if (error.details.length > 0) { diff --git a/packages/cli/src/core/site/git-hash.ts b/packages/cli/src/core/site/git-hash.ts index 0e42e4de..84778c67 100644 --- a/packages/cli/src/core/site/git-hash.ts +++ b/packages/cli/src/core/site/git-hash.ts @@ -25,6 +25,25 @@ export async function resolveGitHash( return hash; } +/** + * The commit this build came from, or `null` when there is none. + * + * For a version the commit is PROVENANCE — recorded, never hashed, and not part + * of what the version is — so a build outside a checkout is still a complete + * version. That is the whole difference from {@link resolveGitHash}, whose + * caller addresses a deployment BY the hash and so cannot go without one. + */ +export async function resolveProvenanceCommit( + projectRoot: string, + explicit?: string, +): Promise { + if (explicit) { + return await resolveGitHash(projectRoot, explicit); + } + const hash = await gitHead(projectRoot); + return hash && isGitCommitHash(hash) ? hash : undefined; +} + async function gitHead(projectRoot: string): Promise { try { const { stdout } = await execa("git", ["rev-parse", "HEAD"], { diff --git a/packages/cli/src/core/site/schema.ts b/packages/cli/src/core/site/schema.ts index 97fd7d4d..713a5760 100644 --- a/packages/cli/src/core/site/schema.ts +++ b/packages/cli/src/core/site/schema.ts @@ -84,6 +84,13 @@ export interface PresignedAssetUpload { contentLength: number; /** Presigned S3 URL — the URL itself is the credential. */ url: string; + /** + * Base64 sha256 the server signed in, when it signed one. Sent as + * `x-amz-checksum-sha256`, which is what makes S3 itself reject a body that + * is not the declared one. Absent on the legacy static lane, whose URLs pin + * only type and length. + */ + checksumSha256?: string; } /** diff --git a/packages/cli/src/core/site/upload.ts b/packages/cli/src/core/site/upload.ts index cdc266a3..0012b634 100644 --- a/packages/cli/src/core/site/upload.ts +++ b/packages/cli/src/core/site/upload.ts @@ -185,7 +185,7 @@ async function buildBucketForm( * carries its own authorization in the query string, so each request is a plain * fetch — never the app client, never an Authorization header. */ -async function uploadPresignedAssets( +export async function uploadPresignedAssets( uploads: PresignedAssetUpload[], assets: AssetManifestResult, options: { @@ -223,9 +223,15 @@ async function uploadPresignedAsset( 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 }, + headers: { + // The server signed these exact values into the URL — deriving our own + // would 403 on any mapping difference, and the checksum is what makes + // S3 refuse a body other than the one that was declared. + "Content-Type": upload.contentType, + ...(upload.checksumSha256 + ? { "x-amz-checksum-sha256": upload.checksumSha256 } + : {}), + }, timeout: 120_000, retry: UPLOAD_RETRY, }); diff --git a/packages/cli/src/core/version/api.ts b/packages/cli/src/core/version/api.ts new file mode 100644 index 00000000..b8a32900 --- /dev/null +++ b/packages/cli/src/core/version/api.ts @@ -0,0 +1,158 @@ +import type { KyResponse } from "ky"; +import type { ZodType } from "zod"; +import { getAppClient } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import { uploadPresignedAssets } from "@/core/site/upload.js"; +import type { + ArtifactSet, + CreateVersionProgress, + CreateVersionResponse, + DeployVersionResponse, +} from "@/core/version/schema.js"; +import { + CreateVersionResponseSchema, + DeclareVersionResponseSchema, + DeployVersionResponseSchema, +} from "@/core/version/schema.js"; + +/** + * Measured on the sandbox's pipe: a 25.5k-asset app moved 27 assets/s at 3, + * needing ~930s of the ~450s a build leaves — SIGKILLed mid-upload every time. + * 8 is the rate the python driver it replaced already sustained to the same + * bucket, and 16 failed a degraded pipe on 2026-07-02. + */ +export const DEFAULT_VERSION_UPLOAD_CONCURRENCY = 8; +export const MAX_VERSION_UPLOAD_CONCURRENCY = 16; + +async function post( + path: string, + json: unknown, + doing: string, +): Promise { + try { + return await getAppClient().post(path, { json, timeout: 180_000 }); + } catch (error) { + throw await ApiError.fromHttpError(error, doing); + } +} + +function parse(schema: ZodType, body: unknown, what: string): T { + const result = schema.safeParse(body); + if (!result.success) { + throw new SchemaValidationError( + `Invalid ${what} response from server`, + result.error, + ); + } + return result.data; +} + +/** + * Declare the artifact set, upload what it names, and commit the version. + * + * Three calls, in that order and no other: nothing is recorded until the bytes + * are in place, so an interrupted run leaves staged objects that expire rather + * than a version naming files that are not there. + */ +export async function createVersion( + artifacts: ArtifactSet, + options: { + sourceCommit?: string; + concurrency?: number; + progress?: CreateVersionProgress; + } = {}, +): Promise { + const declared = parse( + DeclareVersionResponseSchema, + await ( + await post( + "versions", + { + static_bundle: artifacts.files.map(({ path, size, digest }) => ({ + path, + size, + digest, + })), + entities: artifacts.entities, + agents: artifacts.agents, + source_commit: options.sourceCommit, + frontend_commit: options.sourceCommit, + }, + "declaring a version", + ) + ).json(), + "declare", + ); + + options.progress?.onDeclared?.({ + fileCount: artifacts.files.length, + owedFiles: declared.uploads.length, + }); + + await uploadPresignedAssets( + declared.uploads, + { + manifest: Object.fromEntries( + artifacts.files.map((file) => [ + file.path, + { hash: file.digest, size: file.size }, + ]), + ), + filesByHash: new Map( + artifacts.files.map((file) => [ + file.digest, + { + absolutePath: file.absolutePath, + hash: file.digest, + size: file.size, + // Signed into the URL by the server and echoed back on the upload, + // so this value is never the one the PUT sends. + contentType: "application/octet-stream", + }, + ]), + ), + }, + { + concurrency: options.concurrency ?? DEFAULT_VERSION_UPLOAD_CONCURRENCY, + onProgress: options.progress?.onUpload, + }, + ); + + return parse( + CreateVersionResponseSchema, + await ( + await post( + `versions/${encodeURIComponent(declared.sessionId)}/finalize`, + {}, + "creating a version", + ) + ).json(), + "create version", + ); +} + +/** + * Serve a recorded version. One POST, and the body carries a target name and a + * retry key — everything else is the platform's to resolve. + */ +export async function deployVersion( + versionId: string, + options: { target?: string; idempotencyKey?: string } = {}, +): Promise { + return parse( + DeployVersionResponseSchema, + await ( + await post( + `versions/${encodeURIComponent(versionId)}/deployments`, + { + ...(options.target ? { target: options.target } : {}), + ...(options.idempotencyKey + ? { idempotency_key: options.idempotencyKey } + : {}), + }, + "deploying a version", + ) + ).json(), + "deploy", + ); +} diff --git a/packages/cli/src/core/version/artifacts.ts b/packages/cli/src/core/version/artifacts.ts new file mode 100644 index 00000000..8a2d08ee --- /dev/null +++ b/packages/cli/src/core/version/artifacts.ts @@ -0,0 +1,140 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { globby } from "globby"; +import { CONFIG_FILE_EXTENSION_GLOB } from "@/core/consts.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { pathExists, readJsonFile } from "@/core/utils/fs.js"; +import type { ArtifactFile, ArtifactSet } from "@/core/version/schema.js"; + +/** The same ceiling the site collector applies; one build, one limit. */ +const MAX_FILE_COUNT = 100_000; + +const ASSETS_IGNORE_FILE = ".assetsignore"; + +/** Never part of a frontend, whatever `.assetsignore` says. */ +const ALWAYS_IGNORED = new Set([ + ASSETS_IGNORE_FILE, + "wrangler.json", + ".dev.vars", +]); + +/** Every request for an extensionless path is served this, so a set without it + * is a frontend nothing can enter. The platform refuses one; saying so here + * costs the user an upload rather than a round trip. */ +const ENTRY = "index.html"; + +/** + * Full sha256 over a file's bytes — artifact identity. + * + * Streamed, so a large file is never read whole into memory. Deliberately NOT + * `hashAsset`: see the note on {@link ArtifactFile.digest}. + */ +async function digestFile(absolutePath: string): Promise { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(absolutePath)) { + hash.update(chunk); + } + return `sha256:${hash.digest("hex")}`; +} + +/** + * Walk a build's output directory and describe every file in it. + * + * Honors `.assetsignore` at the output root with full gitignore semantics, + * negation included — the same rules the site collector walks by, so the two + * lanes cannot disagree about what a build produced. + */ +export async function collectBuildOutput( + outputDir: string, +): Promise { + // globby returns forward-slash paths on every platform, which is how the + // version 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: outputDir, + dot: true, + onlyFiles: true, + followSymbolicLinks: false, + ignoreFiles: [ASSETS_IGNORE_FILE], + }); + const relativePaths = found + .filter((path) => !ALWAYS_IGNORED.has(basename(path))) + .sort(); + + if (relativePaths.length === 0) { + throw new InvalidInputError( + `No files found in ${outputDir}. Build the site before creating a version.`, + { + hints: [ + { message: "Run 'base44 build' first", command: "base44 build" }, + ], + }, + ); + } + if (relativePaths.length > MAX_FILE_COUNT) { + throw new InvalidInputError( + `Too many files: found ${relativePaths.length}, the limit is ${MAX_FILE_COUNT}.`, + ); + } + if (!relativePaths.includes(ENTRY)) { + throw new InvalidInputError( + `${outputDir} has no ${ENTRY}, so nothing could enter the site.`, + ); + } + + return await Promise.all( + relativePaths.map(async (path) => { + const absolutePath = join(outputDir, ...path.split("/")); + const { size } = await stat(absolutePath); + return { + path, + absolutePath, + size, + digest: await digestFile(absolutePath), + }; + }), + ); +} + +/** + * The app's declared entities and agents, RAW. + * + * Deliberately not the validated resource readers: the platform's own extractor + * and validation are authoritative, and the CLI's stricter entity schema would + * refuse real Builder apps. That is exactly why `site deploy` reads no resources + * at all — re-introducing the strict parse here would reproduce the block. + * + * The key is the file's path under its directory with the schema extension + * stripped, which is the name the platform derives from the same file. A nested + * agent keeps its subpath, so `agents/support/triage.jsonc` is `support/triage`. + */ +async function readRawResources(dir: string): Promise> { + if (!(await pathExists(dir))) { + return {}; + } + const files = await globby(`**/*.${CONFIG_FILE_EXTENSION_GLOB}`, { + cwd: dir, + onlyFiles: true, + followSymbolicLinks: false, + }); + const payloads: Record = {}; + for (const relativePath of files.sort()) { + const name = relativePath.replace(/\.jsonc?$/, ""); + payloads[name] = await readJsonFile(join(dir, ...relativePath.split("/"))); + } + return payloads; +} + +export async function collectResources( + configDir: string, + dirs: { entitiesDir: string; agentsDir: string }, +): Promise> { + const [entities, agents] = await Promise.all([ + readRawResources(join(configDir, dirs.entitiesDir)), + readRawResources(join(configDir, dirs.agentsDir)), + ]); + return { entities, agents }; +} diff --git a/packages/cli/src/core/version/index.ts b/packages/cli/src/core/version/index.ts new file mode 100644 index 00000000..dd3356d7 --- /dev/null +++ b/packages/cli/src/core/version/index.ts @@ -0,0 +1,5 @@ +export * from "./api.js"; +export * from "./artifacts.js"; +export * from "./project.js"; +export * from "./publish.js"; +export * from "./schema.js"; diff --git a/packages/cli/src/core/version/project.ts b/packages/cli/src/core/version/project.ts new file mode 100644 index 00000000..2fcd88e6 --- /dev/null +++ b/packages/cli/src/core/version/project.ts @@ -0,0 +1,108 @@ +import { dirname, join, resolve } from "node:path"; +import { PROJECT_SUBDIR } from "@/core/consts.js"; +import { ConfigNotFoundError } from "@/core/errors.js"; +import { readProjectSettings } from "@/core/project/index.js"; +import type { ProjectWithPaths } from "@/core/project/types.js"; + +/** + * What a Builder repo builds like when nobody wrote a CLI config for it. Both + * come from the app template every Builder app is seeded from. + */ +const DEFAULT_BUILD_COMMAND = "npm run build"; +const DEFAULT_OUTPUT_DIRECTORY = "dist"; + +export interface PublishTarget { + root: string; + /** Where `entitiesDir` and `agentsDir` are resolved from. */ + configDir: string; + /** `undefined` when a config is present and declares none — `runSiteBuild` + * reports that, as it always has. */ + buildCommand?: string; + /** `null` for the same reason, resolved by {@link requireOutputDir} at the + * point of collection — so a project missing both is told about its build + * command first, which is the one it hits first. */ + outputDir: string | null; + entitiesDir: string; + agentsDir: string; +} + +/** + * Resolve where to build and what to publish, filling in what a Builder repo + * does not carry. + * + * Builder repos have NO CLI project config, which is why the sandbox used to + * overwrite `base44/config.jsonc` with a minimal one — destroying any + * checked-in configuration, and for a full-stack app destroying its build + * command. Nothing here writes a file. + * + * The defaults apply only when there is no config AT ALL. A config that is + * present and omits a field said so deliberately, and answering that with a + * guessed `npm run build` would silently change what `base44 build` does for + * every project that already relies on the error. + */ +export async function resolvePublishTarget( + projectRoot: string | undefined, + overrides: { outputDir?: string } = {}, +): Promise { + const project = await readSettingsIfPresent(projectRoot); + const root = project?.root ?? projectRoot ?? process.cwd(); + + return { + root, + configDir: project + ? dirname(project.configPath) + : join(root, PROJECT_SUBDIR), + buildCommand: project ? project.site?.buildCommand : DEFAULT_BUILD_COMMAND, + outputDir: outputDirectory(project, root, overrides.outputDir), + entitiesDir: project?.entitiesDir ?? "entities", + agentsDir: project?.agentsDir ?? "agents", + }; +} + +function outputDirectory( + project: ProjectWithPaths | null, + root: string, + override: string | undefined, +): string | null { + const configured = + override ?? + (project ? project.site?.outputDirectory : DEFAULT_OUTPUT_DIRECTORY); + return configured ? resolve(root, configured) : null; +} + +/** The directory to collect a build from, or the error saying the project never + * named one. */ +export function requireOutputDir(target: PublishTarget): string { + if (target.outputDir === null) { + throw new ConfigNotFoundError("No site configuration found.", { + hints: [ + { + message: + 'Add \'site.outputDirectory\' to your config.jsonc (e.g., "site": { "outputDirectory": "dist" })', + }, + { message: `Or pass --output-dir , relative to ${target.root}` }, + ], + }); + } + return target.outputDir; +} + +/** + * The project's own settings, or `null` when it has none. + * + * Only a MISSING config is answered with `null`: a config that is present and + * invalid still throws, because publishing past a broken one is how a typo + * becomes a version built the wrong way. + */ +async function readSettingsIfPresent( + projectRoot?: string, +): Promise { + try { + return await readProjectSettings(projectRoot); + } catch (error) { + if (error instanceof ConfigNotFoundError) { + return null; + } + throw error; + } +} diff --git a/packages/cli/src/core/version/publish.ts b/packages/cli/src/core/version/publish.ts new file mode 100644 index 00000000..fc62e582 --- /dev/null +++ b/packages/cli/src/core/version/publish.ts @@ -0,0 +1,83 @@ +import { randomUUID } from "node:crypto"; +import { createVersion, deployVersion } from "@/core/version/api.js"; +import type { + ArtifactSet, + CreateVersionProgress, +} from "@/core/version/schema.js"; + +/** + * Which of the three steps a publish broke in. + * + * A user's build failing, an artifact set the platform refused and a lost + * publication race are three different incidents with three different + * responses. Collapsing them into one exit code is how a sandbox log stops + * being diagnostic, so the step travels with the failure and out through the + * `--json` envelope. + */ +export type PublishStep = "build" | "create_version" | "deploy"; + +const STEP = Symbol.for("base44.publishStep"); + +/** Tag an error with the step it broke in, without wrapping it — the original + * type, message, status and request id all still reach the envelope. */ +export async function tagStep( + step: PublishStep, + run: () => Promise, +): Promise { + try { + // Awaited inside the try so a callback that throws SYNCHRONOUSLY is tagged + // too — `run().catch(...)` would let that one escape untagged. + return await run(); + } catch (error) { + if (error !== null && typeof error === "object" && !(STEP in error)) { + Object.defineProperty(error, STEP, { value: step, enumerable: false }); + } + throw error; + } +} + +export function stepOf(error: unknown): PublishStep | undefined { + return error !== null && typeof error === "object" && STEP in error + ? (error as Record)[STEP] + : undefined; +} + +export interface PublishResult { + versionId: string; + manifestHash: string; + deduplicated: boolean; + deploymentId: string; + revision: number; +} + +/** + * Record the artifact set as a version and serve it. + * + * The deploy carries a key generated once here, so a lost response is resolved + * by reading back the deployment this call already made rather than preparing a + * second candidate. + */ +export async function publishVersion( + artifacts: ArtifactSet, + options: { + sourceCommit?: string; + target?: string; + concurrency?: number; + progress?: CreateVersionProgress; + } = {}, +): Promise { + const version = await tagStep("create_version", () => + createVersion(artifacts, { + sourceCommit: options.sourceCommit, + concurrency: options.concurrency, + progress: options.progress, + }), + ); + const deployment = await tagStep("deploy", () => + deployVersion(version.versionId, { + target: options.target, + idempotencyKey: randomUUID(), + }), + ); + return { ...version, ...deployment }; +} diff --git a/packages/cli/src/core/version/schema.ts b/packages/cli/src/core/version/schema.ts new file mode 100644 index 00000000..0b072714 --- /dev/null +++ b/packages/cli/src/core/version/schema.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; + +/** + * A file the build produced, as the version plane names it. + * + * `digest` is a FULL sha256 over the file's bytes — artifact identity, and what + * the platform signs into the upload URL so S3 refuses any other body. It is not + * {@link import("@/core/site/manifest.js").hashAsset}, which is a 32-hex + * truncation of sha256(app id ‖ bytes) and exists to key a provider's asset + * cache. Different purpose, different moment, different value: conflating them + * produces a file that uploads fine and never dedupes, or a digest check that + * fails on a correct file. + */ +export interface ArtifactFile { + /** Build-relative, forward slashes, no leading "/". */ + path: string; + absolutePath: string; + size: number; + digest: string; +} + +/** Everything one build produced, as the create-version call describes it. */ +export interface ArtifactSet { + files: ArtifactFile[]; + /** Raw payloads by name. The server normalizes and hashes them. */ + entities: Record; + agents: Record; +} + +export interface CreateVersionProgress { + onDeclared?: (info: { fileCount: number; owedFiles: number }) => void; + onUpload?: (progress: { uploadedFiles: number; totalFiles: number }) => void; +} + +export const DeclareVersionResponseSchema = z + .object({ + session_id: z.string(), + uploads: z.array( + z.object({ + path: z.string(), + url: z.string(), + content_type: z.string(), + content_length: z.number(), + checksum_sha256: z.string(), + }), + ), + }) + .transform((data) => ({ + sessionId: data.session_id, + uploads: data.uploads.map((upload) => ({ + path: upload.path, + url: upload.url, + contentType: upload.content_type, + contentLength: upload.content_length, + checksumSha256: upload.checksum_sha256, + })), + })); + +export type DeclareVersionResponse = z.infer< + typeof DeclareVersionResponseSchema +>; + +export const CreateVersionResponseSchema = z + .object({ + version_id: z.string(), + manifest_hash: z.string(), + deduplicated: z.boolean(), + }) + .transform((data) => ({ + versionId: data.version_id, + manifestHash: data.manifest_hash, + deduplicated: data.deduplicated, + })); + +export type CreateVersionResponse = z.infer; + +export const DeployVersionResponseSchema = z + .object({ + deployment_id: z.string(), + manifest_hash: z.string(), + revision: z.number(), + }) + .transform((data) => ({ + deploymentId: data.deployment_id, + manifestHash: data.manifest_hash, + revision: data.revision, + })); + +export type DeployVersionResponse = z.infer; diff --git a/packages/cli/tests/cli/publish.spec.ts b/packages/cli/tests/cli/publish.spec.ts new file mode 100644 index 00000000..4e951131 --- /dev/null +++ b/packages/cli/tests/cli/publish.spec.ts @@ -0,0 +1,175 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +const SESSION = "sess-1"; +const INDEX = "
"; +const APP_JS = "console.log(1)"; + +function sha256(content: string): string { + return `sha256:${createHash("sha256").update(content).digest("hex")}`; +} + +describe("publish command", () => { + const t = setupCLITests(); + + const mockPublishApi = () => { + t.api + .mockVersionDeclare(SESSION) + .mockPresignedUpload("/index.html") + .mockPresignedUpload("/assets/app.js") + .mockVersionFinalize({ + version_id: "ver-1", + manifest_hash: "sha256:abc", + deduplicated: false, + }) + .mockVersionDeploy({ + deployment_id: "dep-1", + manifest_hash: "sha256:abc", + revision: 4, + }); + }; + + it("declares every built file by a full sha256 over its bytes", async () => { + await t.givenLoggedInWithProject(fixture("publishable")); + mockPublishApi(); + + const result = await t.run("publish", "--no-build"); + + t.expectResult(result).toSucceed(); + expect(t.api.versionDeclareRequests[0]).toMatchObject({ + static_bundle: [ + { path: "assets/app.js", size: APP_JS.length, digest: sha256(APP_JS) }, + { path: "index.html", size: INDEX.length, digest: sha256(INDEX) }, + ], + }); + }); + + it("sends the app's resources raw, keyed the way the platform names them", async () => { + await t.givenLoggedInWithProject(fixture("publishable")); + mockPublishApi(); + + const result = await t.run("publish", "--no-build"); + + t.expectResult(result).toSucceed(); + expect(t.api.versionDeclareRequests[0]).toMatchObject({ + entities: { + Todo: { + name: "Todo", + type: "object", + properties: { title: { type: "string" } }, + // Passed through untouched: the platform's validation is + // authoritative, and the CLI's own entity schema would refuse this. + unknown_builder_field: true, + }, + }, + agents: { helper: { name: "helper", instructions: "help" } }, + }); + }); + + it("uploads every declared file with the checksum the server signed in", async () => { + await t.givenLoggedInWithProject(fixture("publishable")); + mockPublishApi(); + + const result = await t.run("publish", "--no-build"); + + t.expectResult(result).toSucceed(); + const uploaded = t.api.presignedUploadRequests; + expect(uploaded.map((u) => u.path).sort()).toEqual([ + "/assets/app.js", + "/index.html", + ]); + expect( + uploaded.find((u) => u.path === "/index.html")?.data.toString(), + ).toBe(INDEX); + }); + + it("carries only a target name and a retry key into the deploy", async () => { + // Everything else — the app, the principal, env vars, the revision — is the + // platform's to resolve, and there is deliberately no field for any of them. + await t.givenLoggedInWithProject(fixture("publishable")); + mockPublishApi(); + + const result = await t.run("publish", "--no-build"); + + t.expectResult(result).toSucceed(); + expect(t.api.versionDeployIds).toEqual(["ver-1"]); + expect(Object.keys(t.api.versionDeployRequests[0] as object)).toEqual([ + "idempotency_key", + ]); + }); + + it("emits both references in the --json envelope", async () => { + // Its own field names: `site deploy`'s `deploymentId` means a Cloudflare + // script on the legacy lane, and a caller that could not tell the two apart + // would publish by accident. + await t.givenLoggedInWithProject(fixture("publishable")); + mockPublishApi(); + + const result = await t.run("publish", "--no-build", "--json"); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual({ + versionId: "ver-1", + manifestHash: "sha256:abc", + deduplicated: false, + deploymentId: "dep-1", + revision: 4, + }); + }); + + it("names the step that failed", async () => { + // A user's build failing, a rejected artifact set and a lost publication + // race are three incidents with three responses. + await t.givenLoggedInWithProject(fixture("publishable")); + t.api.mockVersionDeclareError({ + status: 409, + body: { message: "this app declares 3 backend functions" }, + }); + + const result = await t.run("publish", "--no-build", "--json"); + + t.expectResult(result).toFail(); + expect(JSON.parse(result.stdout)).toMatchObject({ + step: "create_version", + statusCode: 409, + }); + }); + + it("builds first unless told not to", async () => { + await t.givenLoggedInWithProject(fixture("publishable")); + mockPublishApi(); + + const result = await t.run("publish"); + + t.expectResult(result).toSucceed(); + expect(await t.readProjectFile("build-env.txt")).toBe( + `BUILD_APP=${t.api.appId}`, + ); + }); +}); + +describe("version deploy command", () => { + const t = setupCLITests(); + + it("serves an existing version with no build and no upload", async () => { + // Which is also what a rollback is: the same call with an older version id. + await t.givenLoggedInWithProject(fixture("publishable")); + t.api.mockVersionDeploy({ + deployment_id: "dep-9", + manifest_hash: "sha256:old", + revision: 12, + }); + + const result = await t.run("version", "deploy", "ver-old", "--json"); + + t.expectResult(result).toSucceed(); + expect(t.api.versionDeployIds).toEqual(["ver-old"]); + expect(t.api.presignedUploadRequests).toEqual([]); + expect(JSON.parse(result.stdout)).toEqual({ + deploymentId: "dep-9", + manifestHash: "sha256:old", + revision: 12, + }); + }); +}); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 0fc704cc..f326217f 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -848,6 +848,84 @@ export class TestAPIServer { return this; } + // ─── VERSION ENDPOINTS ──────────────────────────────────── + + /** Captured JSON bodies of POST versions (declare) requests. */ + readonly versionDeclareRequests: unknown[] = []; + /** Captured JSON bodies of POST versions/{id}/deployments requests. */ + readonly versionDeployRequests: unknown[] = []; + /** Captured version ids the deploy call addressed. */ + readonly versionDeployIds: string[] = []; + + /** + * Mock POST /api/apps/{appId}/versions. `uploads` is built from the declared + * files, pointed at this server's own presigned-style PUT targets, so a test + * exercises the real declare -> upload -> finalize order. + */ + mockVersionDeclare(sessionId: string): this { + this.pendingRoutes.push({ + method: "POST", + path: `/api/apps/${this.appId}/versions`, + handler: (req, res) => { + const body = req.body as { + static_bundle: Array<{ path: string; size: number; digest: string }>; + }; + this.versionDeclareRequests.push(body); + res.status(200).json({ + session_id: sessionId, + uploads: body.static_bundle.map((file) => ({ + path: file.path, + url: `${this.baseUrl}/presigned/${file.path}`, + content_type: "application/octet-stream", + content_length: file.size, + checksum_sha256: Buffer.from( + file.digest.replace("sha256:", ""), + "hex", + ).toString("base64"), + })), + }); + }, + }); + return this; + } + + mockVersionFinalize(response: { + version_id: string; + manifest_hash: string; + deduplicated: boolean; + }): this { + return this.addRoute( + "POST", + `/api/apps/${this.appId}/versions/:sessionId/finalize`, + response, + ); + } + + mockVersionDeploy(response: { + deployment_id: string; + manifest_hash: string; + revision: number; + }): this { + this.pendingRoutes.push({ + method: "POST", + path: `/api/apps/${this.appId}/versions/:versionId/deployments`, + handler: (req, res) => { + this.versionDeployRequests.push(req.body); + this.versionDeployIds.push(String(req.params.versionId)); + res.status(200).json(response); + }, + }); + return this; + } + + mockVersionDeclareError(error: ErrorResponse): this { + return this.addErrorRoute( + "POST", + `/api/apps/${this.appId}/versions`, + error, + ); + } + /** Mock the Cloudflare assets endpoint to always fail with the given error. */ mockAssetUploadError(error: ErrorResponse): this { return this.addErrorRoute("POST", "/cf-assets/upload", error); diff --git a/packages/cli/tests/core/version-artifacts.spec.ts b/packages/cli/tests/core/version-artifacts.spec.ts new file mode 100644 index 00000000..72b698b6 --- /dev/null +++ b/packages/cli/tests/core/version-artifacts.spec.ts @@ -0,0 +1,144 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { InvalidInputError } from "@/core/errors.js"; +import { hashAsset } from "@/core/site/manifest.js"; +import { + collectBuildOutput, + collectResources, +} from "@/core/version/artifacts.js"; + +function sha256(content: string): string { + return `sha256:${createHash("sha256").update(content).digest("hex")}`; +} + +describe("collectBuildOutput", () => { + let outputDir: string; + + beforeEach(async () => { + outputDir = await mkdtemp(join(tmpdir(), "b44-build-")); + await writeFile(join(outputDir, "index.html"), "

Hello

\n"); + }); + + afterEach(async () => { + await rm(outputDir, { recursive: true, force: true }); + }); + + it("names each file by a full sha256 over its bytes", async () => { + const files = await collectBuildOutput(outputDir); + + expect(files).toEqual([ + { + path: "index.html", + absolutePath: join(outputDir, "index.html"), + size: 15, + digest: sha256("

Hello

\n"), + }, + ]); + }); + + it("is not the provider asset hash, which is salted and truncated", async () => { + // Conflating the two produces a file that uploads fine and never dedupes, + // or a digest check that fails on a correct file. + const [file] = await collectBuildOutput(outputDir); + + expect(file.digest).not.toContain( + hashAsset("app-1", Buffer.from("

Hello

\n")), + ); + expect(file.digest.replace("sha256:", "")).toHaveLength(64); + }); + + it("keys nested files by forward-slash paths with no leading slash", async () => { + // The platform turns each path into a key under the frontend's prefix, and + // refuses a leading slash outright. + await mkdir(join(outputDir, "assets")); + await writeFile(join(outputDir, "assets", "app.js"), "console.log(1);"); + + const paths = (await collectBuildOutput(outputDir)).map((f) => f.path); + + expect(paths).toEqual(["assets/app.js", "index.html"]); + }); + + it("honors .assetsignore", async () => { + await writeFile(join(outputDir, ".assetsignore"), "*.map\n"); + await writeFile(join(outputDir, "app.js.map"), "{}"); + + const paths = (await collectBuildOutput(outputDir)).map((f) => f.path); + + expect(paths).toEqual(["index.html"]); + }); + + it("refuses a set with no entry point", async () => { + await rm(join(outputDir, "index.html")); + await writeFile(join(outputDir, "app.js"), "console.log(1);"); + + await expect(collectBuildOutput(outputDir)).rejects.toThrow( + /no index\.html/, + ); + }); + + it("refuses an empty output directory", async () => { + await rm(join(outputDir, "index.html")); + + await expect(collectBuildOutput(outputDir)).rejects.toBeInstanceOf( + InvalidInputError, + ); + }); +}); + +describe("collectResources", () => { + let configDir: string; + + beforeEach(async () => { + configDir = await mkdtemp(join(tmpdir(), "b44-config-")); + }); + + afterEach(async () => { + await rm(configDir, { recursive: true, force: true }); + }); + + const dirs = { entitiesDir: "entities", agentsDir: "agents" }; + + it("keys a payload by its path with the schema extension stripped", async () => { + // The same name the platform derives from the same file, so a version made + // here and one made in-process describe the same entity. + await mkdir(join(configDir, "entities")); + await writeFile( + join(configDir, "entities", "Todo.jsonc"), + '{ "name": "Todo", "type": "object" }', + ); + await mkdir(join(configDir, "agents", "support"), { recursive: true }); + await writeFile(join(configDir, "agents", "support", "triage.json"), "{}"); + + const { entities, agents } = await collectResources(configDir, dirs); + + expect(entities).toEqual({ Todo: { name: "Todo", type: "object" } }); + expect(agents).toEqual({ "support/triage": {} }); + }); + + it("sends the payload raw, without the CLI's stricter validation", async () => { + // The platform's extractor and validation are authoritative. Applying the + // CLI's entity schema here is exactly what blocks real Builder apps, which + // is why `site deploy` reads no resources at all. + await mkdir(join(configDir, "entities")); + await writeFile( + join(configDir, "entities", "Odd.jsonc"), + '{ "no_name_field": true, "properties": { "x": { "type": "whatever" } } }', + ); + + const { entities } = await collectResources(configDir, dirs); + + expect(entities).toEqual({ + Odd: { no_name_field: true, properties: { x: { type: "whatever" } } }, + }); + }); + + it("reads an app that declares none as declaring none", async () => { + expect(await collectResources(configDir, dirs)).toEqual({ + entities: {}, + agents: {}, + }); + }); +}); diff --git a/packages/cli/tests/core/version-project.spec.ts b/packages/cli/tests/core/version-project.spec.ts new file mode 100644 index 00000000..b89fed4f --- /dev/null +++ b/packages/cli/tests/core/version-project.spec.ts @@ -0,0 +1,121 @@ +import { + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { SchemaValidationError } from "@/core/errors.js"; +import { + requireOutputDir, + resolvePublishTarget, +} from "@/core/version/project.js"; + +describe("resolvePublishTarget", () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "b44-project-")); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + async function writeConfig(config: unknown): Promise { + await mkdir(join(root, "base44"), { recursive: true }); + await writeFile( + join(root, "base44", "config.jsonc"), + JSON.stringify(config), + ); + } + + it("supplies a Builder repo's missing defaults", async () => { + // Builder repos carry no CLI config at all, and `base44 build` used to throw + // ConfigNotFoundError on one. + const target = await resolvePublishTarget(root); + + expect(target).toEqual({ + root, + configDir: join(root, "base44"), + buildCommand: "npm run build", + outputDir: resolve(root, "dist"), + entitiesDir: "entities", + agentsDir: "agents", + }); + }); + + it("does not guess for a config that is present and omits a field", async () => { + // Omitting one is a deliberate statement, and every project that relies on + // the existing error still gets it. Only a wholly absent config is defaulted. + await writeConfig({ name: "my-app" }); + + const target = await resolvePublishTarget(root, { outputDir: "dist" }); + + expect(target.buildCommand).toBeUndefined(); + }); + + it("names no output directory when a present config names none", async () => { + // Reported at the point of collection, not here: a project missing both is + // told about its build command first, which is the one it hits first. + await writeConfig({ + name: "my-app", + site: { buildCommand: "npm run build" }, + }); + + const target = await resolvePublishTarget(root); + + expect(target.outputDir).toBeNull(); + expect(() => requireOutputDir(target)).toThrow( + /No site configuration found/, + ); + }); + + it("writes nothing", async () => { + // The sandbox used to overwrite base44/config.jsonc with a minimal one, + // destroying any checked-in configuration — and, for a full-stack app, its + // build command. + await resolvePublishTarget(root); + + expect(await readdir(root)).toEqual([]); + }); + + it("leaves a checked-in config exactly as it was", async () => { + const config = { + name: "my-app", + site: { buildCommand: "pnpm build", outputDirectory: "build" }, + }; + await writeConfig(config); + const before = await readFile(join(root, "base44", "config.jsonc"), "utf8"); + + const target = await resolvePublishTarget(root); + + expect(await readFile(join(root, "base44", "config.jsonc"), "utf8")).toBe( + before, + ); + expect(target.buildCommand).toBe("pnpm build"); + expect(target.outputDir).toBe(resolve(root, "build")); + }); + + it("honors an explicit output directory over both", async () => { + await writeConfig({ name: "my-app", site: { outputDirectory: "out" } }); + + const target = await resolvePublishTarget(root, { outputDir: "elsewhere" }); + + expect(target.outputDir).toBe(resolve(root, "elsewhere")); + }); + + it("still fails on a config that is present and invalid", async () => { + // Only a MISSING config is defaulted. Publishing past a broken one is how a + // typo becomes a version built the wrong way. + await writeConfig({ site: { buildCommand: 42 } }); + + await expect(resolvePublishTarget(root)).rejects.toBeInstanceOf( + SchemaValidationError, + ); + }); +}); diff --git a/packages/cli/tests/core/version-publish.spec.ts b/packages/cli/tests/core/version-publish.spec.ts new file mode 100644 index 00000000..0a054000 --- /dev/null +++ b/packages/cli/tests/core/version-publish.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { ApiError } from "@/core/errors.js"; +import { stepOf, tagStep } from "@/core/version/publish.js"; + +describe("which step a publish broke in", () => { + it("carries the step out with the failure", async () => { + // A user's build failing, an artifact set the platform refused and a lost + // publication race are three incidents with three responses. One exit code + // for all of them is how a sandbox log stops being diagnostic. + await tagStep("create_version", () => + Promise.reject(new ApiError("rejected", { statusCode: 400 })), + ).catch((error) => { + expect(error.message).toBe("rejected"); + expect(stepOf(error)).toBe("create_version"); + }); + }); + + it("tags a callback that throws synchronously too", async () => { + await tagStep("build", () => { + throw new Error("the build command exited 1"); + }).catch((error) => { + expect(stepOf(error)).toBe("build"); + }); + }); + + it("leaves the error otherwise untouched", async () => { + // Wrapping it would cost the envelope the status and request id a caller + // needs to look the failure up server-side. + const original = new ApiError("upstream said no", { + statusCode: 502, + requestId: "req-1", + }); + + await tagStep("deploy", () => Promise.reject(original)).catch((error) => { + expect(error).toBe(original); + expect(error.statusCode).toBe(502); + expect(error.requestId).toBe("req-1"); + }); + }); + + it("keeps the innermost step when steps nest", async () => { + await tagStep("deploy", () => + tagStep("create_version", () => Promise.reject(new Error("inner"))), + ).catch((error) => { + expect(stepOf(error)).toBe("create_version"); + }); + }); + + it("reports no step for a failure that never passed through one", () => { + expect(stepOf(new Error("unrelated"))).toBeUndefined(); + }); +}); diff --git a/packages/cli/tests/fixtures/publishable/base44/agents/helper.jsonc b/packages/cli/tests/fixtures/publishable/base44/agents/helper.jsonc new file mode 100644 index 00000000..8e24cab1 --- /dev/null +++ b/packages/cli/tests/fixtures/publishable/base44/agents/helper.jsonc @@ -0,0 +1 @@ +{ "name": "helper", "instructions": "help" } diff --git a/packages/cli/tests/fixtures/publishable/base44/config.jsonc b/packages/cli/tests/fixtures/publishable/base44/config.jsonc new file mode 100644 index 00000000..cf8a03d0 --- /dev/null +++ b/packages/cli/tests/fixtures/publishable/base44/config.jsonc @@ -0,0 +1,7 @@ +{ + "name": "Publishable Project", + "site": { + "buildCommand": "node -e \"require('fs').writeFileSync('build-env.txt', 'BUILD_APP=' + process.env.VITE_BASE44_APP_ID)\"", + "outputDirectory": "site-output" + } +} diff --git a/packages/cli/tests/fixtures/publishable/base44/entities/Todo.jsonc b/packages/cli/tests/fixtures/publishable/base44/entities/Todo.jsonc new file mode 100644 index 00000000..d2dad3c8 --- /dev/null +++ b/packages/cli/tests/fixtures/publishable/base44/entities/Todo.jsonc @@ -0,0 +1,8 @@ +{ + // Deliberately not what the CLI's own entity schema accepts: the platform's + // extractor is authoritative, and a strict parse here blocks real Builder apps. + "name": "Todo", + "type": "object", + "properties": { "title": { "type": "string" } }, + "unknown_builder_field": true +} diff --git a/packages/cli/tests/fixtures/publishable/site-output/assets/app.js b/packages/cli/tests/fixtures/publishable/site-output/assets/app.js new file mode 100644 index 00000000..29348283 --- /dev/null +++ b/packages/cli/tests/fixtures/publishable/site-output/assets/app.js @@ -0,0 +1 @@ +console.log(1) \ No newline at end of file diff --git a/packages/cli/tests/fixtures/publishable/site-output/index.html b/packages/cli/tests/fixtures/publishable/site-output/index.html new file mode 100644 index 00000000..06a67d94 --- /dev/null +++ b/packages/cli/tests/fixtures/publishable/site-output/index.html @@ -0,0 +1 @@ +
\ No newline at end of file From d7fec63ca3549a7c994380a35379fb5e962a6837 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Mon, 14 Sep 2026 15:54:45 +0300 Subject: [PATCH 02/21] fix: `base44 build` needs no credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It runs a local command and reads local files — it never calls the API, so prompting for a login was always pointless. It matters now because it is the step a publish sandbox runs BEFORE minting its publish key: requiring auth there either fails that exec or puts the key beside the repo-controlled code the build runs. The app id is still required; it is injected into the build as VITE_BASE44_APP_ID. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/src/cli/commands/project/build.ts | 7 ++++++- packages/cli/tests/cli/build.spec.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cli/commands/project/build.ts b/packages/cli/src/cli/commands/project/build.ts index 07bc9e3f..30152fac 100644 --- a/packages/cli/src/cli/commands/project/build.ts +++ b/packages/cli/src/cli/commands/project/build.ts @@ -24,7 +24,12 @@ async function buildAction(ctx: CLIContext): Promise { } export function getBuildCommand(): Command { - return new Base44Command("build") + // No credential: the build runs a local command and reads local files, and it + // is the step a publish sandbox runs BEFORE minting its publish key — so + // requiring auth here would either fail that exec or put the key beside the + // repo-controlled code the build runs. The app id is still required: it is + // injected into the build as VITE_BASE44_APP_ID. + return new Base44Command("build", { requireAuth: false }) .description("Build the site with the Base44 app id injected") .action(buildAction); } diff --git a/packages/cli/tests/cli/build.spec.ts b/packages/cli/tests/cli/build.spec.ts index a7d758a1..3086da52 100644 --- a/packages/cli/tests/cli/build.spec.ts +++ b/packages/cli/tests/cli/build.spec.ts @@ -33,6 +33,17 @@ describe("build command", () => { t.expectResult(result).toContain("Build failed"); }); + it("needs no credential, so a publish sandbox can build before it holds one", async () => { + await t.givenProject(fixture("with-buildable-site")); + + const result = await t.run("build"); + + t.expectResult(result).toSucceed(); + expect(await t.readProjectFile("build-env.txt")).toBe( + `BUILD_APP=${t.api.appId}`, + ); + }); + it("fails when not in a project directory", async () => { await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); From c473ba95b597ad2b8ca4c6f80615f4753d823d70 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Mon, 14 Sep 2026 17:33:52 +0300 Subject: [PATCH 03/21] refactor: name the group `versions`, not `version` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `base44 version` printed a command group's help while `base44 --version` printed the version number, two lines apart in the same `--help` output. Anyone reaching for the CLI's version got the wrong thing. Plural is also this CLI's own convention for a group you have many of — `agents`, `entities`, `functions`, `secrets`, `workflows` — against the singular groups you have exactly one of: `auth`, `site`, `sandbox`, `types`. `create` and `deploy` keep their names. `create` is the word this CLI already uses for bringing a new thing into existence (`base44 create`), and `deploy` is already its verb for making something live (`functions deploy`, `site deploy`). Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/src/cli/commands/{version => versions}/create.ts | 0 packages/cli/src/cli/commands/{version => versions}/deploy.ts | 0 packages/cli/src/cli/commands/{version => versions}/index.ts | 4 ++-- packages/cli/src/cli/program.ts | 4 ++-- packages/cli/tests/cli/publish.spec.ts | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) rename packages/cli/src/cli/commands/{version => versions}/create.ts (100%) rename packages/cli/src/cli/commands/{version => versions}/deploy.ts (100%) rename packages/cli/src/cli/commands/{version => versions}/index.ts (78%) diff --git a/packages/cli/src/cli/commands/version/create.ts b/packages/cli/src/cli/commands/versions/create.ts similarity index 100% rename from packages/cli/src/cli/commands/version/create.ts rename to packages/cli/src/cli/commands/versions/create.ts diff --git a/packages/cli/src/cli/commands/version/deploy.ts b/packages/cli/src/cli/commands/versions/deploy.ts similarity index 100% rename from packages/cli/src/cli/commands/version/deploy.ts rename to packages/cli/src/cli/commands/versions/deploy.ts diff --git a/packages/cli/src/cli/commands/version/index.ts b/packages/cli/src/cli/commands/versions/index.ts similarity index 78% rename from packages/cli/src/cli/commands/version/index.ts rename to packages/cli/src/cli/commands/versions/index.ts index 220f1733..8114d655 100644 --- a/packages/cli/src/cli/commands/version/index.ts +++ b/packages/cli/src/cli/commands/versions/index.ts @@ -2,8 +2,8 @@ import { Command } from "commander"; import { getVersionCreateCommand } from "./create.js"; import { getVersionDeployCommand } from "./deploy.js"; -export function getVersionCommand(): Command { - return new Command("version") +export function getVersionsCommand(): Command { + return new Command("versions") .description("Record app versions and serve them") .addCommand(getVersionCreateCommand()) .addCommand(getVersionDeployCommand()); diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 56146f87..5d42cf19 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -22,7 +22,7 @@ import { getSandboxCommand } from "@/cli/commands/sandbox/index.js"; import { getSecretsCommand } from "@/cli/commands/secrets/index.js"; import { getSiteCommand } from "@/cli/commands/site/index.js"; import { getTypesCommand } from "@/cli/commands/types/index.js"; -import { getVersionCommand } from "@/cli/commands/version/index.js"; +import { getVersionsCommand } from "@/cli/commands/versions/index.js"; import { getWorkflowsCommand } from "@/cli/commands/workflows/index.js"; import { getWorkspaceCommand } from "@/cli/commands/workspace/index.js"; import { Base44Command } from "@/cli/utils/index.js"; @@ -118,7 +118,7 @@ export function createProgram(context: CLIContext): Command { // Register site commands program.addCommand(getSiteCommand()); program.addCommand(getPublishCommand()); - program.addCommand(getVersionCommand()); + program.addCommand(getVersionsCommand()); // Register types command program.addCommand(getTypesCommand()); diff --git a/packages/cli/tests/cli/publish.spec.ts b/packages/cli/tests/cli/publish.spec.ts index 4e951131..abbef6c8 100644 --- a/packages/cli/tests/cli/publish.spec.ts +++ b/packages/cli/tests/cli/publish.spec.ts @@ -149,7 +149,7 @@ describe("publish command", () => { }); }); -describe("version deploy command", () => { +describe("versions deploy command", () => { const t = setupCLITests(); it("serves an existing version with no build and no upload", async () => { @@ -161,7 +161,7 @@ describe("version deploy command", () => { revision: 12, }); - const result = await t.run("version", "deploy", "ver-old", "--json"); + const result = await t.run("versions", "deploy", "ver-old", "--json"); t.expectResult(result).toSucceed(); expect(t.api.versionDeployIds).toEqual(["ver-old"]); From 17f6b6a73783df0d9a54af0adf7714669cff651a Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Mon, 14 Sep 2026 18:13:26 +0300 Subject: [PATCH 04/21] feat: gate the versions lane behind BASE44_VERSIONS_API, and document it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `base44 publish` and the `versions` group are not registered without the env var, so with it off they are absent from `--help` and typing one is an unknown command. Not `.hidden()`: a command that runs but is unlisted is discoverable by anyone who reads the source, and cannot be un-shipped once someone scripts against it. This is the shape `site deploy` already uses for the flags that only mean something on the deployments lane. Deliberately a second var rather than `BASE44_DEPLOYMENTS_API`. That one selects the legacy deployments transport, and the build sandbox already sets it for that arm; one var switching both lanes would make them impossible to roll out apart. `base44 build` stays ungated — it is a pre-existing public command, and what changed there is what lets the sandbox build before it holds a publish key. Adds `docs/versions.md` alongside `docs/deployments.md`: the house rule is to gate the lane out of the CLI's surface and document it in `docs/`, not to hide it from the repo. Co-Authored-By: Claude Opus 5 (1M context) --- docs/AGENTS.md | 1 + docs/versions.md | 89 ++++++++++++++++++++ packages/cli/src/cli/program.ts | 10 ++- packages/cli/src/core/version/gate.ts | 23 +++++ packages/cli/src/core/version/index.ts | 1 + packages/cli/tests/cli/publish.spec.ts | 45 ++++++++++ packages/cli/tests/core/version-gate.spec.ts | 22 +++++ 7 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 docs/versions.md create mode 100644 packages/cli/src/core/version/gate.ts create mode 100644 packages/cli/tests/core/version-gate.spec.ts diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 45504ec8..e15a8484 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -81,6 +81,7 @@ Read these when working on the relevant area: - **[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](deployments.md)** - Deploys addressed by commit, wrangler config, asset manifest hashing, direct asset uploads (Workers) and presigned uploads (static) +- **[Versions](versions.md)** - Recording a build as an immutable version and serving it, staged uploads with server-verified digests, raw resource payloads, the `BASE44_VERSIONS_API` gate - **[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/versions.md b/docs/versions.md new file mode 100644 index 00000000..3cdda416 --- /dev/null +++ b/docs/versions.md @@ -0,0 +1,89 @@ +# Versions + +**Keywords:** versions, publish, deployment, rollback, artifact set, sha256, digest, staged upload, presigned, x-amz-checksum-sha256, entities, agents, raw payloads, provenance, commit, idempotency key, step, BASE44_VERSIONS_API, env gate, build sandbox + +A **version** is one immutable thing a build produced — every frontend file, plus the entity and agent payloads the app declares. A **deployment** is a version made live at an environment. Recording one and serving one are separate acts, which is what makes a rollback a deploy of an older version rather than a second code path. + +This lives in `src/core/version/`: `artifacts.ts` (the build-output walk and the raw resource reads), `api.ts` (the three HTTP calls), `publish.ts` (orchestration and step tagging), `project.ts` (where to build and what to publish), `gate.ts` (the env gate), `schema.ts` (wire types). + +It is **not** `src/core/site/` — see [Deployments](deployments.md). That lane ships a build to the legacy hosting API and its `deploymentId` names a Cloudflare script; this one records a version on the platform's version plane and its `deploymentId` names a deployment there. A caller that could not tell the two apart would publish by accident, so they are separate commands with separate envelope field names. + +## Two hashes, never conflated + +`hashAsset` in `core/site/manifest.ts` is the first 32 hex characters of `sha256(utf8(app_id) ‖ bytes)` — a **provider upload identifier**, salted so a tenant can only collide with its own files. + +`ArtifactFile.digest` is a **full sha256 over the stored bytes**, streamed so a large file is never read whole. It is durable artifact identity, and the platform signs it into the upload URL. Different purpose, different moment, different value: conflating them produces an asset that uploads fine and never dedupes, or a digest check that fails on a correct file. + +## The flow + +`createVersion(artifacts, options)` in `api.ts` — three calls, in this order and no other, so nothing is recorded until the bytes are in place: + +1. **Declare.** `POST versions` with `static_bundle` (path, size, digest per file), the raw `entities` and `agents` payloads, and `source_commit`. The response carries a `session_id` and one presigned PUT per file. +2. **Upload.** `uploadPresignedAssets` — the same function the static deployments lane uses, same `pMap` concurrency and same ky retry policy. Each PUT sends the server's `Content-Type` **and** its `x-amz-checksum-sha256` verbatim; deriving either locally would 403 on any mapping difference. +3. **Finalize.** `POST versions/{session_id}/finalize`, no body. The set was fixed at declare, so there is nothing left for the caller to change. The response says whether the content deduplicated to a version that already existed. + +`deployVersion(versionId, options)` is one POST carrying a target name and an idempotency key. Nothing else is the caller's to say: the app comes from the credential, and so do the acting principal, the runtime environment variables, every artifact key, the manifest hash and the publication revision. The request models on the server forbid unknown fields, so sending one is an error rather than a silent drop. + +## Why the digest is signed into the URL + +The platform pins content type, content length **and** sha256 into each presigned PUT, so S3 itself rejects a body that does not hash to the declared digest. The URL is permission to write exactly one payload, once — which is what lets the server commit those bytes with a server-side copy instead of reading them back to re-hash. A frontend of any size is recorded without its bytes passing through a worker. + +The practical consequence for this CLI: **send the checksum the server gave you, unchanged.** `PresignedAssetUpload.checksumSha256` is optional because the legacy static lane's URLs pin only type and length. + +## Resources go up raw + +`collectResources` reads `entities/` and `agents/` with `readJsonFile` and sends the parsed payloads untouched, keyed by the file's path with the schema extension stripped — `entities/Todo.jsonc` → `Todo`, `agents/support/triage.jsonc` → `support/triage`. That is the same name the platform derives from the same file. + +Deliberately **not** `entityResource.readAll` / `agentResource.readAll`. The platform's own extractor and validation are authoritative, and this CLI's stricter entity schema refuses real Builder apps — which is exactly why `site deploy` reads no resources at all. Re-introducing the strict parse here would reproduce that block. + +## The commit is provenance, not identity + +`resolveProvenanceCommit(projectRoot, explicit?)` in `core/site/git-hash.ts` returns `undefined` rather than failing when there is no checkout. A version is identified by its **content**; the commit is recorded beside it and never hashed, so a build outside a git checkout is still a complete version. + +That is the whole difference from `resolveGitHash`, whose caller addresses a deployment *by* the hash and therefore cannot go without one. + +## A Builder repo carries no CLI config + +`resolvePublishTarget(projectRoot, overrides)` in `project.ts` fills in `npm run build` and `dist` **only when a repo has no config at all**, and **writes nothing**. The python driver it replaces used to overwrite `base44/config.jsonc` with a minimal config before building, destroying any checked-in configuration — and, for a full-stack app, its build command. + +A config that is present wins, field by field, and one that omits a field still gets today's error: omitting `site.buildCommand` is a deliberate statement, and answering it with a guessed `npm run build` would change what `base44 build` does for every project that relies on that error. `requireOutputDir(target)` raises at the point of collection rather than at resolution, so a project missing both is told about its build command first — the one it hits first. + +## Which step failed + +A publish is three steps and they fail differently: a user's build failing, an artifact set the platform refused, and a lost publication race are three incidents with three responses. `tagStep(step, run)` attaches the step to the error through a non-enumerable symbol — the original error type, message, status code and request id all survive — and `Base44Command` writes it into the `--json` error envelope as `step`. + +A callback that throws synchronously is tagged too; `run().catch(...)` would let that one escape untagged. + +## Commands + +**`base44 publish [--no-build] [--output-dir ] [--target ] [--git-hash ] [--concurrency ]`** — build, record a version, serve it. Under `--json`, stdout is a single `{versionId, manifestHash, deduplicated, deploymentId, revision}` document. + +**`base44 versions create`** — record built output without serving it. A version can sit unpublished for as long as it likes. + +**`base44 versions deploy [--target ]`** — serve a recorded version: no checkout, no build, no upload. Passing an older id is how a rollback is done. + +`base44 build` is not part of this group and is not gated, but the lane depends on two things about it: it needs **no credential** (the publish sandbox builds before minting a key that can deploy), and it resolves its config through `resolvePublishTarget` (a Builder repo has none). What it builds and what it prints are unchanged. + +The group is plural to match `agents`, `entities`, `functions`, `secrets` and `workflows` — and because `base44 version` shadowed `base44 --version` two lines above it in `--help`. + +## Upload concurrency + +`DEFAULT_VERSION_UPLOAD_CONCURRENCY` is 8, `MAX_VERSION_UPLOAD_CONCURRENCY` is 16. Measured on the build sandbox's pipe: at 3, a 25 500-asset app moved 27 assets/s (~109 ms per PUT, 23 KiB mean — latency-bound) and needed ~930 s of the ~450 s a build leaves, so it was SIGKILLed mid-upload every time. 8 is the rate the python driver it replaced already sustained to the same bucket; 16 failed a degraded pipe on 2026-07-02. + +## The env gate + +The whole lane is one env var. With `BASE44_VERSIONS_API=1` (or `true`; internal gate, not user-facing yet) `base44 publish` and the `versions` group are registered; without it they are **not registered at all**, so they are absent from `--help` and typing one is an unknown command. `versionsApiEnabled()` in `core/version/gate.ts` is read in exactly one place: the registration in `program.ts`. + +Deliberately **not** `BASE44_DEPLOYMENTS_API`. That one selects the legacy deployments transport for `site deploy`, and the build sandbox already sets it for that arm; one var switching both lanes would make them impossible to roll out apart. + +## The automated consumer + +The platform's build sandbox runs the lane in two execs and builds once: + +``` +base44 build --json # no credential in the environment +base44 publish --no-build --json \ # the key exists only for this one + --git-hash --concurrency 8 +``` + +Two execs because the key now carries publish authority and the build is code the repo controls, so the key is minted only after the build finishes and revoked on the way out. `--no-build` is what keeps it one build rather than two. diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 5d42cf19..1b54779e 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -27,6 +27,7 @@ import { getWorkflowsCommand } from "@/cli/commands/workflows/index.js"; import { getWorkspaceCommand } from "@/cli/commands/workspace/index.js"; import { Base44Command } from "@/cli/utils/index.js"; import { BASE44_APP_ID_ENV_VAR } from "@/core/consts.js"; +import { versionsApiEnabled } from "@/core/version/gate.js"; import packageJson from "../../package.json"; import { getDevCommand } from "./commands/dev.js"; import { getExecCommand } from "./commands/exec.js"; @@ -117,8 +118,13 @@ export function createProgram(context: CLIContext): Command { // Register site commands program.addCommand(getSiteCommand()); - program.addCommand(getPublishCommand()); - program.addCommand(getVersionsCommand()); + // Registered on the enabled lane only: with the gate off they are absent from + // --help and rejected as unknown commands, rather than exposing a lane that is + // still being integrated against the platform. + if (versionsApiEnabled()) { + program.addCommand(getPublishCommand()); + program.addCommand(getVersionsCommand()); + } // Register types command program.addCommand(getTypesCommand()); diff --git a/packages/cli/src/core/version/gate.ts b/packages/cli/src/core/version/gate.ts new file mode 100644 index 00000000..eb9bfd22 --- /dev/null +++ b/packages/cli/src/core/version/gate.ts @@ -0,0 +1,23 @@ +const VERSIONS_API_ENV = "BASE44_VERSIONS_API"; + +/** + * Internal gate for the versions lane — `base44 publish` and the `versions` + * group, neither user-facing yet. With it off the commands are **not registered + * at all**, so they are absent from `--help` and typing one is an unknown + * command, rather than a half-integrated lane a user can stumble into. + * + * Deliberately not `BASE44_DEPLOYMENTS_API`: that one selects the legacy + * deployments transport for `site deploy`, and the build sandbox already sets it + * for that arm. One var switching both lanes would make them impossible to roll + * out apart. + * + * `base44 build` is NOT gated. It is a pre-existing public command, and what + * changed there — no credential, and config defaults for a repo that has none — + * is what lets the sandbox build before it holds a publish key. + */ +export function versionsApiEnabled( + env: NodeJS.ProcessEnv = process.env, +): boolean { + const value = env[VERSIONS_API_ENV]; + return value === "1" || value === "true"; +} diff --git a/packages/cli/src/core/version/index.ts b/packages/cli/src/core/version/index.ts index dd3356d7..bc734d49 100644 --- a/packages/cli/src/core/version/index.ts +++ b/packages/cli/src/core/version/index.ts @@ -1,5 +1,6 @@ export * from "./api.js"; export * from "./artifacts.js"; +export * from "./gate.js"; export * from "./project.js"; export * from "./publish.js"; export * from "./schema.js"; diff --git a/packages/cli/tests/cli/publish.spec.ts b/packages/cli/tests/cli/publish.spec.ts index abbef6c8..abadcf9d 100644 --- a/packages/cli/tests/cli/publish.spec.ts +++ b/packages/cli/tests/cli/publish.spec.ts @@ -31,6 +31,7 @@ describe("publish command", () => { }; it("declares every built file by a full sha256 over its bytes", async () => { + t.givenEnv({ BASE44_VERSIONS_API: "1" }); await t.givenLoggedInWithProject(fixture("publishable")); mockPublishApi(); @@ -46,6 +47,7 @@ describe("publish command", () => { }); it("sends the app's resources raw, keyed the way the platform names them", async () => { + t.givenEnv({ BASE44_VERSIONS_API: "1" }); await t.givenLoggedInWithProject(fixture("publishable")); mockPublishApi(); @@ -68,6 +70,7 @@ describe("publish command", () => { }); it("uploads every declared file with the checksum the server signed in", async () => { + t.givenEnv({ BASE44_VERSIONS_API: "1" }); await t.givenLoggedInWithProject(fixture("publishable")); mockPublishApi(); @@ -87,6 +90,7 @@ describe("publish command", () => { it("carries only a target name and a retry key into the deploy", async () => { // Everything else — the app, the principal, env vars, the revision — is the // platform's to resolve, and there is deliberately no field for any of them. + t.givenEnv({ BASE44_VERSIONS_API: "1" }); await t.givenLoggedInWithProject(fixture("publishable")); mockPublishApi(); @@ -103,6 +107,7 @@ describe("publish command", () => { // Its own field names: `site deploy`'s `deploymentId` means a Cloudflare // script on the legacy lane, and a caller that could not tell the two apart // would publish by accident. + t.givenEnv({ BASE44_VERSIONS_API: "1" }); await t.givenLoggedInWithProject(fixture("publishable")); mockPublishApi(); @@ -121,6 +126,7 @@ describe("publish command", () => { it("names the step that failed", async () => { // A user's build failing, a rejected artifact set and a lost publication // race are three incidents with three responses. + t.givenEnv({ BASE44_VERSIONS_API: "1" }); await t.givenLoggedInWithProject(fixture("publishable")); t.api.mockVersionDeclareError({ status: 409, @@ -137,6 +143,7 @@ describe("publish command", () => { }); it("builds first unless told not to", async () => { + t.givenEnv({ BASE44_VERSIONS_API: "1" }); await t.givenLoggedInWithProject(fixture("publishable")); mockPublishApi(); @@ -154,6 +161,7 @@ describe("versions deploy command", () => { it("serves an existing version with no build and no upload", async () => { // Which is also what a rollback is: the same call with an older version id. + t.givenEnv({ BASE44_VERSIONS_API: "1" }); await t.givenLoggedInWithProject(fixture("publishable")); t.api.mockVersionDeploy({ deployment_id: "dep-9", @@ -173,3 +181,40 @@ describe("versions deploy command", () => { }); }); }); + +describe("the versions lane is gated", () => { + const t = setupCLITests(); + + it("does not exist with the gate off", async () => { + // Not hidden — absent. A command that runs but is unlisted is discoverable + // by anyone who reads the source, and cannot be un-shipped once someone + // scripts against it. + await t.givenLoggedInWithProject(fixture("publishable")); + + for (const argv of [["publish"], ["versions", "deploy", "ver-1"]]) { + const result = await t.run(...argv); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("unknown command"); + } + }); + + it("is absent from --help with the gate off", async () => { + await t.givenLoggedInWithProject(fixture("publishable")); + + const result = await t.run("--help"); + + expect(result.stdout).not.toContain("publish"); + expect(result.stdout).not.toContain("versions"); + }); + + it("appears once the gate is on", async () => { + t.givenEnv({ BASE44_VERSIONS_API: "1" }); + await t.givenLoggedInWithProject(fixture("publishable")); + + const result = await t.run("--help"); + + expect(result.stdout).toContain("publish"); + expect(result.stdout).toContain("versions"); + }); +}); diff --git a/packages/cli/tests/core/version-gate.spec.ts b/packages/cli/tests/core/version-gate.spec.ts new file mode 100644 index 00000000..38eb48f2 --- /dev/null +++ b/packages/cli/tests/core/version-gate.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { versionsApiEnabled } from "@/core/version/gate.js"; + +describe("versionsApiEnabled", () => { + it("is off unless the env var says otherwise", () => { + expect(versionsApiEnabled({})).toBe(false); + expect(versionsApiEnabled({ BASE44_VERSIONS_API: "" })).toBe(false); + expect(versionsApiEnabled({ BASE44_VERSIONS_API: "0" })).toBe(false); + expect(versionsApiEnabled({ BASE44_VERSIONS_API: "yes" })).toBe(false); + }); + + it("takes the same two values the deployments gate takes", () => { + expect(versionsApiEnabled({ BASE44_VERSIONS_API: "1" })).toBe(true); + expect(versionsApiEnabled({ BASE44_VERSIONS_API: "true" })).toBe(true); + }); + + it("is a separate switch from the deployments lane", () => { + // One var switching both would make them impossible to roll out apart, and + // the build sandbox already sets BASE44_DEPLOYMENTS_API for the legacy arm. + expect(versionsApiEnabled({ BASE44_DEPLOYMENTS_API: "1" })).toBe(false); + }); +}); From 866bd0d3f046e71f37aa232ec3020dff2f1fcda3 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Mon, 14 Sep 2026 18:18:58 +0300 Subject: [PATCH 05/21] refactor: build the versions lane's shared options in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--output-dir`, `--target`, `--git-hash` and `--concurrency` were defined once per command, with the flag names, descriptions, defaults and validators copied between `publish`, `versions create` and `versions deploy`. Nothing stopped the copies drifting, and a user comparing two `--help` screens would have been the one to find out. Four plain functions returning an `Option`, composed with `addOption`. 82 lines out, 20 in. Kept deliberately small — the CLI has no other shared option builders, so this is not a pattern to generalize until something else needs it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/src/cli/commands/publish.ts | 53 ++++------------- .../cli/src/cli/commands/versions/create.ts | 46 +++------------ .../cli/src/cli/commands/versions/deploy.ts | 3 +- .../cli/src/cli/commands/versions/options.ts | 57 +++++++++++++++++++ 4 files changed, 77 insertions(+), 82 deletions(-) create mode 100644 packages/cli/src/cli/commands/versions/options.ts diff --git a/packages/cli/src/cli/commands/publish.ts b/packages/cli/src/cli/commands/publish.ts index 0d29bd92..63d85536 100644 --- a/packages/cli/src/cli/commands/publish.ts +++ b/packages/cli/src/cli/commands/publish.ts @@ -1,15 +1,17 @@ import type { Command } from "commander"; -import { InvalidArgumentError, Option } from "commander"; import { runSiteBuild } from "@/cli/commands/project/site-build.js"; +import { + concurrencyOption, + gitHashOption, + outputDirOption, + targetOption, +} from "@/cli/commands/versions/options.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command, theme } from "@/cli/utils/index.js"; import { resolveProvenanceCommit } from "@/core/site/index.js"; -import { isGitCommitHash } from "@/core/utils/git.js"; import { collectBuildOutput, collectResources, - DEFAULT_VERSION_UPLOAD_CONCURRENCY, - MAX_VERSION_UPLOAD_CONCURRENCY, publishVersion, requireOutputDir, resolvePublishTarget, @@ -98,44 +100,9 @@ export function getPublishCommand(): Command { "--no-build", "Publish the existing build output without rebuilding", ) - .option( - "--output-dir ", - "Build output directory (defaults to the project's, else dist)", - ) - .option("--target ", "Environment to serve the version at") - .addOption( - new Option( - "--git-hash ", - "Commit the build came from (defaults to the checkout's HEAD)", - ).argParser(parseGitHash), - ) - .addOption( - new Option("--concurrency ", "Parallel file uploads") - .default(DEFAULT_VERSION_UPLOAD_CONCURRENCY) - .argParser(parseConcurrency), - ) + .addOption(outputDirOption()) + .addOption(targetOption()) + .addOption(gitHashOption()) + .addOption(concurrencyOption()) .action(publishAction); } - -function parseGitHash(value: string): string { - if (!isGitCommitHash(value)) { - throw new InvalidArgumentError( - "Expected a git commit hash (7-64 hex chars).", - ); - } - return value; -} - -function parseConcurrency(value: string): number { - const parsed = Number(value); - if ( - !Number.isInteger(parsed) || - parsed < 1 || - parsed > MAX_VERSION_UPLOAD_CONCURRENCY - ) { - throw new InvalidArgumentError( - `Expected a whole number between 1 and ${MAX_VERSION_UPLOAD_CONCURRENCY}.`, - ); - } - return parsed; -} diff --git a/packages/cli/src/cli/commands/versions/create.ts b/packages/cli/src/cli/commands/versions/create.ts index 99642325..71fe2341 100644 --- a/packages/cli/src/cli/commands/versions/create.ts +++ b/packages/cli/src/cli/commands/versions/create.ts @@ -1,15 +1,16 @@ import type { Command } from "commander"; -import { InvalidArgumentError, Option } from "commander"; +import { + concurrencyOption, + gitHashOption, + outputDirOption, +} from "@/cli/commands/versions/options.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { resolveProvenanceCommit } from "@/core/site/index.js"; -import { isGitCommitHash } from "@/core/utils/git.js"; import { collectBuildOutput, collectResources, createVersion, - DEFAULT_VERSION_UPLOAD_CONCURRENCY, - MAX_VERSION_UPLOAD_CONCURRENCY, requireOutputDir, resolvePublishTarget, } from "@/core/version/index.js"; @@ -67,39 +68,8 @@ async function createAction( export function getVersionCreateCommand(): Command { return new Base44Command("create") .description("Record the built output as a version, without deploying it") - .option( - "--output-dir ", - "Build output directory (defaults to the project's, else dist)", - ) - .addOption( - new Option( - "--git-hash ", - "Commit the build came from (defaults to the checkout's HEAD)", - ).argParser((value: string) => { - if (!isGitCommitHash(value)) { - throw new InvalidArgumentError( - "Expected a git commit hash (7-64 hex chars).", - ); - } - return value; - }), - ) - .addOption( - new Option("--concurrency ", "Parallel file uploads") - .default(DEFAULT_VERSION_UPLOAD_CONCURRENCY) - .argParser((value: string) => { - const parsed = Number(value); - if ( - !Number.isInteger(parsed) || - parsed < 1 || - parsed > MAX_VERSION_UPLOAD_CONCURRENCY - ) { - throw new InvalidArgumentError( - `Expected a whole number between 1 and ${MAX_VERSION_UPLOAD_CONCURRENCY}.`, - ); - } - return parsed; - }), - ) + .addOption(outputDirOption()) + .addOption(gitHashOption()) + .addOption(concurrencyOption()) .action(createAction); } diff --git a/packages/cli/src/cli/commands/versions/deploy.ts b/packages/cli/src/cli/commands/versions/deploy.ts index 7267ad9a..c6e47536 100644 --- a/packages/cli/src/cli/commands/versions/deploy.ts +++ b/packages/cli/src/cli/commands/versions/deploy.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import type { Command } from "commander"; +import { targetOption } from "@/cli/commands/versions/options.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { deployVersion } from "@/core/version/index.js"; @@ -39,6 +40,6 @@ export function getVersionDeployCommand(): Command { "Serve an already-recorded version (also how a rollback is done)", ) .argument("", "The version to serve") - .option("--target ", "Environment to serve the version at") + .addOption(targetOption()) .action(deployAction); } diff --git a/packages/cli/src/cli/commands/versions/options.ts b/packages/cli/src/cli/commands/versions/options.ts new file mode 100644 index 00000000..8afd4c1d --- /dev/null +++ b/packages/cli/src/cli/commands/versions/options.ts @@ -0,0 +1,57 @@ +import { InvalidArgumentError, Option } from "commander"; +import { isGitCommitHash } from "@/core/utils/git.js"; +import { + DEFAULT_VERSION_UPLOAD_CONCURRENCY, + MAX_VERSION_UPLOAD_CONCURRENCY, +} from "@/core/version/index.js"; + +/** + * The options the versions lane's commands share. + * + * Built here rather than repeated per command so `publish --help` and + * `versions create --help` cannot describe the same flag differently, and so a + * change to what is accepted lands in one place. + */ + +export function outputDirOption(): Option { + return new Option( + "--output-dir ", + "Build output directory (defaults to the project's, else dist)", + ); +} + +export function targetOption(): Option { + return new Option("--target ", "Environment to serve the version at"); +} + +export function gitHashOption(): Option { + return new Option( + "--git-hash ", + "Commit the build came from (defaults to the checkout's HEAD)", + ).argParser((value) => { + if (!isGitCommitHash(value)) { + throw new InvalidArgumentError( + "Expected a git commit hash (7-64 hex chars).", + ); + } + return value; + }); +} + +export function concurrencyOption(): Option { + return new Option("--concurrency ", "Parallel file uploads") + .default(DEFAULT_VERSION_UPLOAD_CONCURRENCY) + .argParser((value) => { + const parsed = Number(value); + if ( + !Number.isInteger(parsed) || + parsed < 1 || + parsed > MAX_VERSION_UPLOAD_CONCURRENCY + ) { + throw new InvalidArgumentError( + `Expected a whole number between 1 and ${MAX_VERSION_UPLOAD_CONCURRENCY}.`, + ); + } + return parsed; + }); +} From 988fe1ac60817db5b16c86cbf2dba09d1b1f1d46 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Mon, 14 Sep 2026 18:21:28 +0300 Subject: [PATCH 06/21] fix: stop exporting four types nothing outside asks for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PublishTarget`, `PublishStep` and `PublishResult` are each used only inside their own module, and `DeclareVersionResponse` was used nowhere at all — the response is consumed through the schema's inferred type at the call site. Exporting them put four names on the package's surface that no consumer names, which is what `knip` flags. The alias is deleted; the other three stay where they are, unexported. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/src/core/version/project.ts | 2 +- packages/cli/src/core/version/publish.ts | 4 ++-- packages/cli/src/core/version/schema.ts | 4 ---- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/core/version/project.ts b/packages/cli/src/core/version/project.ts index 2fcd88e6..d1d870f2 100644 --- a/packages/cli/src/core/version/project.ts +++ b/packages/cli/src/core/version/project.ts @@ -11,7 +11,7 @@ import type { ProjectWithPaths } from "@/core/project/types.js"; const DEFAULT_BUILD_COMMAND = "npm run build"; const DEFAULT_OUTPUT_DIRECTORY = "dist"; -export interface PublishTarget { +interface PublishTarget { root: string; /** Where `entitiesDir` and `agentsDir` are resolved from. */ configDir: string; diff --git a/packages/cli/src/core/version/publish.ts b/packages/cli/src/core/version/publish.ts index fc62e582..45b0eb35 100644 --- a/packages/cli/src/core/version/publish.ts +++ b/packages/cli/src/core/version/publish.ts @@ -14,7 +14,7 @@ import type { * being diagnostic, so the step travels with the failure and out through the * `--json` envelope. */ -export type PublishStep = "build" | "create_version" | "deploy"; +type PublishStep = "build" | "create_version" | "deploy"; const STEP = Symbol.for("base44.publishStep"); @@ -42,7 +42,7 @@ export function stepOf(error: unknown): PublishStep | undefined { : undefined; } -export interface PublishResult { +interface PublishResult { versionId: string; manifestHash: string; deduplicated: boolean; diff --git a/packages/cli/src/core/version/schema.ts b/packages/cli/src/core/version/schema.ts index 0b072714..38fc0841 100644 --- a/packages/cli/src/core/version/schema.ts +++ b/packages/cli/src/core/version/schema.ts @@ -56,10 +56,6 @@ export const DeclareVersionResponseSchema = z })), })); -export type DeclareVersionResponse = z.infer< - typeof DeclareVersionResponseSchema ->; - export const CreateVersionResponseSchema = z .object({ version_id: z.string(), From e0bde0c5efa19a64930584fbc81b3371e9fd92e8 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Tue, 15 Sep 2026 09:49:48 +0300 Subject: [PATCH 07/21] docs: name what keeps the versions client aligned with the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The house pattern from api-patterns.md — every response parsed through its Zod schema, a mismatch raised as SchemaValidationError — is the whole mechanism, and the doc now says so rather than leaving the next reader to ask. Co-Authored-By: Claude Opus 5 (1M context) --- docs/versions.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/versions.md b/docs/versions.md index 3cdda416..b3a77a8e 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -22,6 +22,10 @@ It is **not** `src/core/site/` — see [Deployments](deployments.md). That lane 2. **Upload.** `uploadPresignedAssets` — the same function the static deployments lane uses, same `pMap` concurrency and same ky retry policy. Each PUT sends the server's `Content-Type` **and** its `x-amz-checksum-sha256` verbatim; deriving either locally would 403 on any mapping difference. 3. **Finalize.** `POST versions/{session_id}/finalize`, no body. The set was fixed at declare, so there is nothing left for the caller to change. The response says whether the content deduplicated to a version that already existed. +Each of the three responses is parsed through its Zod schema and a mismatch raises `SchemaValidationError` — the house pattern from [Making API calls](api-patterns.md), and the only thing keeping this client aligned with the server. There is no generated type and no shared contract fixture here, the same as every other CLI↔platform surface: a server that renames or retypes a response field fails the publish with an error naming the field, rather than propagating `undefined`. In the other direction the server's request models forbid unknown fields, so a field this client sends that the server no longer accepts is a 422. + +What that does **not** catch is a change of meaning behind an unchanged shape. Nothing here does; the lane is small enough that both sides are reviewed together. + `deployVersion(versionId, options)` is one POST carrying a target name and an idempotency key. Nothing else is the caller's to say: the app comes from the credential, and so do the acting principal, the runtime environment variables, every artifact key, the manifest hash and the publication revision. The request models on the server forbid unknown fields, so sending one is an error rather than a silent drop. ## Why the digest is signed into the URL From b9ab9e7e61d102f3b6f8a6cc431b830e8d4fa177 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Tue, 15 Sep 2026 13:57:57 +0300 Subject: [PATCH 08/21] docs: cut the prose down to what the code does not say MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every docstring and comment added by this branch, trimmed to one or two lines: no narration of what the code already says, and no paragraph where a clause works. 58 lines out, no behaviour change. What stays is the non-obvious — why the digest is not `hashAsset`, why resources go up unvalidated, why the gate is a second env var rather than the deployments one, why `build` needs no credential, and the measurements behind the upload concurrency. Co-Authored-By: Claude Opus 5 (1M context) --- .../cli/src/cli/commands/project/build.ts | 13 +++---- packages/cli/src/cli/commands/publish.ts | 10 +++--- .../cli/src/cli/commands/versions/create.ts | 5 +-- .../cli/src/cli/commands/versions/deploy.ts | 6 ++-- .../cli/src/cli/commands/versions/options.ts | 7 ++-- packages/cli/src/core/version/api.ts | 23 +++++-------- packages/cli/src/core/version/artifacts.ts | 32 +++++++---------- packages/cli/src/core/version/gate.ts | 18 ++++------ packages/cli/src/core/version/project.ts | 34 ++++++------------- packages/cli/src/core/version/publish.ts | 21 ++++-------- packages/cli/src/core/version/schema.ts | 13 +++---- 11 files changed, 62 insertions(+), 120 deletions(-) diff --git a/packages/cli/src/cli/commands/project/build.ts b/packages/cli/src/cli/commands/project/build.ts index 30152fac..887b25c0 100644 --- a/packages/cli/src/cli/commands/project/build.ts +++ b/packages/cli/src/cli/commands/project/build.ts @@ -6,10 +6,8 @@ import { resolvePublishTarget } from "@/core/version/index.js"; async function buildAction(ctx: CLIContext): Promise { const { app } = ctx; - // Not readProjectConfig: a Builder repo carries no CLI config at all, and this - // is the step a publish sandbox runs inside one. resolvePublishTarget fills in - // the missing defaults without writing a file; a config that is present still - // wins, and one that omits a build command still gets today's error. + // Not readProjectConfig: a Builder repo carries no CLI config, and this is the + // step a publish sandbox runs inside one. A present config still wins. const target = await resolvePublishTarget(app?.projectRoot); await runSiteBuild(ctx, { @@ -24,11 +22,8 @@ async function buildAction(ctx: CLIContext): Promise { } export function getBuildCommand(): Command { - // No credential: the build runs a local command and reads local files, and it - // is the step a publish sandbox runs BEFORE minting its publish key — so - // requiring auth here would either fail that exec or put the key beside the - // repo-controlled code the build runs. The app id is still required: it is - // injected into the build as VITE_BASE44_APP_ID. + // No credential: it calls no API, and a publish sandbox runs it before minting + // a key that can deploy. The app id is still required. return new Base44Command("build", { requireAuth: false }) .description("Build the site with the Base44 app id injected") .action(buildAction); diff --git a/packages/cli/src/cli/commands/publish.ts b/packages/cli/src/cli/commands/publish.ts index 63d85536..6a36c158 100644 --- a/packages/cli/src/cli/commands/publish.ts +++ b/packages/cli/src/cli/commands/publish.ts @@ -27,13 +27,11 @@ interface PublishOptions { } /** - * Build, record a version, and serve it — the three steps in order. + * Build, record a version, and serve it. * - * NOT `site deploy`. That command drives the legacy full-stack hosting lane, - * whose own source says nothing there publishes; its envelope names a - * `deploymentId` that means a Cloudflare script, not a deployment on this plane. - * A caller that could not tell the two apart would publish by accident, so this - * is a separate command with separate envelope field names. + * Not `site deploy`: that drives the legacy hosting lane, where `deploymentId` + * means a Cloudflare script rather than a deployment on this plane. Separate + * command and separate envelope names, so the two cannot be confused. */ async function publishAction( ctx: CLIContext, diff --git a/packages/cli/src/cli/commands/versions/create.ts b/packages/cli/src/cli/commands/versions/create.ts index 71fe2341..067bcd59 100644 --- a/packages/cli/src/cli/commands/versions/create.ts +++ b/packages/cli/src/cli/commands/versions/create.ts @@ -21,10 +21,7 @@ interface CreateOptions { concurrency?: number; } -/** - * Record a build that already exists. No build of its own and no deploy — - * a version is a blueprint, and one can sit unpublished for as long as it likes. - */ +/** Record a build that already exists. No build of its own, and no deploy. */ async function createAction( { runTask, jsonMode, app }: CLIContext, options: CreateOptions, diff --git a/packages/cli/src/cli/commands/versions/deploy.ts b/packages/cli/src/cli/commands/versions/deploy.ts index c6e47536..b495bcfb 100644 --- a/packages/cli/src/cli/commands/versions/deploy.ts +++ b/packages/cli/src/cli/commands/versions/deploy.ts @@ -9,10 +9,8 @@ interface DeployOptions { target?: string; } -/** - * Serve a version that already exists. No checkout, no build and no upload — - * which is also what a rollback is: the same call with an older version id. - */ +/** Serve an existing version: no checkout, no build, no upload. A rollback is + * the same call with an older id. */ async function deployAction( { runTask, jsonMode }: CLIContext, versionId: string, diff --git a/packages/cli/src/cli/commands/versions/options.ts b/packages/cli/src/cli/commands/versions/options.ts index 8afd4c1d..0efdebef 100644 --- a/packages/cli/src/cli/commands/versions/options.ts +++ b/packages/cli/src/cli/commands/versions/options.ts @@ -6,11 +6,8 @@ import { } from "@/core/version/index.js"; /** - * The options the versions lane's commands share. - * - * Built here rather than repeated per command so `publish --help` and - * `versions create --help` cannot describe the same flag differently, and so a - * change to what is accepted lands in one place. + * The options the versions lane's commands share, built here so two `--help` + * screens cannot describe the same flag differently. */ export function outputDirOption(): Option { diff --git a/packages/cli/src/core/version/api.ts b/packages/cli/src/core/version/api.ts index b8a32900..19926d16 100644 --- a/packages/cli/src/core/version/api.ts +++ b/packages/cli/src/core/version/api.ts @@ -16,10 +16,8 @@ import { } from "@/core/version/schema.js"; /** - * Measured on the sandbox's pipe: a 25.5k-asset app moved 27 assets/s at 3, - * needing ~930s of the ~450s a build leaves — SIGKILLed mid-upload every time. - * 8 is the rate the python driver it replaced already sustained to the same - * bucket, and 16 failed a degraded pipe on 2026-07-02. + * Measured on the sandbox's pipe: at 3, a 25.5k-asset app needed ~930s of the + * ~450s a build leaves. 16 failed a degraded pipe on 2026-07-02. */ export const DEFAULT_VERSION_UPLOAD_CONCURRENCY = 8; export const MAX_VERSION_UPLOAD_CONCURRENCY = 16; @@ -48,11 +46,9 @@ function parse(schema: ZodType, body: unknown, what: string): T { } /** - * Declare the artifact set, upload what it names, and commit the version. - * - * Three calls, in that order and no other: nothing is recorded until the bytes - * are in place, so an interrupted run leaves staged objects that expire rather - * than a version naming files that are not there. + * Declare the artifact set, upload what it names, and commit the version. In + * that order: an interrupted run leaves staged objects that expire, never a + * version naming files that are not there. */ export async function createVersion( artifacts: ArtifactSet, @@ -105,8 +101,7 @@ export async function createVersion( absolutePath: file.absolutePath, hash: file.digest, size: file.size, - // Signed into the URL by the server and echoed back on the upload, - // so this value is never the one the PUT sends. + // The PUT echoes the server's signed value, never this one. contentType: "application/octet-stream", }, ]), @@ -131,10 +126,8 @@ export async function createVersion( ); } -/** - * Serve a recorded version. One POST, and the body carries a target name and a - * retry key — everything else is the platform's to resolve. - */ +/** Serve a recorded version. The body carries a target name and a retry key; + * everything else is the platform's to resolve. */ export async function deployVersion( versionId: string, options: { target?: string; idempotencyKey?: string } = {}, diff --git a/packages/cli/src/core/version/artifacts.ts b/packages/cli/src/core/version/artifacts.ts index 8a2d08ee..695bcb11 100644 --- a/packages/cli/src/core/version/artifacts.ts +++ b/packages/cli/src/core/version/artifacts.ts @@ -20,16 +20,12 @@ const ALWAYS_IGNORED = new Set([ ".dev.vars", ]); -/** Every request for an extensionless path is served this, so a set without it - * is a frontend nothing can enter. The platform refuses one; saying so here - * costs the user an upload rather than a round trip. */ +/** The platform refuses a set without it; failing here saves the upload. */ const ENTRY = "index.html"; /** - * Full sha256 over a file's bytes — artifact identity. - * - * Streamed, so a large file is never read whole into memory. Deliberately NOT - * `hashAsset`: see the note on {@link ArtifactFile.digest}. + * Full sha256 over a file's bytes, streamed. Deliberately not `hashAsset` — see + * {@link ArtifactFile.digest}. */ async function digestFile(absolutePath: string): Promise { const hash = createHash("sha256"); @@ -40,11 +36,9 @@ async function digestFile(absolutePath: string): Promise { } /** - * Walk a build's output directory and describe every file in it. - * - * Honors `.assetsignore` at the output root with full gitignore semantics, - * negation included — the same rules the site collector walks by, so the two - * lanes cannot disagree about what a build produced. + * Walk a build's output directory and describe every file in it. Honors + * `.assetsignore` by the same rules the site collector uses, so the two lanes + * cannot disagree about what a build produced. */ export async function collectBuildOutput( outputDir: string, @@ -100,16 +94,14 @@ export async function collectBuildOutput( } /** - * The app's declared entities and agents, RAW. + * The app's declared entities and agents, raw. * - * Deliberately not the validated resource readers: the platform's own extractor - * and validation are authoritative, and the CLI's stricter entity schema would - * refuse real Builder apps. That is exactly why `site deploy` reads no resources - * at all — re-introducing the strict parse here would reproduce the block. + * Not the validated resource readers: the platform's extractor is authoritative, + * and this CLI's stricter entity schema refuses real Builder apps — which is why + * `site deploy` reads no resources at all. * - * The key is the file's path under its directory with the schema extension - * stripped, which is the name the platform derives from the same file. A nested - * agent keeps its subpath, so `agents/support/triage.jsonc` is `support/triage`. + * Keyed by path with the schema extension stripped, the name the platform + * derives from the same file, so `agents/support/triage.jsonc` is `support/triage`. */ async function readRawResources(dir: string): Promise> { if (!(await pathExists(dir))) { diff --git a/packages/cli/src/core/version/gate.ts b/packages/cli/src/core/version/gate.ts index eb9bfd22..0edf3793 100644 --- a/packages/cli/src/core/version/gate.ts +++ b/packages/cli/src/core/version/gate.ts @@ -1,19 +1,13 @@ const VERSIONS_API_ENV = "BASE44_VERSIONS_API"; /** - * Internal gate for the versions lane — `base44 publish` and the `versions` - * group, neither user-facing yet. With it off the commands are **not registered - * at all**, so they are absent from `--help` and typing one is an unknown - * command, rather than a half-integrated lane a user can stumble into. + * Internal gate for the versions lane, not user-facing yet. With it off + * `publish` and `versions` are not registered at all, so they are absent from + * `--help` rather than half-integrated commands a user can stumble into. * - * Deliberately not `BASE44_DEPLOYMENTS_API`: that one selects the legacy - * deployments transport for `site deploy`, and the build sandbox already sets it - * for that arm. One var switching both lanes would make them impossible to roll - * out apart. - * - * `base44 build` is NOT gated. It is a pre-existing public command, and what - * changed there — no credential, and config defaults for a repo that has none — - * is what lets the sandbox build before it holds a publish key. + * Not `BASE44_DEPLOYMENTS_API`: that selects the legacy transport for + * `site deploy` and the sandbox already sets it, so one var would tie the two + * lanes together. */ export function versionsApiEnabled( env: NodeJS.ProcessEnv = process.env, diff --git a/packages/cli/src/core/version/project.ts b/packages/cli/src/core/version/project.ts index d1d870f2..f9c8acad 100644 --- a/packages/cli/src/core/version/project.ts +++ b/packages/cli/src/core/version/project.ts @@ -4,10 +4,7 @@ import { ConfigNotFoundError } from "@/core/errors.js"; import { readProjectSettings } from "@/core/project/index.js"; import type { ProjectWithPaths } from "@/core/project/types.js"; -/** - * What a Builder repo builds like when nobody wrote a CLI config for it. Both - * come from the app template every Builder app is seeded from. - */ +/** From the app template every Builder app is seeded from. */ const DEFAULT_BUILD_COMMAND = "npm run build"; const DEFAULT_OUTPUT_DIRECTORY = "dist"; @@ -15,12 +12,11 @@ interface PublishTarget { root: string; /** Where `entitiesDir` and `agentsDir` are resolved from. */ configDir: string; - /** `undefined` when a config is present and declares none — `runSiteBuild` + /** `undefined` when a config is present and declares none; `runSiteBuild` * reports that, as it always has. */ buildCommand?: string; - /** `null` for the same reason, resolved by {@link requireOutputDir} at the - * point of collection — so a project missing both is told about its build - * command first, which is the one it hits first. */ + /** `null` for the same reason. {@link requireOutputDir} raises at collection, + * so a project missing both hears about its build command first. */ outputDir: string | null; entitiesDir: string; agentsDir: string; @@ -28,17 +24,11 @@ interface PublishTarget { /** * Resolve where to build and what to publish, filling in what a Builder repo - * does not carry. + * does not carry — without writing a file. The sandbox used to overwrite + * `base44/config.jsonc` with a minimal one, destroying checked-in configuration. * - * Builder repos have NO CLI project config, which is why the sandbox used to - * overwrite `base44/config.jsonc` with a minimal one — destroying any - * checked-in configuration, and for a full-stack app destroying its build - * command. Nothing here writes a file. - * - * The defaults apply only when there is no config AT ALL. A config that is - * present and omits a field said so deliberately, and answering that with a - * guessed `npm run build` would silently change what `base44 build` does for - * every project that already relies on the error. + * Defaults apply only when there is no config at all: one that omits a field + * said so deliberately, and still gets today's error. */ export async function resolvePublishTarget( projectRoot: string | undefined, @@ -88,11 +78,9 @@ export function requireOutputDir(target: PublishTarget): string { } /** - * The project's own settings, or `null` when it has none. - * - * Only a MISSING config is answered with `null`: a config that is present and - * invalid still throws, because publishing past a broken one is how a typo - * becomes a version built the wrong way. + * The project's settings, or `null` when it has none. A config that is present + * and invalid still throws — publishing past a broken one is how a typo becomes + * a version built the wrong way. */ async function readSettingsIfPresent( projectRoot?: string, diff --git a/packages/cli/src/core/version/publish.ts b/packages/cli/src/core/version/publish.ts index 45b0eb35..08d4ab3c 100644 --- a/packages/cli/src/core/version/publish.ts +++ b/packages/cli/src/core/version/publish.ts @@ -6,20 +6,16 @@ import type { } from "@/core/version/schema.js"; /** - * Which of the three steps a publish broke in. - * - * A user's build failing, an artifact set the platform refused and a lost - * publication race are three different incidents with three different - * responses. Collapsing them into one exit code is how a sandbox log stops - * being diagnostic, so the step travels with the failure and out through the - * `--json` envelope. + * Which of the three steps a publish broke in. A failed build, a rejected + * artifact set and a lost publication race need three different responses, so + * the step travels with the failure and out through the `--json` envelope. */ type PublishStep = "build" | "create_version" | "deploy"; const STEP = Symbol.for("base44.publishStep"); -/** Tag an error with the step it broke in, without wrapping it — the original - * type, message, status and request id all still reach the envelope. */ +/** Tag an error with its step without wrapping it, so the original type, + * status and request id still reach the envelope. */ export async function tagStep( step: PublishStep, run: () => Promise, @@ -51,11 +47,8 @@ interface PublishResult { } /** - * Record the artifact set as a version and serve it. - * - * The deploy carries a key generated once here, so a lost response is resolved - * by reading back the deployment this call already made rather than preparing a - * second candidate. + * Record the artifact set as a version and serve it. The deploy key is generated + * once here, so a lost response reads back the deployment this call already made. */ export async function publishVersion( artifacts: ArtifactSet, diff --git a/packages/cli/src/core/version/schema.ts b/packages/cli/src/core/version/schema.ts index 38fc0841..554699ea 100644 --- a/packages/cli/src/core/version/schema.ts +++ b/packages/cli/src/core/version/schema.ts @@ -1,15 +1,12 @@ import { z } from "zod"; /** - * A file the build produced, as the version plane names it. + * A file the build produced. * - * `digest` is a FULL sha256 over the file's bytes — artifact identity, and what - * the platform signs into the upload URL so S3 refuses any other body. It is not - * {@link import("@/core/site/manifest.js").hashAsset}, which is a 32-hex - * truncation of sha256(app id ‖ bytes) and exists to key a provider's asset - * cache. Different purpose, different moment, different value: conflating them - * produces a file that uploads fine and never dedupes, or a digest check that - * fails on a correct file. + * `digest` is a full sha256 over the bytes — artifact identity, signed into the + * upload URL so S3 refuses any other body. Not `hashAsset`, which truncates + * sha256(app id ‖ bytes) to key a provider's asset cache; conflating the two + * gives a file that uploads fine and never dedupes. */ export interface ArtifactFile { /** Build-relative, forward slashes, no leading "/". */ From 652021ef670cdabca254a0962fdbd5e40c730120 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Tue, 15 Sep 2026 14:23:42 +0300 Subject: [PATCH 09/21] refactor: drop `deduplicated` and `revision` from the wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `deduplicated` was derived state: the response already carries `manifest_hash`, which IS the version's identity, so a caller asking whether a rebuild changed anything compares that. The flag also answered two different questions — line 245 hardcoded it for "you already made this call", the other branch computed "this content already existed" — and an existing version is not necessarily the one being served, so it was misleading either way. `revision` was read back from the environment AFTER publication, so a concurrent deploy could bump it in between and the caller would be told someone else's number. `store.publish` returns the right one; `deploy()` logs it and drops it, because `Deployment` has no such field. Reporting it honestly would mean widening the primitive, and nothing consumes it. Both existed to print five words. The conditional-write fence that makes publication safe is untouched — that is `Environment.revision` in the store, which nothing on the wire ever needed. Co-Authored-By: Claude Opus 5 (1M context) --- docs/versions.md | 6 ++++-- packages/cli/src/cli/commands/publish.ts | 8 ++------ packages/cli/src/cli/commands/versions/deploy.ts | 2 +- packages/cli/src/core/version/publish.ts | 2 -- packages/cli/src/core/version/schema.ts | 5 +---- packages/cli/tests/cli/publish.spec.ts | 15 +++------------ packages/cli/tests/cli/testkit/TestAPIServer.ts | 2 -- 7 files changed, 11 insertions(+), 29 deletions(-) diff --git a/docs/versions.md b/docs/versions.md index b3a77a8e..77fc1c23 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -20,7 +20,9 @@ It is **not** `src/core/site/` — see [Deployments](deployments.md). That lane 1. **Declare.** `POST versions` with `static_bundle` (path, size, digest per file), the raw `entities` and `agents` payloads, and `source_commit`. The response carries a `session_id` and one presigned PUT per file. 2. **Upload.** `uploadPresignedAssets` — the same function the static deployments lane uses, same `pMap` concurrency and same ky retry policy. Each PUT sends the server's `Content-Type` **and** its `x-amz-checksum-sha256` verbatim; deriving either locally would 403 on any mapping difference. -3. **Finalize.** `POST versions/{session_id}/finalize`, no body. The set was fixed at declare, so there is nothing left for the caller to change. The response says whether the content deduplicated to a version that already existed. +3. **Finalize.** `POST versions/{session_id}/finalize`, no body. The set was fixed at declare, so there is nothing left for the caller to change. + +The response carries `version_id` and `manifest_hash`, and no flag for "this content already existed" — the hash **is** the identity, so a caller asking whether a rebuild changed anything compares it against the last one. An existing version is not necessarily the one being served, so such a flag would be misleading anyway. Each of the three responses is parsed through its Zod schema and a mismatch raises `SchemaValidationError` — the house pattern from [Making API calls](api-patterns.md), and the only thing keeping this client aligned with the server. There is no generated type and no shared contract fixture here, the same as every other CLI↔platform surface: a server that renames or retypes a response field fails the publish with an error naming the field, rather than propagating `undefined`. In the other direction the server's request models forbid unknown fields, so a field this client sends that the server no longer accepts is a 422. @@ -60,7 +62,7 @@ A callback that throws synchronously is tagged too; `run().catch(...)` would let ## Commands -**`base44 publish [--no-build] [--output-dir ] [--target ] [--git-hash ] [--concurrency ]`** — build, record a version, serve it. Under `--json`, stdout is a single `{versionId, manifestHash, deduplicated, deploymentId, revision}` document. +**`base44 publish [--no-build] [--output-dir ] [--target ] [--git-hash ] [--concurrency ]`** — build, record a version, serve it. Under `--json`, stdout is a single `{versionId, manifestHash, deploymentId}` document. **`base44 versions create`** — record built output without serving it. A version can sit unpublished for as long as it likes. diff --git a/packages/cli/src/cli/commands/publish.ts b/packages/cli/src/cli/commands/publish.ts index 6a36c158..54ee1841 100644 --- a/packages/cli/src/cli/commands/publish.ts +++ b/packages/cli/src/cli/commands/publish.ts @@ -77,14 +77,10 @@ async function publishAction( ); if (!jsonMode) { - log.message( - theme.styles.dim( - `version ${result.versionId}${result.deduplicated ? " (existing content)" : ""}`, - ), - ); + log.message(theme.styles.dim(`version ${result.versionId}`)); } return { - outroMessage: `Deployment ${result.deploymentId} at revision ${result.revision}`, + outroMessage: `Deployment ${result.deploymentId}`, stdout: jsonMode ? `${JSON.stringify(result, null, 2)}\n` : undefined, }; } diff --git a/packages/cli/src/cli/commands/versions/deploy.ts b/packages/cli/src/cli/commands/versions/deploy.ts index b495bcfb..2ce0fa19 100644 --- a/packages/cli/src/cli/commands/versions/deploy.ts +++ b/packages/cli/src/cli/commands/versions/deploy.ts @@ -27,7 +27,7 @@ async function deployAction( ); return { - outroMessage: `Deployment ${deployment.deploymentId} at revision ${deployment.revision}`, + outroMessage: `Deployment ${deployment.deploymentId}`, stdout: jsonMode ? `${JSON.stringify(deployment, null, 2)}\n` : undefined, }; } diff --git a/packages/cli/src/core/version/publish.ts b/packages/cli/src/core/version/publish.ts index 08d4ab3c..1ba59d95 100644 --- a/packages/cli/src/core/version/publish.ts +++ b/packages/cli/src/core/version/publish.ts @@ -41,9 +41,7 @@ export function stepOf(error: unknown): PublishStep | undefined { interface PublishResult { versionId: string; manifestHash: string; - deduplicated: boolean; deploymentId: string; - revision: number; } /** diff --git a/packages/cli/src/core/version/schema.ts b/packages/cli/src/core/version/schema.ts index 554699ea..eb51cebe 100644 --- a/packages/cli/src/core/version/schema.ts +++ b/packages/cli/src/core/version/schema.ts @@ -56,13 +56,12 @@ export const DeclareVersionResponseSchema = z export const CreateVersionResponseSchema = z .object({ version_id: z.string(), + /** The identity: unchanged across two builds means unchanged content. */ manifest_hash: z.string(), - deduplicated: z.boolean(), }) .transform((data) => ({ versionId: data.version_id, manifestHash: data.manifest_hash, - deduplicated: data.deduplicated, })); export type CreateVersionResponse = z.infer; @@ -71,12 +70,10 @@ export const DeployVersionResponseSchema = z .object({ deployment_id: z.string(), manifest_hash: z.string(), - revision: z.number(), }) .transform((data) => ({ deploymentId: data.deployment_id, manifestHash: data.manifest_hash, - revision: data.revision, })); export type DeployVersionResponse = z.infer; diff --git a/packages/cli/tests/cli/publish.spec.ts b/packages/cli/tests/cli/publish.spec.ts index abadcf9d..5cde8563 100644 --- a/packages/cli/tests/cli/publish.spec.ts +++ b/packages/cli/tests/cli/publish.spec.ts @@ -18,15 +18,10 @@ describe("publish command", () => { .mockVersionDeclare(SESSION) .mockPresignedUpload("/index.html") .mockPresignedUpload("/assets/app.js") - .mockVersionFinalize({ - version_id: "ver-1", - manifest_hash: "sha256:abc", - deduplicated: false, - }) + .mockVersionFinalize({ version_id: "ver-1", manifest_hash: "sha256:abc" }) .mockVersionDeploy({ deployment_id: "dep-1", manifest_hash: "sha256:abc", - revision: 4, }); }; @@ -88,8 +83,8 @@ describe("publish command", () => { }); it("carries only a target name and a retry key into the deploy", async () => { - // Everything else — the app, the principal, env vars, the revision — is the - // platform's to resolve, and there is deliberately no field for any of them. + // Everything else — the app, the principal, env vars — is the platform's to + // resolve, and there is deliberately no field for any of them. t.givenEnv({ BASE44_VERSIONS_API: "1" }); await t.givenLoggedInWithProject(fixture("publishable")); mockPublishApi(); @@ -117,9 +112,7 @@ describe("publish command", () => { expect(JSON.parse(result.stdout)).toEqual({ versionId: "ver-1", manifestHash: "sha256:abc", - deduplicated: false, deploymentId: "dep-1", - revision: 4, }); }); @@ -166,7 +159,6 @@ describe("versions deploy command", () => { t.api.mockVersionDeploy({ deployment_id: "dep-9", manifest_hash: "sha256:old", - revision: 12, }); const result = await t.run("versions", "deploy", "ver-old", "--json"); @@ -177,7 +169,6 @@ describe("versions deploy command", () => { expect(JSON.parse(result.stdout)).toEqual({ deploymentId: "dep-9", manifestHash: "sha256:old", - revision: 12, }); }); }); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index f326217f..fd94a97e 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -892,7 +892,6 @@ export class TestAPIServer { mockVersionFinalize(response: { version_id: string; manifest_hash: string; - deduplicated: boolean; }): this { return this.addRoute( "POST", @@ -904,7 +903,6 @@ export class TestAPIServer { mockVersionDeploy(response: { deployment_id: string; manifest_hash: string; - revision: number; }): this { this.pendingRoutes.push({ method: "POST", From 85429f91eab25987e0199c1852daa79a2d663b67 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Tue, 15 Sep 2026 15:36:32 +0300 Subject: [PATCH 10/21] fix: bound the hash fan-out, and tag local validation as create_version **EMFILE at ~1.2k files.** `collectBuildOutput` opened one descriptor per file through a bare `Promise.all`, so it died far below the 100k it advertises. Now bounded with `p-map` at 32. The test writes its fixture sequentially and was verified to fail with EMFILE against the unbounded version under `ulimit -n 256` and pass against the bounded one. **A missing output directory emitted no `step`.** Resolving the directory and reading it ran outside `tagStep`, so the sandbox could not tell a rejected build output from a transport failure. Producing the artifact set is part of create_version, so it is tagged as one. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/src/cli/commands/publish.ts | 10 +- packages/cli/src/core/version/artifacts.ts | 15 ++- packages/cli/tests/cli/publish.spec.ts | 19 ++++ .../cli/tests/core/version-artifacts.spec.ts | 20 ++++ pr-review-626-24525.html | 76 ++++++++++++++ pr-review-626-24525.md | 99 +++++++++++++++++++ 6 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 pr-review-626-24525.html create mode 100644 pr-review-626-24525.md diff --git a/packages/cli/src/cli/commands/publish.ts b/packages/cli/src/cli/commands/publish.ts index 54ee1841..2495c159 100644 --- a/packages/cli/src/cli/commands/publish.ts +++ b/packages/cli/src/cli/commands/publish.ts @@ -52,15 +52,17 @@ async function publishAction( ); } - const outputDir = requireOutputDir(target); const gitHash = await resolveProvenanceCommit(target.root, options.gitHash); const result = await runTask( "Publishing...", async (updateMessage) => { - const artifacts = { - files: await collectBuildOutput(outputDir), + // Inside the tag: resolving the output directory and reading it are part + // of producing the version, so a missing directory is a create_version + // failure rather than an envelope with no step at all. + const artifacts = await tagStep("create_version", async () => ({ + files: await collectBuildOutput(requireOutputDir(target)), ...(await collectResources(target.configDir, target)), - }; + })); return await publishVersion(artifacts, { sourceCommit: gitHash, target: options.target, diff --git a/packages/cli/src/core/version/artifacts.ts b/packages/cli/src/core/version/artifacts.ts index 695bcb11..d69b3e62 100644 --- a/packages/cli/src/core/version/artifacts.ts +++ b/packages/cli/src/core/version/artifacts.ts @@ -3,6 +3,7 @@ import { createReadStream } from "node:fs"; import { stat } from "node:fs/promises"; import { basename, join } from "node:path"; import { globby } from "globby"; +import pMap from "p-map"; import { CONFIG_FILE_EXTENSION_GLOB } from "@/core/consts.js"; import { InvalidInputError } from "@/core/errors.js"; import { pathExists, readJsonFile } from "@/core/utils/fs.js"; @@ -11,6 +12,9 @@ import type { ArtifactFile, ArtifactSet } from "@/core/version/schema.js"; /** The same ceiling the site collector applies; one build, one limit. */ const MAX_FILE_COUNT = 100_000; +/** Open descriptors while hashing. Well under the 256 a production Node keeps. */ +const HASH_CONCURRENCY = 32; + const ASSETS_IGNORE_FILE = ".assetsignore"; /** Never part of a frontend, whatever `.assetsignore` says. */ @@ -79,8 +83,12 @@ export async function collectBuildOutput( ); } - return await Promise.all( - relativePaths.map(async (path) => { + // Bounded: one open descriptor per file, and the advertised ceiling is 100k. + // An unbounded Promise.all hits EMFILE at ~1.5k on a default descriptor limit, + // long before any of the declared limits. + return await pMap( + relativePaths, + async (path) => { const absolutePath = join(outputDir, ...path.split("/")); const { size } = await stat(absolutePath); return { @@ -89,7 +97,8 @@ export async function collectBuildOutput( size, digest: await digestFile(absolutePath), }; - }), + }, + { concurrency: HASH_CONCURRENCY }, ); } diff --git a/packages/cli/tests/cli/publish.spec.ts b/packages/cli/tests/cli/publish.spec.ts index 5cde8563..646e0d85 100644 --- a/packages/cli/tests/cli/publish.spec.ts +++ b/packages/cli/tests/cli/publish.spec.ts @@ -135,6 +135,25 @@ describe("publish command", () => { }); }); + it("names create_version when the output directory is missing", async () => { + // Local validation is part of producing the version. Before, this emitted an + // envelope with no `step`, so a sandbox could not tell a rejected build + // output from a transport failure. + t.givenEnv({ BASE44_VERSIONS_API: "1" }); + await t.givenLoggedInWithProject(fixture("publishable")); + + const result = await t.run( + "publish", + "--no-build", + "--output-dir", + "does-not-exist", + "--json", + ); + + t.expectResult(result).toFail(); + expect(JSON.parse(result.stdout).step).toBe("create_version"); + }); + it("builds first unless told not to", async () => { t.givenEnv({ BASE44_VERSIONS_API: "1" }); await t.givenLoggedInWithProject(fixture("publishable")); diff --git a/packages/cli/tests/core/version-artifacts.spec.ts b/packages/cli/tests/core/version-artifacts.spec.ts index 72b698b6..65f641cf 100644 --- a/packages/cli/tests/core/version-artifacts.spec.ts +++ b/packages/cli/tests/core/version-artifacts.spec.ts @@ -70,6 +70,26 @@ describe("collectBuildOutput", () => { expect(paths).toEqual(["index.html"]); }); + it("hashes a large set without exhausting file descriptors", async () => { + // An unbounded Promise.all opens one descriptor per file and dies with + // EMFILE around 1.5k on a default limit — well under the 100k this + // advertises. 1200 is enough to fail the unbounded version reliably. + // Written sequentially: the point under test is the COLLECTOR's fan-out, + // so the fixture must not be what runs out of descriptors. + await mkdir(join(outputDir, "many")); + for (let i = 0; i < 1200; i++) { + await writeFile( + join(outputDir, "many", `f${i}.js`), + `export const x = ${i};`, + ); + } + + const files = await collectBuildOutput(outputDir); + + expect(files).toHaveLength(1201); + expect(new Set(files.map((f) => f.digest)).size).toBe(1201); + }, 30_000); + it("refuses a set with no entry point", async () => { await rm(join(outputDir, "index.html")); await writeFile(join(outputDir, "app.js"), "console.log(1);"); diff --git a/pr-review-626-24525.html b/pr-review-626-24525.html new file mode 100644 index 00000000..652645b7 --- /dev/null +++ b/pr-review-626-24525.html @@ -0,0 +1,76 @@ +CLI #626 + apper #24525 — architecture review

Template: T3 · Review — correctness, SOLID, KISS, and design compliance.

+

Task: Review the CLI producer and backend version-publishing workflow together.

+

Where we stand: Changes requested; correctness and rollout blockers remain.

+

Last time: Proposed order was CLI merge/release, then backend merge.

+

Now: Findings against CLI 652021e and apper b3f5e04f, checked on 15 September 2026.

+

Blockers: 3 · Blast radius: shared publishing infrastructure and enrolled apps’ runtime schemas.

+

The change separates recording a build from serving it

+

Before. Builder prepared a commit’s frontend, then Python recorded and deployed its artifacts.

+

After. A temporary sandbox runs the CLI, which declares files, uploads them to S3, finalizes a version, and requests deployment.

+

Why this shape. Keeping manifest identity, storage keys, runtime secrets, and publication on the server follows the design. Separately callable create/deploy operations are the right foundation.

+

Shared helpers widen the review beyond the new commands

+
+ + + + +
Changed surfaceReachEvidence
s3_service.py26 direct production/script importing filesCounted Python imports at the reviewed backend head; the new argument is optional, so existing callers retain their behavior
Base44Command57 CLI files reference this symbol, including its definition and exportCounted source references; shared lifecycle now imports version-specific error tagging
build_deploy/service.py2 direct production importing filesBuilder publishing and the new versions API
sandbox_build_operations.py1 direct production importing fileShared sandbox integration; both legacy and version paths use the extracted checkout/install helper
+

The HTML report includes direct-import counts for every changed production module. These are static module references, not runtime call counts.

+

Verification covers local behavior, not a live deployment

+
+ + + + + + + +
AreaCheckedResult
Changed CLI/backend implementation and adjacent consumersRead it onlyTraced collection, authentication, staging, finalization, publication, schema reads, and outer Builder flow
Focused CLI testsRan it47 tests passed across six build/version test files
Pinned SDK compatibilityRan itActual argument validation against the official pinned S3 model rejects the new copy argument
Collector and failure envelopesRan itReproduced missing User, EMFILE under Node, and an error envelope without step
Backend validation and retry scenariosRan isolated probesExecuted actual function bodies with storage/data-container doubles; not a full backend integration run
Current CIRead logs/statusCLI Windows dev-server timeout; backend shard failure from unused marketing_os.compile.* observability registrations. These failures are outside this diff
Real S3 upload/copy, sandbox image, cold deploy, rollbackDid not runStaging proof remains missing
+

Apper was rebased during this review. The reviewed publishing modules, SDK pins, and supporting runtime/auth paths are unchanged between the initial 5bd89e5a and final b3f5e04f heads.

+

Fix these before merging the complete workflow

+
+
+ + + + + + + + +
SeverityWhereFinding and consequenceCheckedRequired change
🔴 blocker · P1apper s3_service.py:472Every nonempty staged finalization passes IfNoneMatch to CopyObject, but the pinned SDK rejects it with ParamValidationError before sending a request. The S3 mocks hide this.Ran SDK-model validationUpgrade the compatible AWS SDK dependency set, or implement a create-only operation supported by the pin. Preserve destination immutability; CopySourceIfNoneMatch checks the source and is not a substitute.
🔴 blocker · P1CLI artifacts.ts:123A repo without User.jsonc sends no User schema. Python’s schemas.extract() always adds User: {}. Finalize stores the CLI omission unchanged; runtime treats it as deletion, and UserCRUD raises EntitySchemaNotFoundError. Ordinary user-management APIs break after publication.Ran both producer extractions; read runtime consequencePut built-in schema normalization at the shared server boundary. Compare the real CLI collector against Python source extraction, including absent/customized User.
🔴 blocker · P1, rolloutapper image pin and new invocationThe sandbox pin remains 0.1.14. Publishing a newer CLI package does not change the image’s installed CLI. The backend immediately relies on the new build behavior and publish command.Read pin, Dockerfiles, and invocationRelease the CLI, update the image pin, rebuild and roll the relevant providers’ images, then verify the actual executable and smoke-test this path before enabling the backend switch.
🟠 fix in this PR · P2CLI artifacts.ts:82Promise.all starts one streaming hash per file without a concurrency bound. Under production Node with a 256-descriptor limit, a 1,500-file fixture fails with EMFILE, well below the advertised file-count ceiling.Reproduced under Node 24Bound stat/hash work with the existing p-map utility. Upload concurrency does not limit this earlier phase.
🟠 fix in this PR · P2apper versions_api.py:294 and receipt write:323The retry receipt is written to Redis after publication. If that write fails, the endpoint reports failure although the version is live. Retrying the same key can publish again; the isolated handler probe produced A → B → A. Concurrent misses are also unclaimed, and the receipt stores neither requested version nor target.Ran post-publication failure probe; read other casesPersist/recover request identity with the deployment and reject reuse for a different request. A receipt-cache failure must not misreport a known committed publication.
🟠 fix in this PR · P2apper producer.py:137The schema-byte budget counts only dictionary values. A schema supplied as a string larger than the budget passes declaration and is serialized into Redis; resource names are excluded from the budget too. Later rejection does not bound ingestion.Ran actual validator with an oversized non-object valueReject non-object schemas before saving; bound the complete serialized schema map, including names.
🟠 fix in this PR · P2apper versions_api.py:243An empty declaration can finalize into a version; neither declaration nor validate_artifacts() requires index.html. The requirement is checked only in static_bundle.prepare() during deploy. Create-version success therefore does not mean the admitted static artifact set is deployable.Ran declaration validator; read finalize/store/prepareEnforce static completeness at the shared version-validation boundary before recording the version. Keep fullstack validation separate.
🟠 fix in this PR · P2CLI publish.ts:55Output-directory resolution and artifact/resource collection run outside tagStep. A missing output directory emits JSON without step, so the sandbox cannot distinguish rejected build output from a transport failure.Reproduced using the actual CLITag the whole collection/create operation, including local validation, as create_version. Preserve the original error type.
+

🔴 blocker — do not merge the workflow · 🟠 fix in this PR — belongs in these changes.

+

SOLID/KISS: keep the primitives, fix the shared rules

+
+ + + + + +
PrincipleAssessment
Single responsibilityBuild, version storage, and deployment remain distinct. However, finalization’s verify → construct → create → save receipt → cleanup workflow exists only in the HTTP handler. Move that composition behind an ordinary Python service function.
Open/closed and interface segregationExplicit calls per artifact kind and small request bodies are appropriate. A generic registry, matching interfaces for unrelated kinds, or new microservices would add work without solving these defects.
SubstitutionIn-memory and staged static storage share index construction, which is sound. The real producers are not interchangeable yet: their treatment of built-in User differs. The equivalence test hand-builds identical payloads and misses that boundary.
Dependency directionCLI core remains free of UI dependencies. Move tagStep/stepOf into shared error infrastructure: the common command lifecycle should not import the feature’s publish composer.
SimplicityReusing the uploader and centralizing deploy-plus-domain-binding are good choices. Avoid treating post-publication Redis bookkeeping as a second authority. Keep the retained in-process path only if its supported use is explicit and tested against the same normalization rules.
+

The design deviations are larger than naming or style

+
+ + + + + + + + +
Design requirementActual behaviorAssessment
Fullstack first; complete, versioned artifact descriptor — §§2.1, 19, 21Static file list only; index.html is mandatory; the backend explicitly refuses fullstack apps; Build returns a status message, not an artifact descriptorMajor scope deviation. Useful static transport work, but not the planned fullstack milestone. The wire lacks an explicit format version/site discriminator.
One checkout and one build — §§18.4, 20.1Outer _prepare_commit_build() still prepares the legacy artifact, then the new lane starts another sandbox and frontend buildThe outer preparation predates this PR; the second frontend build comes from replacing reuse of ExistingCommitDist with a new CLI build. Pass one prepared version forward.
Committed dependencies or recorded pinned overlay — §4New path removes update_packages, but still calls the installer that runs npm install --ignore-scriptsImprovement, not lockfile-exact reproduction. Use an immutable install policy for this lane.
Serving authority survives admission being disabled — §19runtime.current() returns None when the flag is off; the description advertises flag-off rollback to legacy pointersInherited implementation, still contrary to the design. A kill switch is not a safe rollback once legacy pointers are stale.
Session-bound publishing capability — §16.1Late-minted, app-scoped apps:deploy key; no upload-session/version bindingApp scoping and revocation help, but the key can deploy another known retained version of that app. The broader scope needs an explicit design decision; late injection does not isolate background build processes.
Unsupported resources fail explicitly — §§19, 20.1CLI collects only files/entities/agents; backend function refusal consults current app metadataLocal newly added function files can be omitted without refusal when the app’s metadata still reports no functions. Admission must account for the submitted build’s declared capabilities. The design’s unversioned-agent/skill admission rule is also not represented here.
Endpoint adds no behavior — §§17, 20.1Deploy-plus-bind is shared, but admission enforcement/finalization and retry handling remain in HTTP orchestrationSharing helper functions is not enough: expose the same application operation to Python and HTTP callers. Keep transport parsing/auth/status mapping in the adapter.
Server verifies staged content — §7S3 verifies a signed SHA-256 at PUT time; finalize checks presence/size and copiesReasonable documented substitution, conditional on real S3 enforcement. Presigned URLs are reusable until expiry; the signature fixes the allowed bytes, not the number of writes.
+

References: design document, outer preparation, legacy frontend reuse, mutable installer, runtime flag check, AWS presigned URL semantics.

+

The missing staging proof changes the merge decision

+

Green unit tests cannot establish image rollout, S3 checksum enforcement, or reconstruction after the builder disappears. Full backend tests were not run locally in this review. Current CI also remains red: CLI Windows failure, backend shard failure.

+

Verify the repaired workflow in this order

+
    +
  1. Run create-version using the repository’s locked AWS SDK; expect successful conditional copy, plus refusal of a wrong-checksum upload against real S3.
  2. +
  3. Publish an app without User.jsonc; expect User: {} in the stored artifact set and working user-management endpoints. Repeat with customized User fields.
  4. +
  5. Collect a large fixture under a low descriptor limit; expect bounded hashing and success. Missing output must produce step: create_version.
  6. +
  7. Fail the retry-receipt write after publication, publish a newer version, and retry the original request; expect recovery of the original result without republishing it.
  8. +
  9. Reject oversized/non-object schemas and incomplete static output before version creation succeeds.
  10. +
  11. Run the pinned sandbox CLI once, finalize, destroy the sandbox, then deploy/redeploy/rollback from S3. For the stated design milestone, include SSR, a server route, and an asset from a real fullstack fixture.
  12. +
+

In one line: These PRs add a useful static version-ingress path, but the current implementation can fail finalization, omit a required schema, and does not deliver the fullstack-first workflow.

+
Direct importer counts for changed production modules

Computed from static imports at the reviewed heads. Barrel exports can hide many indirect callers; a direct count is not a runtime fan-out estimate.

RepositoryModuleDirect importing filesReferences
clipackages/cli/src/cli/commands/project/build.ts1packages/cli/src/cli/program.ts
clipackages/cli/src/cli/commands/publish.ts1packages/cli/src/cli/program.ts
clipackages/cli/src/cli/commands/versions/create.ts1packages/cli/src/cli/commands/versions/index.ts
clipackages/cli/src/cli/commands/versions/deploy.ts1packages/cli/src/cli/commands/versions/index.ts
clipackages/cli/src/cli/commands/versions/index.ts1packages/cli/src/cli/program.ts
clipackages/cli/src/cli/commands/versions/options.ts3packages/cli/src/cli/commands/publish.ts
packages/cli/src/cli/commands/versions/create.ts
packages/cli/src/cli/commands/versions/deploy.ts
clipackages/cli/src/cli/program.ts1packages/cli/src/cli/index.ts
clipackages/cli/src/cli/utils/command/Base44Command.ts1packages/cli/src/cli/utils/command/index.ts
clipackages/cli/src/core/site/git-hash.ts1packages/cli/src/core/site/index.ts
clipackages/cli/src/core/site/schema.ts8packages/cli/src/core/project/api.ts
packages/cli/src/core/site/api.ts
packages/cli/src/core/site/deploy.ts
packages/cli/src/core/site/deployment.ts
packages/cli/src/core/site/index.ts
packages/cli/src/core/site/manifest.ts
packages/cli/src/core/site/modules.ts
packages/cli/src/core/site/upload.ts
clipackages/cli/src/core/site/upload.ts3packages/cli/src/core/site/deployment.ts
packages/cli/src/core/site/index.ts
packages/cli/src/core/version/api.ts
clipackages/cli/src/core/version/api.ts2packages/cli/src/core/version/index.ts
packages/cli/src/core/version/publish.ts
clipackages/cli/src/core/version/artifacts.ts1packages/cli/src/core/version/index.ts
clipackages/cli/src/core/version/gate.ts2packages/cli/src/cli/program.ts
packages/cli/src/core/version/index.ts
clipackages/cli/src/core/version/index.ts5packages/cli/src/cli/commands/project/build.ts
packages/cli/src/cli/commands/publish.ts
packages/cli/src/cli/commands/versions/create.ts
packages/cli/src/cli/commands/versions/deploy.ts
packages/cli/src/cli/commands/versions/options.ts
clipackages/cli/src/core/version/project.ts1packages/cli/src/core/version/index.ts
clipackages/cli/src/core/version/publish.ts2packages/cli/src/cli/utils/command/Base44Command.ts
packages/cli/src/core/version/index.ts
clipackages/cli/src/core/version/schema.ts4packages/cli/src/core/version/api.ts
packages/cli/src/core/version/artifacts.ts
packages/cli/src/core/version/index.ts
packages/cli/src/core/version/publish.ts
apperbackend/app/user_apps/app_deployments/builder_api.py1backend/route_registry.py
apperbackend/app/user_apps/build_deploy/artifact_store.py8backend/app/user_apps/app_deployments/s3_dist_service.py
backend/app/user_apps/build_deploy/deploy.py
backend/app/user_apps/build_deploy/kinds/schemas.py
backend/app/user_apps/build_deploy/kinds/static_bundle.py
backend/app/user_apps/build_deploy/kinds/worker_bundles.py
backend/app/user_apps/build_deploy/producer.py
backend/app/user_apps/build_deploy/runtime.py
backend/app/user_apps/build_deploy/version.py
apperbackend/app/user_apps/build_deploy/contracts.py11backend/app/user_apps/build_deploy/build.py
backend/app/user_apps/build_deploy/deploy.py
backend/app/user_apps/build_deploy/kinds/schemas.py
backend/app/user_apps/build_deploy/kinds/static_bundle.py
backend/app/user_apps/build_deploy/kinds/worker_bundles.py
backend/app/user_apps/build_deploy/producer.py
backend/app/user_apps/build_deploy/runtime.py
backend/app/user_apps/build_deploy/service.py
backend/app/user_apps/build_deploy/store.py
backend/app/user_apps/build_deploy/version.py
backend/app/user_apps/build_deploy/versions_api.py
apperbackend/app/user_apps/build_deploy/kinds/static_bundle.py3backend/app/user_apps/build_deploy/build.py
backend/app/user_apps/build_deploy/deploy.py
backend/app/user_apps/build_deploy/version.py
apperbackend/app/user_apps/build_deploy/producer.py1backend/app/user_apps/build_deploy/versions_api.py
apperbackend/app/user_apps/build_deploy/service.py2backend/app/user_apps/app_deployments/builder_api.py
backend/app/user_apps/build_deploy/versions_api.py
apperbackend/app/user_apps/build_deploy/versions_api.py1backend/route_registry.py
apperbackend/app/user_apps/s3/s3_service.py26backend/app/agent_ops/service.py
backend/app/ai/providers/llm_dump.py
backend/app/fullstack_hosting/service.py
backend/app/gdpr/data_sources/llm_dumps.py
backend/app/preview_frontend.py
backend/app/preview_frontend_bundle.py
backend/app/skills_registry/registry_service.py
backend/app/static_files_fallback.py
backend/app/user_apps/admin/core_integrations_api.py
backend/app/user_apps/admin/import_export.py
backend/app/user_apps/app_deployments/build_utils.py
backend/app/user_apps/app_deployments/dist_upload_service.py
backend/app/user_apps/app_deployments/s3_dist_service.py
backend/app/user_apps/app_security_scan/wiz/scan_service.py
backend/app/user_apps/backend_functions/git_archive.py
backend/app/user_apps/build_deploy/artifact_store.py
backend/app/user_apps/common/app_cleanup.py
backend/app/user_apps/git_storage/s3_provider.py
backend/app/user_apps/s3/archive_service.py
backend/app/user_apps/s3/s3_admin_router.py
backend/app/user_apps/s3/squash_git_history.py
backend/app/user_apps/sandbox/executors/git_operations_executor.py
backend/lib/common/lifespan.py
backend/scripts/apps/download_app_code.py
backend/scripts/benchmark_model/prompt_evaluation/services/judge_worker.py
backend/scripts/migrations/repair_superagent_s3_heads.py
apperbackend/app/user_apps/sandbox/sandbox_build_operations.py1backend/app/user_apps/sandbox/sandbox_integration.py

Decision

+

Rework. Fix the correctness findings and either implement the planned fullstack slice or explicitly revise the scope to a static transport slice. The complete rollout order is CLI fixes → release → sandbox pin/image rollout and smoke test → backend enablement. CLI release alone is insufficient.

\ No newline at end of file diff --git a/pr-review-626-24525.md b/pr-review-626-24525.md new file mode 100644 index 00000000..f3df6302 --- /dev/null +++ b/pr-review-626-24525.md @@ -0,0 +1,99 @@ +**Template:** T3 · Review — correctness, SOLID, KISS, and design compliance. +**Task:** Review the CLI producer and backend version-publishing workflow together. +**Where we stand:** Changes requested; correctness and rollout blockers remain. +**Last time:** Proposed order was CLI merge/release, then backend merge. +**Now:** Findings against CLI `652021e` and apper `b3f5e04f`, checked on 15 September 2026. + +**Blockers: 3 · Blast radius: shared publishing infrastructure and enrolled apps’ runtime schemas.** + +## The change separates recording a build from serving it + +**Before.** Builder prepared a commit’s frontend, then Python recorded and deployed its artifacts. + +**After.** A temporary sandbox runs the CLI, which declares files, uploads them to S3, finalizes a version, and requests deployment. + +**Why this shape.** Keeping manifest identity, storage keys, runtime secrets, and publication on the server follows the design. Separately callable create/deploy operations are the right foundation. + +## Shared helpers widen the review beyond the new commands + +| Changed surface | Reach | Evidence | +| --- | --- | --- | +| `s3_service.py` | 26 direct production/script importing files | Counted Python imports at the reviewed backend head; the new argument is optional, so existing callers retain their behavior | +| `Base44Command` | 57 CLI files reference this symbol, including its definition and export | Counted source references; shared lifecycle now imports version-specific error tagging | +| `build_deploy/service.py` | 2 direct production importing files | Builder publishing and the new versions API | +| `sandbox_build_operations.py` | 1 direct production importing file | Shared sandbox integration; both legacy and version paths use the extracted checkout/install helper | + +The HTML report includes direct-import counts for every changed production module. These are static module references, not runtime call counts. + +## Verification covers local behavior, not a live deployment + +| Area | Checked | Result | +| --- | --- | --- | +| Changed CLI/backend implementation and adjacent consumers | Read it only | Traced collection, authentication, staging, finalization, publication, schema reads, and outer Builder flow | +| Focused CLI tests | Ran it | 47 tests passed across six build/version test files | +| Pinned SDK compatibility | Ran it | Actual argument validation against the [official pinned S3 model](https://github.com/boto/botocore/blob/1.40.18/botocore/data/s3/2006-03-01/service-2.json) rejects the new copy argument | +| Collector and failure envelopes | Ran it | Reproduced missing `User`, `EMFILE` under Node, and an error envelope without `step` | +| Backend validation and retry scenarios | Ran isolated probes | Executed actual function bodies with storage/data-container doubles; not a full backend integration run | +| Current CI | Read logs/status | CLI Windows dev-server timeout; backend shard failure from unused `marketing_os.compile.*` observability registrations. These failures are outside this diff | +| Real S3 upload/copy, sandbox image, cold deploy, rollback | Did not run | Staging proof remains missing | + +Apper was rebased during this review. The reviewed publishing modules, SDK pins, and supporting runtime/auth paths are unchanged between the initial `5bd89e5a` and final `b3f5e04f` heads. + +## Fix these before merging the complete workflow + +| Severity | Where | Finding and consequence | Checked | Required change | +| --- | --- | --- | --- | --- | +| 🔴 blocker · P1 | [apper `s3_service.py:472`](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/s3/s3_service.py#L472) | Every nonempty staged finalization passes `IfNoneMatch` to `CopyObject`, but the pinned SDK rejects it with `ParamValidationError` before sending a request. The S3 mocks hide this. | Ran SDK-model validation | Upgrade the compatible AWS SDK dependency set, or implement a create-only operation supported by the pin. Preserve destination immutability; `CopySourceIfNoneMatch` checks the source and is not a substitute. | +| 🔴 blocker · P1 | [CLI `artifacts.ts:123`](https://github.com/base44/cli/blob/652021ef670cdabca254a0962fdbd5e40c730120/packages/cli/src/core/version/artifacts.ts#L123) | A repo without `User.jsonc` sends no `User` schema. Python’s `schemas.extract()` always adds `User: {}`. Finalize stores the CLI omission unchanged; runtime treats it as deletion, and `UserCRUD` raises `EntitySchemaNotFoundError`. Ordinary user-management APIs break after publication. | Ran both producer extractions; read runtime consequence | Put built-in schema normalization at the shared server boundary. Compare the real CLI collector against Python source extraction, including absent/customized `User`. | +| 🔴 blocker · P1, rollout | [apper image pin](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/sandbox/base44_cli_version#L1) and [new invocation](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/sandbox/sandbox_build_operations.py#L519) | The sandbox pin remains `0.1.14`. Publishing a newer CLI package does not change the image’s installed CLI. The backend immediately relies on the new build behavior and `publish` command. | Read pin, Dockerfiles, and invocation | Release the CLI, update the image pin, rebuild and roll the relevant providers’ images, then verify the actual executable and smoke-test this path before enabling the backend switch. | +| 🟠 fix in this PR · P2 | [CLI `artifacts.ts:82`](https://github.com/base44/cli/blob/652021ef670cdabca254a0962fdbd5e40c730120/packages/cli/src/core/version/artifacts.ts#L82) | `Promise.all` starts one streaming hash per file without a concurrency bound. Under production Node with a 256-descriptor limit, a 1,500-file fixture fails with `EMFILE`, well below the advertised file-count ceiling. | Reproduced under Node 24 | Bound stat/hash work with the existing `p-map` utility. Upload concurrency does not limit this earlier phase. | +| 🟠 fix in this PR · P2 | [apper `versions_api.py:294`](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/build_deploy/versions_api.py#L294) and [receipt write:323](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/build_deploy/versions_api.py#L323) | The retry receipt is written to Redis after publication. If that write fails, the endpoint reports failure although the version is live. Retrying the same key can publish again; the isolated handler probe produced A → B → A. Concurrent misses are also unclaimed, and the receipt stores neither requested version nor target. | Ran post-publication failure probe; read other cases | Persist/recover request identity with the deployment and reject reuse for a different request. A receipt-cache failure must not misreport a known committed publication. | +| 🟠 fix in this PR · P2 | [apper `producer.py:137`](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/build_deploy/producer.py#L137) | The schema-byte budget counts only dictionary values. A schema supplied as a string larger than the budget passes declaration and is serialized into Redis; resource names are excluded from the budget too. Later rejection does not bound ingestion. | Ran actual validator with an oversized non-object value | Reject non-object schemas before saving; bound the complete serialized schema map, including names. | +| 🟠 fix in this PR · P2 | [apper `versions_api.py:243`](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/build_deploy/versions_api.py#L243) | An empty declaration can finalize into a version; neither declaration nor `validate_artifacts()` requires `index.html`. The requirement is checked only in `static_bundle.prepare()` during deploy. Create-version success therefore does not mean the admitted static artifact set is deployable. | Ran declaration validator; read finalize/store/prepare | Enforce static completeness at the shared version-validation boundary before recording the version. Keep fullstack validation separate. | +| 🟠 fix in this PR · P2 | [CLI `publish.ts:55`](https://github.com/base44/cli/blob/652021ef670cdabca254a0962fdbd5e40c730120/packages/cli/src/cli/commands/publish.ts#L55) | Output-directory resolution and artifact/resource collection run outside `tagStep`. A missing output directory emits JSON without `step`, so the sandbox cannot distinguish rejected build output from a transport failure. | Reproduced using the actual CLI | Tag the whole collection/create operation, including local validation, as `create_version`. Preserve the original error type. | + +🔴 blocker — do not merge the workflow · 🟠 fix in this PR — belongs in these changes. + +## SOLID/KISS: keep the primitives, fix the shared rules + +| Principle | Assessment | +| --- | --- | +| Single responsibility | Build, version storage, and deployment remain distinct. However, finalization’s verify → construct → create → save receipt → cleanup workflow exists only in the HTTP handler. Move that composition behind an ordinary Python service function. | +| Open/closed and interface segregation | Explicit calls per artifact kind and small request bodies are appropriate. A generic registry, matching interfaces for unrelated kinds, or new microservices would add work without solving these defects. | +| Substitution | In-memory and staged static storage share index construction, which is sound. The real producers are not interchangeable yet: their treatment of built-in `User` differs. The equivalence test hand-builds identical payloads and misses that boundary. | +| Dependency direction | CLI core remains free of UI dependencies. Move `tagStep`/`stepOf` into shared error infrastructure: the common command lifecycle should not import the feature’s publish composer. | +| Simplicity | Reusing the uploader and centralizing deploy-plus-domain-binding are good choices. Avoid treating post-publication Redis bookkeeping as a second authority. Keep the retained in-process path only if its supported use is explicit and tested against the same normalization rules. | + +## The design deviations are larger than naming or style + +| Design requirement | Actual behavior | Assessment | +| --- | --- | --- | +| Fullstack first; complete, versioned artifact descriptor — §§2.1, 19, 21 | Static file list only; `index.html` is mandatory; the backend explicitly refuses fullstack apps; Build returns a status message, not an artifact descriptor | Major scope deviation. Useful static transport work, but not the planned fullstack milestone. The wire lacks an explicit format version/site discriminator. | +| One checkout and one build — §§18.4, 20.1 | Outer `_prepare_commit_build()` still prepares the legacy artifact, then the new lane starts another sandbox and frontend build | The outer preparation predates this PR; the second frontend build comes from replacing reuse of `ExistingCommitDist` with a new CLI build. Pass one prepared version forward. | +| Committed dependencies or recorded pinned overlay — §4 | New path removes `update_packages`, but still calls the installer that runs `npm install --ignore-scripts` | Improvement, not lockfile-exact reproduction. Use an immutable install policy for this lane. | +| Serving authority survives admission being disabled — §19 | `runtime.current()` returns `None` when the flag is off; the description advertises flag-off rollback to legacy pointers | Inherited implementation, still contrary to the design. A kill switch is not a safe rollback once legacy pointers are stale. | +| Session-bound publishing capability — §16.1 | Late-minted, app-scoped `apps:deploy` key; no upload-session/version binding | App scoping and revocation help, but the key can deploy another known retained version of that app. The broader scope needs an explicit design decision; late injection does not isolate background build processes. | +| Unsupported resources fail explicitly — §§19, 20.1 | CLI collects only files/entities/agents; backend function refusal consults current app metadata | Local newly added function files can be omitted without refusal when the app’s metadata still reports no functions. Admission must account for the submitted build’s declared capabilities. The design’s unversioned-agent/skill admission rule is also not represented here. | +| Endpoint adds no behavior — §§17, 20.1 | Deploy-plus-bind is shared, but admission enforcement/finalization and retry handling remain in HTTP orchestration | Sharing helper functions is not enough: expose the same application operation to Python and HTTP callers. Keep transport parsing/auth/status mapping in the adapter. | +| Server verifies staged content — §7 | S3 verifies a signed SHA-256 at PUT time; finalize checks presence/size and copies | Reasonable documented substitution, conditional on real S3 enforcement. Presigned URLs are reusable until expiry; the signature fixes the allowed bytes, not the number of writes. | + +References: [design document](/Users/yurym/Desktop/build-deploy-design.md), [outer preparation](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/app_deployments/builder_api.py#L1398), [legacy frontend reuse](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/build_deploy/service.py#L217), [mutable installer](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/builder/git/npm_sandbox_driver.py#L587), [runtime flag check](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/build_deploy/runtime.py#L53), [AWS presigned URL semantics](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html). + +## The missing staging proof changes the merge decision + +Green unit tests cannot establish image rollout, S3 checksum enforcement, or reconstruction after the builder disappears. Full backend tests were not run locally in this review. Current CI also remains red: [CLI Windows failure](https://github.com/base44/cli/actions/runs/34963055505/job/104361139812), [backend shard failure](https://github.com/base44-dev/apper/actions/runs/34964708923/job/104366818745). + +## Verify the repaired workflow in this order + +1. Run create-version using the repository’s locked AWS SDK; expect successful conditional copy, plus refusal of a wrong-checksum upload against real S3. +2. Publish an app without `User.jsonc`; expect `User: {}` in the stored artifact set and working user-management endpoints. Repeat with customized User fields. +3. Collect a large fixture under a low descriptor limit; expect bounded hashing and success. Missing output must produce `step: create_version`. +4. Fail the retry-receipt write after publication, publish a newer version, and retry the original request; expect recovery of the original result without republishing it. +5. Reject oversized/non-object schemas and incomplete static output before version creation succeeds. +6. Run the pinned sandbox CLI once, finalize, destroy the sandbox, then deploy/redeploy/rollback from S3. For the stated design milestone, include SSR, a server route, and an asset from a real fullstack fixture. + +**In one line:** These PRs add a useful static version-ingress path, but the current implementation can fail finalization, omit a required schema, and does not deliver the fullstack-first workflow. + +## Decision + +**Rework.** Fix the correctness findings and either implement the planned fullstack slice or explicitly revise the scope to a static transport slice. The complete rollout order is **CLI fixes → release → sandbox pin/image rollout and smoke test → backend enablement**. CLI release alone is insufficient. From cd2b486e68c9dab86a28471982885f00e2698edb Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Tue, 15 Sep 2026 17:02:10 +0300 Subject: [PATCH 11/21] fix: bound what one declaration can cost the platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declare signed one presigned URL per declared file at a ceiling of 100 000, and held the whole set in Redis for an hour. Measured: ~108 µs of blocking crypto per URL and ~138 bytes per file, so the ceiling was ~10.8 s of pinned event loop and ~13.8 MB of Redis in a single request — at 600 requests a minute, because that limit was copied from `fullstack_deploy` when the comparable endpoint, `dist_deploy`, allows 5. Three changes. The ceiling is 50 000, roughly 2x the largest frontend ever measured through the build sandbox (25 500 assets) and ~5.4 s at the very top. Declare gets its own rate limit of 10/min, since its cost scales with the app while finalize and deploy are flat; those drop to 60/min. A test pins the CPU bound, so raising the ceiling is a decision someone makes rather than a number that drifts. Co-Authored-By: Claude Opus 5 (1M context) --- docs/versions.md | 13 ++ packages/cli/src/core/version/artifacts.ts | 9 +- pr-review-626-24525.html | 135 +++++++++++---------- pr-review-626-24525.md | 134 ++++++++++---------- 4 files changed, 158 insertions(+), 133 deletions(-) diff --git a/docs/versions.md b/docs/versions.md index 77fc1c23..93e2493e 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -76,6 +76,19 @@ The group is plural to match `agents`, `entities`, `functions`, `secrets` and `w `DEFAULT_VERSION_UPLOAD_CONCURRENCY` is 8, `MAX_VERSION_UPLOAD_CONCURRENCY` is 16. Measured on the build sandbox's pipe: at 3, a 25 500-asset app moved 27 assets/s (~109 ms per PUT, 23 KiB mean — latency-bound) and needed ~930 s of the ~450 s a build leaves, so it was SIGKILLed mid-upload every time. 8 is the rate the python driver it replaced already sustained to the same bucket; 16 failed a degraded pipe on 2026-07-02. +## What one declaration may cost + +`MAX_FILE_COUNT` is 50 000, matching the server. It is a cost bound, not a guess +about app size: the platform signs one presigned URL per declared file — measured +at ~108 µs of blocking crypto each — and holds the whole declared set in Redis +until the version finalizes. At the ceiling that is ~5.4 s and ~7 MB for a single +request, which is why declaring is rate-limited far more tightly than finalizing +or deploying. + +The largest frontend ever measured through the build sandbox is 25 500 assets, so +the ceiling is roughly 2x a real worst case. Raising it on one side alone only +earns a rejection after the walk. + ## The env gate The whole lane is one env var. With `BASE44_VERSIONS_API=1` (or `true`; internal gate, not user-facing yet) `base44 publish` and the `versions` group are registered; without it they are **not registered at all**, so they are absent from `--help` and typing one is an unknown command. `versionsApiEnabled()` in `core/version/gate.ts` is read in exactly one place: the registration in `program.ts`. diff --git a/packages/cli/src/core/version/artifacts.ts b/packages/cli/src/core/version/artifacts.ts index d69b3e62..ae20ac30 100644 --- a/packages/cli/src/core/version/artifacts.ts +++ b/packages/cli/src/core/version/artifacts.ts @@ -9,8 +9,13 @@ import { InvalidInputError } from "@/core/errors.js"; import { pathExists, readJsonFile } from "@/core/utils/fs.js"; import type { ArtifactFile, ArtifactSet } from "@/core/version/schema.js"; -/** The same ceiling the site collector applies; one build, one limit. */ -const MAX_FILE_COUNT = 100_000; +/** + * What one declaration may cost the platform: a presigned URL per file, and the + * whole set held in Redis until it finalizes. ~2x the largest frontend ever + * measured through the build sandbox (25.5k assets). Must match the server's + * ceiling — declaring more only earns a rejection after the walk. + */ +const MAX_FILE_COUNT = 50_000; /** Open descriptors while hashing. Well under the 256 a production Node keeps. */ const HASH_CONCURRENCY = 32; diff --git a/pr-review-626-24525.html b/pr-review-626-24525.html index 652645b7..85896072 100644 --- a/pr-review-626-24525.html +++ b/pr-review-626-24525.html @@ -1,76 +1,79 @@ -CLI #626 + apper #24525 — architecture review

Template: T3 · Review — correctness, SOLID, KISS, and design compliance.

-

Task: Review the CLI producer and backend version-publishing workflow together.

-

Where we stand: Changes requested; correctness and rollout blockers remain.

-

Last time: Proposed order was CLI merge/release, then backend merge.

-

Now: Findings against CLI 652021e and apper b3f5e04f, checked on 15 September 2026.

-

Blockers: 3 · Blast radius: shared publishing infrastructure and enrolled apps’ runtime schemas.

-

The change separates recording a build from serving it

-

Before. Builder prepared a commit’s frontend, then Python recorded and deployed its artifacts.

-

After. A temporary sandbox runs the CLI, which declares files, uploads them to S3, finalizes a version, and requests deployment.

-

Why this shape. Keeping manifest identity, storage keys, runtime secrets, and publication on the server follows the design. Separately callable create/deploy operations are the right foundation.

-

Shared helpers widen the review beyond the new commands

-
- - - - +CLI #626 + apper #24525 — architecture review

Template: T3 · Review — re-review of the static CLI publishing slice.

+

Task: Assess CLI #626 and apper #24525 for correctness, SOLID/KISS, and the implementation brief.

+

Where we stand: Six correctness findings are resolved; backend changes and rollout requirements remain.

+

Last time: The accepted cohort was a static web frontend with zero backend functions.

+

Now: Review of CLI 85429f9 and apper 9e140055, checked on 15 September 2026.

+

Blockers: 2 · Blast radius: 🟠 shared publishing code. The blockers concern sandbox credentials and image rollout; retry recovery and the duplicate build also need fixes.

+

The fixes repair collection and version creation

+

Before. The CLI could exhaust file descriptors or omit failure-step information. Backend finalization used an unsupported SDK argument, omitted built-in User, and accepted invalid schema budgets or incomplete frontends.

+

After. Hashing is bounded, local collection failures identify create_version, the copy call fits the pinned SDK, server normalization restores User, and validation rejects those invalid inputs. Deployment requests now claim their retry key before publishing.

+

Why this shape. Shared validation and the existing uploader keep the implementation small. Claiming a request before publication is necessary, but the claim also needs a defined outcome when preparation or receipt persistence fails.

+

The governing scope is after-23460.md. The end-state design is background where that brief narrows the milestone. Fullstack remains phase 2 and is not a merge requirement.

+

Shared helpers determine the review’s reach

+
Changed surfaceReachEvidence
s3_service.py26 direct production/script importing filesCounted Python imports at the reviewed backend head; the new argument is optional, so existing callers retain their behavior
Base44Command57 CLI files reference this symbol, including its definition and exportCounted source references; shared lifecycle now imports version-specific error tagging
build_deploy/service.py2 direct production importing filesBuilder publishing and the new versions API
sandbox_build_operations.py1 direct production importing fileShared sandbox integration; both legacy and version paths use the extracted checkout/install helper
+ + + + + +
Changed surfaceDirect importing production filesEvidence
s3_service.py26Counted static imports at the reviewed backend head
kinds/schemas.py4Includes both source extraction and HTTP normalization
version.py3Shared version creation now rejects incomplete static frontends
service.py2Builder flow and versions API
sandbox_build_operations.py1Shared sandbox integration
Base44Command.ts1 direct barrel importerBarrel exports make the indirect reach larger; this is the shared command lifecycle
-

The HTML report includes direct-import counts for every changed production module. These are static module references, not runtime call counts.

-

Verification covers local behavior, not a live deployment

-
- - - - - - - +

The HTML appendix contains counts for all 30 changed production modules. Counts describe static imports, including re-exports; they do not measure runtime calls.

+

Six findings are resolved at these commits

+
AreaCheckedResult
Changed CLI/backend implementation and adjacent consumersRead it onlyTraced collection, authentication, staging, finalization, publication, schema reads, and outer Builder flow
Focused CLI testsRan it47 tests passed across six build/version test files
Pinned SDK compatibilityRan itActual argument validation against the official pinned S3 model rejects the new copy argument
Collector and failure envelopesRan itReproduced missing User, EMFILE under Node, and an error envelope without step
Backend validation and retry scenariosRan isolated probesExecuted actual function bodies with storage/data-container doubles; not a full backend integration run
Current CIRead logs/statusCLI Windows dev-server timeout; backend shard failure from unused marketing_os.compile.* observability registrations. These failures are outside this diff
Real S3 upload/copy, sandbox image, cold deploy, rollbackDid not runStaging proof remains missing
+ + + + + +
FindingCurrent resultChecked
Unsupported conditional CopyObject parameterRemoved. The actual current method’s parameters pass validation against the pinned botocore S3 model.Ran SDK-model validation; no AWS request
Missing built-in Userschemas.normalize_entities() adds User: {} when absent and preserves customized fields. HTTP finalization uses it.Ran actual normalization/extraction bodies with absent and customized User; read the revised parity test
Unbounded hashingUses existing p-map with bounded concurrency.Ran actual collector under production Node with 1,500 files and a 256-descriptor limit; succeeded
Schema-budget bypassNon-object payloads are rejected; complete maps, including names, count toward the budget.Ran actual validator with oversized names and non-object values; both rejected
Empty or unenterable frontendShared version validation rejects empty sets and sets missing index.html, for bytes and staged descriptors.Ran actual validation helper for both representations
Missing collection error stepOutput resolution, hashing, and resource reads are inside tagStep("create_version", ...).Built current CLI; regression test passed
-

Apper was rebased during this review. The reviewed publishing modules, SDK pins, and supporting runtime/auth paths are unchanged between the initial 5bd89e5a and final b3f5e04f heads.

+

The copy now overwrites an existing destination with the same content-addressed bytes. Under the declared AWS checksum contract, that closes the SDK defect without changing artifact content. It is no longer a create-only storage operation. Real checksum enforcement and copy behavior still need staging proof. The comment saying IfNoneMatch is “PutObject-only” should describe the pinned SDK limitation; the current S3 CopyObject API supports the header.

Fix these before merging the complete workflow

-
+
- - - - - - - - + + + + + +
SeverityWhereFinding and consequenceCheckedRequired change
🔴 blocker · P1apper s3_service.py:472Every nonempty staged finalization passes IfNoneMatch to CopyObject, but the pinned SDK rejects it with ParamValidationError before sending a request. The S3 mocks hide this.Ran SDK-model validationUpgrade the compatible AWS SDK dependency set, or implement a create-only operation supported by the pin. Preserve destination immutability; CopySourceIfNoneMatch checks the source and is not a substitute.
🔴 blocker · P1CLI artifacts.ts:123A repo without User.jsonc sends no User schema. Python’s schemas.extract() always adds User: {}. Finalize stores the CLI omission unchanged; runtime treats it as deletion, and UserCRUD raises EntitySchemaNotFoundError. Ordinary user-management APIs break after publication.Ran both producer extractions; read runtime consequencePut built-in schema normalization at the shared server boundary. Compare the real CLI collector against Python source extraction, including absent/customized User.
🔴 blocker · P1, rolloutapper image pin and new invocationThe sandbox pin remains 0.1.14. Publishing a newer CLI package does not change the image’s installed CLI. The backend immediately relies on the new build behavior and publish command.Read pin, Dockerfiles, and invocationRelease the CLI, update the image pin, rebuild and roll the relevant providers’ images, then verify the actual executable and smoke-test this path before enabling the backend switch.
🟠 fix in this PR · P2CLI artifacts.ts:82Promise.all starts one streaming hash per file without a concurrency bound. Under production Node with a 256-descriptor limit, a 1,500-file fixture fails with EMFILE, well below the advertised file-count ceiling.Reproduced under Node 24Bound stat/hash work with the existing p-map utility. Upload concurrency does not limit this earlier phase.
🟠 fix in this PR · P2apper versions_api.py:294 and receipt write:323The retry receipt is written to Redis after publication. If that write fails, the endpoint reports failure although the version is live. Retrying the same key can publish again; the isolated handler probe produced A → B → A. Concurrent misses are also unclaimed, and the receipt stores neither requested version nor target.Ran post-publication failure probe; read other casesPersist/recover request identity with the deployment and reject reuse for a different request. A receipt-cache failure must not misreport a known committed publication.
🟠 fix in this PR · P2apper producer.py:137The schema-byte budget counts only dictionary values. A schema supplied as a string larger than the budget passes declaration and is serialized into Redis; resource names are excluded from the budget too. Later rejection does not bound ingestion.Ran actual validator with an oversized non-object valueReject non-object schemas before saving; bound the complete serialized schema map, including names.
🟠 fix in this PR · P2apper versions_api.py:243An empty declaration can finalize into a version; neither declaration nor validate_artifacts() requires index.html. The requirement is checked only in static_bundle.prepare() during deploy. Create-version success therefore does not mean the admitted static artifact set is deployable.Ran declaration validator; read finalize/store/prepareEnforce static completeness at the shared version-validation boundary before recording the version. Keep fullstack validation separate.
🟠 fix in this PR · P2CLI publish.ts:55Output-directory resolution and artifact/resource collection run outside tagStep. A missing output directory emits JSON without step, so the sandbox cannot distinguish rejected build output from a transport failure.Reproduced using the actual CLITag the whole collection/create operation, including local validation, as create_version. Preserve the original error type.
🔴 blocker · P1apper sandbox_build_operations.py:568The late-minted key still has the broad APPS_DEPLOY scope, and the build and publish execs share the sandbox without a new process-user boundary. Brief S4 requires the new endpoints only and protection from surviving build processes. The same scope also authorizes legacy auth-configuration mutation.Read minting, execution, scope policy, and legacy auth mutation; no live exploit testEnforce an endpoint restriction for this credential and isolate the credential-bearing publisher from build-owned processes. App-wide versus version-bound authority is a separate open choice under §9.1.
🔴 blocker · P1, rolloutsandbox CLI pinThe pin remains 0.1.14. Releasing CLI #626 does not update the executable installed in either sandbox image, while the backend requires its new commands and build behavior.Read pin and both Modal and Cloudflare DockerfilesRelease CLI, update the pin, rebuild and roll the images, and run the static smoke test before backend enablement. This is a release prerequisite.
🟠 fix in this PR · P2apper versions_api.py:302, failure paths, receipt writeA request claims its key, but failure never releases or completes the claim. A temporary preparation failure returns 502; the same request then returns 409 already running for the one-hour expiry even after the dependency recovers. If receipt writing fails after success and the response is lost, the caller also cannot recover the committed result.Reproduced both cases using actual handler and producer bodies with storage/context doublesValidate before claiming where possible. Give failures before publication a completed failure or safely released claim. Recover a committed deployment by request identity when receipt persistence fails. Do not blindly release a claim after publication might have happened.
🟠 fix in this PR · P2apper builder_api.py:1398, new buildBuilder still prepares the legacy frontend before reaching the new lane, which starts another sandbox and frontend build. Brief S1 and definition-of-done item 10 explicitly require one build. The outer preparation is older code; replacing reuse of its dist with a fresh CLI build introduces the second build.Traced outer preparation through _publish_from_recorded_version() to sandbox publicationRoute the enrolled flow through one preparation/build and carry its result forward; preserve the ordinary legacy flow.
🟢 your callCLI Base44Command.ts:20The common command lifecycle imports error metadata from the version publish composer. This couples general command handling to one feature.Read importsMove tagStep/stepOf to shared error infrastructure when convenient.
🟢 your callcommitted review reportThe CLI commit includes these generated review reports, whose original contents refer to older heads.Read PR file list and reportRemove review output from the product PR, or give it an intentional documentation home. The local reports are updated by this review.
-

🔴 blocker — do not merge the workflow · 🟠 fix in this PR — belongs in these changes.

-

SOLID/KISS: keep the primitives, fix the shared rules

+

🔴 blocker — blocks the complete rollout · 🟠 fix in this PR — required branch work · 🟢 your call — optional cleanup.

+

SOLID/KISS supports the current structure

- - - - - + + + + +
PrincipleAssessment
Single responsibilityBuild, version storage, and deployment remain distinct. However, finalization’s verify → construct → create → save receipt → cleanup workflow exists only in the HTTP handler. Move that composition behind an ordinary Python service function.
Open/closed and interface segregationExplicit calls per artifact kind and small request bodies are appropriate. A generic registry, matching interfaces for unrelated kinds, or new microservices would add work without solving these defects.
SubstitutionIn-memory and staged static storage share index construction, which is sound. The real producers are not interchangeable yet: their treatment of built-in User differs. The equivalence test hand-builds identical payloads and misses that boundary.
Dependency directionCLI core remains free of UI dependencies. Move tagStep/stepOf into shared error infrastructure: the common command lifecycle should not import the feature’s publish composer.
SimplicityReusing the uploader and centralizing deploy-plus-domain-binding are good choices. Avoid treating post-publication Redis bookkeeping as a second authority. Keep the retained in-process path only if its supported use is explicit and tested against the same normalization rules.
Single responsibilityBuild, record, and deploy remain separate. HTTP handlers may construct BuildOutput and call the shared operations; the brief explicitly prescribes this. No extra orchestration framework is needed.
SubstitutionShared index construction and server-side User normalization now align the two producer paths for the reviewed cases.
Dependency directionCLI core remains independent of presentation. The shared command-to-feature import above is a small cleanup, not a reason to redesign the layers.
SimplicityBounded p-map, explicit artifact kinds, shared uploads, and shared deploy-plus-domain-binding are appropriate. Keep the in-process path required by B6.
Failure handlingThe request claim needs a complete failure lifecycle. B4 requires request replay; it does not require the attempts framework, generations, outbox, or GC excluded by §2.
-

The design deviations are larger than naming or style

-
- - - - - - - - +

Other scope distinctions remain relevant

+
Design requirementActual behaviorAssessment
Fullstack first; complete, versioned artifact descriptor — §§2.1, 19, 21Static file list only; index.html is mandatory; the backend explicitly refuses fullstack apps; Build returns a status message, not an artifact descriptorMajor scope deviation. Useful static transport work, but not the planned fullstack milestone. The wire lacks an explicit format version/site discriminator.
One checkout and one build — §§18.4, 20.1Outer _prepare_commit_build() still prepares the legacy artifact, then the new lane starts another sandbox and frontend buildThe outer preparation predates this PR; the second frontend build comes from replacing reuse of ExistingCommitDist with a new CLI build. Pass one prepared version forward.
Committed dependencies or recorded pinned overlay — §4New path removes update_packages, but still calls the installer that runs npm install --ignore-scriptsImprovement, not lockfile-exact reproduction. Use an immutable install policy for this lane.
Serving authority survives admission being disabled — §19runtime.current() returns None when the flag is off; the description advertises flag-off rollback to legacy pointersInherited implementation, still contrary to the design. A kill switch is not a safe rollback once legacy pointers are stale.
Session-bound publishing capability — §16.1Late-minted, app-scoped apps:deploy key; no upload-session/version bindingApp scoping and revocation help, but the key can deploy another known retained version of that app. The broader scope needs an explicit design decision; late injection does not isolate background build processes.
Unsupported resources fail explicitly — §§19, 20.1CLI collects only files/entities/agents; backend function refusal consults current app metadataLocal newly added function files can be omitted without refusal when the app’s metadata still reports no functions. Admission must account for the submitted build’s declared capabilities. The design’s unversioned-agent/skill admission rule is also not represented here.
Endpoint adds no behavior — §§17, 20.1Deploy-plus-bind is shared, but admission enforcement/finalization and retry handling remain in HTTP orchestrationSharing helper functions is not enough: expose the same application operation to Python and HTTP callers. Keep transport parsing/auth/status mapping in the adapter.
Server verifies staged content — §7S3 verifies a signed SHA-256 at PUT time; finalize checks presence/size and copiesReasonable documented substitution, conditional on real S3 enforcement. Presigned URLs are reusable until expiry; the signature fixes the allowed bytes, not the number of writes.
+ + + + + +
TopicAssessment
Fullstack, manifest v2, Cloudflare deployment workExplicitly outside this slice. Raw entity and agent schemas are in scope.
Standalone build envelopeA reusable artifact collector exists, but base44 build --json still returns status. C1’s command-envelope wording remains a contract clarification.
Function admissionServer admission checks current app metadata. Local function files newly added before metadata synchronization are not checked by the collector. Source-level refusal remains an admission gap to settle; supporting those functions is not requested.
Package installationRemoving update_packages satisfies the explicit S3 change. The inherited npm install --ignore-scripts reproducibility issue does not require an installer rewrite in this review.
Flag-off rollbackDisabling the existing flag restores legacy reads. That inherited behavior can expose stale legacy pointers; it is distinct from deploying an older stored version.
Upload verificationSigned SHA-256 verification at S3 PUT time is a documented substitution for backend re-hashing. The signature constrains bytes; it does not make a presigned URL single-use. See AWS presigned URL semantics.
-

References: design document, outer preparation, legacy frontend reuse, mutable installer, runtime flag check, AWS presigned URL semantics.

-

The missing staging proof changes the merge decision

-

Green unit tests cannot establish image rollout, S3 checksum enforcement, or reconstruction after the builder disappears. Full backend tests were not run locally in this review. Current CI also remains red: CLI Windows failure, backend shard failure.

-

Verify the repaired workflow in this order

+

Local verification does not establish release readiness

+
+ + + + + +
CoverageResult
CLI build and focused suite — ran itCurrent build succeeded; all 49 tests passed across six version/build test files.
Targeted regressions — ran itNode descriptor-limit test and pinned SDK-model validation passed. Backend helper probes verified normalization, schema budgets, static completeness, normal replay, and conflicting key rejection; failure replay reproduced the remaining defect.
Full backend suite — did not run locallyNo configured backend virtual environment was available. Isolated probes use data-container and storage/context doubles; they do not exercise Pydantic, authentication, or real infrastructure.
Current CI — read statusCLI checks completed successfully. Backend checks were still running, with no reported failures at 13:00 UTC.
Real S3, deployed image, cold deploy, rollback — did not runThese remain staging gates from the static brief. No live credential-isolation test was run.
+

A green CLI suite cannot prove the image contains that CLI. A mocked copy cannot prove checksum enforcement. Neither establishes cold replay after the producer disappears.

+

Check the remaining behavior before rollout

    -
  1. Run create-version using the repository’s locked AWS SDK; expect successful conditional copy, plus refusal of a wrong-checksum upload against real S3.
  2. -
  3. Publish an app without User.jsonc; expect User: {} in the stored artifact set and working user-management endpoints. Repeat with customized User fields.
  4. -
  5. Collect a large fixture under a low descriptor limit; expect bounded hashing and success. Missing output must produce step: create_version.
  6. -
  7. Fail the retry-receipt write after publication, publish a newer version, and retry the original request; expect recovery of the original result without republishing it.
  8. -
  9. Reject oversized/non-object schemas and incomplete static output before version creation succeeds.
  10. -
  11. Run the pinned sandbox CLI once, finalize, destroy the sandbox, then deploy/redeploy/rollback from S3. For the stated design milestone, include SSR, a server route, and an asset from a real fullstack fixture.
  12. +
  13. Fail preparation once and retry the same key after recovery. Expect an accurate completed outcome or a safe retry, never a false claim that a finished request is still running.
  14. +
  15. Fail receipt persistence after publication and lose the response. Retry the same key; expect the original deployment reference without republishing. Also verify concurrent callers and conflicting version/target reuse.
  16. +
  17. Leave a process running after the build. Expect it to have no access to the publish credential; expect that credential to be refused by legacy mutation endpoints.
  18. +
  19. Trace one enrolled Builder publication. Expect one exact checkout/build and the intended released CLI inside the deployed image.
  20. +
  21. Publish a static zero-function app against real S3, including absent/customized User. Reject a wrong checksum, remove the sandbox and checkout, deploy the stored version, then roll back to an older one. Expect correct frontend/user schemas and no Cloudflare deployment calls.
-

In one line: These PRs add a useful static version-ingress path, but the current implementation can fail finalization, omit a required schema, and does not deliver the fullstack-first workflow.

-
Direct importer counts for changed production modules

Computed from static imports at the reviewed heads. Barrel exports can hide many indirect callers; a direct count is not a runtime fan-out estimate.

RepositoryModuleDirect importing filesReferences
clipackages/cli/src/cli/commands/project/build.ts1packages/cli/src/cli/program.ts
clipackages/cli/src/cli/commands/publish.ts1packages/cli/src/cli/program.ts
clipackages/cli/src/cli/commands/versions/create.ts1packages/cli/src/cli/commands/versions/index.ts
clipackages/cli/src/cli/commands/versions/deploy.ts1packages/cli/src/cli/commands/versions/index.ts
clipackages/cli/src/cli/commands/versions/index.ts1packages/cli/src/cli/program.ts
clipackages/cli/src/cli/commands/versions/options.ts3packages/cli/src/cli/commands/publish.ts
packages/cli/src/cli/commands/versions/create.ts
packages/cli/src/cli/commands/versions/deploy.ts
clipackages/cli/src/cli/program.ts1packages/cli/src/cli/index.ts
clipackages/cli/src/cli/utils/command/Base44Command.ts1packages/cli/src/cli/utils/command/index.ts
clipackages/cli/src/core/site/git-hash.ts1packages/cli/src/core/site/index.ts
clipackages/cli/src/core/site/schema.ts8packages/cli/src/core/project/api.ts
packages/cli/src/core/site/api.ts
packages/cli/src/core/site/deploy.ts
packages/cli/src/core/site/deployment.ts
packages/cli/src/core/site/index.ts
packages/cli/src/core/site/manifest.ts
packages/cli/src/core/site/modules.ts
packages/cli/src/core/site/upload.ts
clipackages/cli/src/core/site/upload.ts3packages/cli/src/core/site/deployment.ts
packages/cli/src/core/site/index.ts
packages/cli/src/core/version/api.ts
clipackages/cli/src/core/version/api.ts2packages/cli/src/core/version/index.ts
packages/cli/src/core/version/publish.ts
clipackages/cli/src/core/version/artifacts.ts1packages/cli/src/core/version/index.ts
clipackages/cli/src/core/version/gate.ts2packages/cli/src/cli/program.ts
packages/cli/src/core/version/index.ts
clipackages/cli/src/core/version/index.ts5packages/cli/src/cli/commands/project/build.ts
packages/cli/src/cli/commands/publish.ts
packages/cli/src/cli/commands/versions/create.ts
packages/cli/src/cli/commands/versions/deploy.ts
packages/cli/src/cli/commands/versions/options.ts
clipackages/cli/src/core/version/project.ts1packages/cli/src/core/version/index.ts
clipackages/cli/src/core/version/publish.ts2packages/cli/src/cli/utils/command/Base44Command.ts
packages/cli/src/core/version/index.ts
clipackages/cli/src/core/version/schema.ts4packages/cli/src/core/version/api.ts
packages/cli/src/core/version/artifacts.ts
packages/cli/src/core/version/index.ts
packages/cli/src/core/version/publish.ts
apperbackend/app/user_apps/app_deployments/builder_api.py1backend/route_registry.py
apperbackend/app/user_apps/build_deploy/artifact_store.py8backend/app/user_apps/app_deployments/s3_dist_service.py
backend/app/user_apps/build_deploy/deploy.py
backend/app/user_apps/build_deploy/kinds/schemas.py
backend/app/user_apps/build_deploy/kinds/static_bundle.py
backend/app/user_apps/build_deploy/kinds/worker_bundles.py
backend/app/user_apps/build_deploy/producer.py
backend/app/user_apps/build_deploy/runtime.py
backend/app/user_apps/build_deploy/version.py
apperbackend/app/user_apps/build_deploy/contracts.py11backend/app/user_apps/build_deploy/build.py
backend/app/user_apps/build_deploy/deploy.py
backend/app/user_apps/build_deploy/kinds/schemas.py
backend/app/user_apps/build_deploy/kinds/static_bundle.py
backend/app/user_apps/build_deploy/kinds/worker_bundles.py
backend/app/user_apps/build_deploy/producer.py
backend/app/user_apps/build_deploy/runtime.py
backend/app/user_apps/build_deploy/service.py
backend/app/user_apps/build_deploy/store.py
backend/app/user_apps/build_deploy/version.py
backend/app/user_apps/build_deploy/versions_api.py
apperbackend/app/user_apps/build_deploy/kinds/static_bundle.py3backend/app/user_apps/build_deploy/build.py
backend/app/user_apps/build_deploy/deploy.py
backend/app/user_apps/build_deploy/version.py
apperbackend/app/user_apps/build_deploy/producer.py1backend/app/user_apps/build_deploy/versions_api.py
apperbackend/app/user_apps/build_deploy/service.py2backend/app/user_apps/app_deployments/builder_api.py
backend/app/user_apps/build_deploy/versions_api.py
apperbackend/app/user_apps/build_deploy/versions_api.py1backend/route_registry.py
apperbackend/app/user_apps/s3/s3_service.py26backend/app/agent_ops/service.py
backend/app/ai/providers/llm_dump.py
backend/app/fullstack_hosting/service.py
backend/app/gdpr/data_sources/llm_dumps.py
backend/app/preview_frontend.py
backend/app/preview_frontend_bundle.py
backend/app/skills_registry/registry_service.py
backend/app/static_files_fallback.py
backend/app/user_apps/admin/core_integrations_api.py
backend/app/user_apps/admin/import_export.py
backend/app/user_apps/app_deployments/build_utils.py
backend/app/user_apps/app_deployments/dist_upload_service.py
backend/app/user_apps/app_deployments/s3_dist_service.py
backend/app/user_apps/app_security_scan/wiz/scan_service.py
backend/app/user_apps/backend_functions/git_archive.py
backend/app/user_apps/build_deploy/artifact_store.py
backend/app/user_apps/common/app_cleanup.py
backend/app/user_apps/git_storage/s3_provider.py
backend/app/user_apps/s3/archive_service.py
backend/app/user_apps/s3/s3_admin_router.py
backend/app/user_apps/s3/squash_git_history.py
backend/app/user_apps/sandbox/executors/git_operations_executor.py
backend/lib/common/lifespan.py
backend/scripts/apps/download_app_code.py
backend/scripts/benchmark_model/prompt_evaluation/services/judge_worker.py
backend/scripts/migrations/repair_superagent_s3_heads.py
apperbackend/app/user_apps/sandbox/sandbox_build_operations.py1backend/app/user_apps/sandbox/sandbox_integration.py

Decision

-

Rework. Fix the correctness findings and either implement the planned fullstack slice or explicitly revise the scope to a static transport slice. The complete rollout order is CLI fixes → release → sandbox pin/image rollout and smoke test → backend enablement. CLI release alone is insufficient.

\ No newline at end of file +

In one line: Collection and version creation are repaired; backend retry recovery, single-build integration, credential restrictions, and image rollout remain.

+
Direct importer counts for changed production modules

Computed from static imports at the reviewed heads. Barrel exports can hide many indirect callers; a direct count is not a runtime fan-out estimate.

RepositoryModuleDirect importing filesReferences
clipackages/cli/src/cli/commands/project/build.ts1packages/cli/src/cli/program.ts
clipackages/cli/src/cli/commands/publish.ts1packages/cli/src/cli/program.ts
clipackages/cli/src/cli/commands/versions/create.ts1packages/cli/src/cli/commands/versions/index.ts
clipackages/cli/src/cli/commands/versions/deploy.ts1packages/cli/src/cli/commands/versions/index.ts
clipackages/cli/src/cli/commands/versions/index.ts1packages/cli/src/cli/program.ts
clipackages/cli/src/cli/commands/versions/options.ts3packages/cli/src/cli/commands/publish.ts
packages/cli/src/cli/commands/versions/create.ts
packages/cli/src/cli/commands/versions/deploy.ts
clipackages/cli/src/cli/program.ts1packages/cli/src/cli/index.ts
clipackages/cli/src/cli/utils/command/Base44Command.ts1packages/cli/src/cli/utils/command/index.ts
clipackages/cli/src/core/site/git-hash.ts1packages/cli/src/core/site/index.ts
clipackages/cli/src/core/site/schema.ts8packages/cli/src/core/project/api.ts
packages/cli/src/core/site/api.ts
packages/cli/src/core/site/deploy.ts
packages/cli/src/core/site/deployment.ts
packages/cli/src/core/site/index.ts
packages/cli/src/core/site/manifest.ts
packages/cli/src/core/site/modules.ts
packages/cli/src/core/site/upload.ts
clipackages/cli/src/core/site/upload.ts3packages/cli/src/core/site/deployment.ts
packages/cli/src/core/site/index.ts
packages/cli/src/core/version/api.ts
clipackages/cli/src/core/version/api.ts2packages/cli/src/core/version/index.ts
packages/cli/src/core/version/publish.ts
clipackages/cli/src/core/version/artifacts.ts1packages/cli/src/core/version/index.ts
clipackages/cli/src/core/version/gate.ts2packages/cli/src/cli/program.ts
packages/cli/src/core/version/index.ts
clipackages/cli/src/core/version/index.ts5packages/cli/src/cli/commands/project/build.ts
packages/cli/src/cli/commands/publish.ts
packages/cli/src/cli/commands/versions/create.ts
packages/cli/src/cli/commands/versions/deploy.ts
packages/cli/src/cli/commands/versions/options.ts
clipackages/cli/src/core/version/project.ts1packages/cli/src/core/version/index.ts
clipackages/cli/src/core/version/publish.ts2packages/cli/src/cli/utils/command/Base44Command.ts
packages/cli/src/core/version/index.ts
clipackages/cli/src/core/version/schema.ts4packages/cli/src/core/version/api.ts
packages/cli/src/core/version/artifacts.ts
packages/cli/src/core/version/index.ts
packages/cli/src/core/version/publish.ts
apperbackend/app/user_apps/app_deployments/builder_api.py1backend/route_registry.py
apperbackend/app/user_apps/build_deploy/artifact_store.py8backend/app/user_apps/app_deployments/s3_dist_service.py
backend/app/user_apps/build_deploy/deploy.py
backend/app/user_apps/build_deploy/kinds/schemas.py
backend/app/user_apps/build_deploy/kinds/static_bundle.py
backend/app/user_apps/build_deploy/kinds/worker_bundles.py
backend/app/user_apps/build_deploy/producer.py
backend/app/user_apps/build_deploy/runtime.py
backend/app/user_apps/build_deploy/version.py
apperbackend/app/user_apps/build_deploy/contracts.py11backend/app/user_apps/build_deploy/build.py
backend/app/user_apps/build_deploy/deploy.py
backend/app/user_apps/build_deploy/kinds/schemas.py
backend/app/user_apps/build_deploy/kinds/static_bundle.py
backend/app/user_apps/build_deploy/kinds/worker_bundles.py
backend/app/user_apps/build_deploy/producer.py
backend/app/user_apps/build_deploy/runtime.py
backend/app/user_apps/build_deploy/service.py
backend/app/user_apps/build_deploy/store.py
backend/app/user_apps/build_deploy/version.py
backend/app/user_apps/build_deploy/versions_api.py
apperbackend/app/user_apps/build_deploy/kinds/schemas.py4backend/app/user_apps/build_deploy/build.py
backend/app/user_apps/build_deploy/deploy.py
backend/app/user_apps/build_deploy/version.py
backend/app/user_apps/build_deploy/versions_api.py
apperbackend/app/user_apps/build_deploy/kinds/static_bundle.py3backend/app/user_apps/build_deploy/build.py
backend/app/user_apps/build_deploy/deploy.py
backend/app/user_apps/build_deploy/version.py
apperbackend/app/user_apps/build_deploy/producer.py1backend/app/user_apps/build_deploy/versions_api.py
apperbackend/app/user_apps/build_deploy/service.py2backend/app/user_apps/app_deployments/builder_api.py
backend/app/user_apps/build_deploy/versions_api.py
apperbackend/app/user_apps/build_deploy/version.py3backend/app/user_apps/build_deploy/service.py
backend/app/user_apps/build_deploy/versions_api.py
backend/scripts/migrations/ensure_application_indexes.py
apperbackend/app/user_apps/build_deploy/versions_api.py1backend/route_registry.py
apperbackend/app/user_apps/s3/s3_service.py26backend/app/agent_ops/service.py
backend/app/ai/providers/llm_dump.py
backend/app/fullstack_hosting/service.py
backend/app/gdpr/data_sources/llm_dumps.py
backend/app/preview_frontend.py
backend/app/preview_frontend_bundle.py
backend/app/skills_registry/registry_service.py
backend/app/static_files_fallback.py
backend/app/user_apps/admin/core_integrations_api.py
backend/app/user_apps/admin/import_export.py
backend/app/user_apps/app_deployments/build_utils.py
backend/app/user_apps/app_deployments/dist_upload_service.py
backend/app/user_apps/app_deployments/s3_dist_service.py
backend/app/user_apps/app_security_scan/wiz/scan_service.py
backend/app/user_apps/backend_functions/git_archive.py
backend/app/user_apps/build_deploy/artifact_store.py
backend/app/user_apps/common/app_cleanup.py
backend/app/user_apps/git_storage/s3_provider.py
backend/app/user_apps/s3/archive_service.py
backend/app/user_apps/s3/s3_admin_router.py
backend/app/user_apps/s3/squash_git_history.py
backend/app/user_apps/sandbox/executors/git_operations_executor.py
backend/lib/common/lifespan.py
backend/scripts/apps/download_app_code.py
backend/scripts/benchmark_model/prompt_evaluation/services/judge_worker.py
backend/scripts/migrations/repair_superagent_s3_heads.py
apperbackend/app/user_apps/sandbox/sandbox_build_operations.py1backend/app/user_apps/sandbox/sandbox_integration.py
apperbackend/route_registry.py0No direct imports found

Decision

+

Merge after the remaining backend fixes and release gates. The reviewed CLI fixes are sound and its checks are green. CLI can proceed as the gated prerequisite release, with report-file cleanup recommended. Backend enablement still needs the fixes above, a released CLI pinned into deployed images, green backend CI, and the static staging proof. Fullstack remains phase 2.

\ No newline at end of file diff --git a/pr-review-626-24525.md b/pr-review-626-24525.md index f3df6302..c649d5ab 100644 --- a/pr-review-626-24525.md +++ b/pr-review-626-24525.md @@ -1,99 +1,103 @@ -**Template:** T3 · Review — correctness, SOLID, KISS, and design compliance. -**Task:** Review the CLI producer and backend version-publishing workflow together. -**Where we stand:** Changes requested; correctness and rollout blockers remain. -**Last time:** Proposed order was CLI merge/release, then backend merge. -**Now:** Findings against CLI `652021e` and apper `b3f5e04f`, checked on 15 September 2026. +**Template:** T3 · Review — re-review of the static CLI publishing slice. +**Task:** Assess CLI #626 and apper #24525 for correctness, SOLID/KISS, and the implementation brief. +**Where we stand:** Six correctness findings are resolved; backend changes and rollout requirements remain. +**Last time:** The accepted cohort was a static web frontend with zero backend functions. +**Now:** Review of CLI `85429f9` and apper `9e140055`, checked on 15 September 2026. -**Blockers: 3 · Blast radius: shared publishing infrastructure and enrolled apps’ runtime schemas.** +**Blockers: 2 · Blast radius: 🟠 shared publishing code.** The blockers concern sandbox credentials and image rollout; retry recovery and the duplicate build also need fixes. -## The change separates recording a build from serving it +## The fixes repair collection and version creation -**Before.** Builder prepared a commit’s frontend, then Python recorded and deployed its artifacts. +**Before.** The CLI could exhaust file descriptors or omit failure-step information. Backend finalization used an unsupported SDK argument, omitted built-in `User`, and accepted invalid schema budgets or incomplete frontends. -**After.** A temporary sandbox runs the CLI, which declares files, uploads them to S3, finalizes a version, and requests deployment. +**After.** Hashing is bounded, local collection failures identify `create_version`, the copy call fits the pinned SDK, server normalization restores `User`, and validation rejects those invalid inputs. Deployment requests now claim their retry key before publishing. -**Why this shape.** Keeping manifest identity, storage keys, runtime secrets, and publication on the server follows the design. Separately callable create/deploy operations are the right foundation. +**Why this shape.** Shared validation and the existing uploader keep the implementation small. Claiming a request before publication is necessary, but the claim also needs a defined outcome when preparation or receipt persistence fails. -## Shared helpers widen the review beyond the new commands +The governing scope is [after-23460.md](/Users/yurym/Desktop/after-23460.md). The [end-state design](/Users/yurym/Desktop/build-deploy-design.md) is background where that brief narrows the milestone. Fullstack remains phase 2 and is not a merge requirement. -| Changed surface | Reach | Evidence | +## Shared helpers determine the review’s reach + +| Changed surface | Direct importing production files | Evidence | | --- | --- | --- | -| `s3_service.py` | 26 direct production/script importing files | Counted Python imports at the reviewed backend head; the new argument is optional, so existing callers retain their behavior | -| `Base44Command` | 57 CLI files reference this symbol, including its definition and export | Counted source references; shared lifecycle now imports version-specific error tagging | -| `build_deploy/service.py` | 2 direct production importing files | Builder publishing and the new versions API | -| `sandbox_build_operations.py` | 1 direct production importing file | Shared sandbox integration; both legacy and version paths use the extracted checkout/install helper | +| `s3_service.py` | 26 | Counted static imports at the reviewed backend head | +| `kinds/schemas.py` | 4 | Includes both source extraction and HTTP normalization | +| `version.py` | 3 | Shared version creation now rejects incomplete static frontends | +| `service.py` | 2 | Builder flow and versions API | +| `sandbox_build_operations.py` | 1 | Shared sandbox integration | +| `Base44Command.ts` | 1 direct barrel importer | Barrel exports make the indirect reach larger; this is the shared command lifecycle | -The HTML report includes direct-import counts for every changed production module. These are static module references, not runtime call counts. +The HTML appendix contains counts for all 30 changed production modules. Counts describe static imports, including re-exports; they do not measure runtime calls. -## Verification covers local behavior, not a live deployment +## Six findings are resolved at these commits -| Area | Checked | Result | +| Finding | Current result | Checked | | --- | --- | --- | -| Changed CLI/backend implementation and adjacent consumers | Read it only | Traced collection, authentication, staging, finalization, publication, schema reads, and outer Builder flow | -| Focused CLI tests | Ran it | 47 tests passed across six build/version test files | -| Pinned SDK compatibility | Ran it | Actual argument validation against the [official pinned S3 model](https://github.com/boto/botocore/blob/1.40.18/botocore/data/s3/2006-03-01/service-2.json) rejects the new copy argument | -| Collector and failure envelopes | Ran it | Reproduced missing `User`, `EMFILE` under Node, and an error envelope without `step` | -| Backend validation and retry scenarios | Ran isolated probes | Executed actual function bodies with storage/data-container doubles; not a full backend integration run | -| Current CI | Read logs/status | CLI Windows dev-server timeout; backend shard failure from unused `marketing_os.compile.*` observability registrations. These failures are outside this diff | -| Real S3 upload/copy, sandbox image, cold deploy, rollback | Did not run | Staging proof remains missing | +| Unsupported conditional `CopyObject` parameter | Removed. The actual current method’s parameters pass validation against the pinned botocore S3 model. | Ran SDK-model validation; no AWS request | +| Missing built-in `User` | `schemas.normalize_entities()` adds `User: {}` when absent and preserves customized fields. HTTP finalization uses it. | Ran actual normalization/extraction bodies with absent and customized `User`; read the revised parity test | +| Unbounded hashing | Uses existing `p-map` with bounded concurrency. | Ran actual collector under production Node with 1,500 files and a 256-descriptor limit; succeeded | +| Schema-budget bypass | Non-object payloads are rejected; complete maps, including names, count toward the budget. | Ran actual validator with oversized names and non-object values; both rejected | +| Empty or unenterable frontend | Shared version validation rejects empty sets and sets missing `index.html`, for bytes and staged descriptors. | Ran actual validation helper for both representations | +| Missing collection error step | Output resolution, hashing, and resource reads are inside `tagStep("create_version", ...)`. | Built current CLI; regression test passed | -Apper was rebased during this review. The reviewed publishing modules, SDK pins, and supporting runtime/auth paths are unchanged between the initial `5bd89e5a` and final `b3f5e04f` heads. +The copy now overwrites an existing destination with the same content-addressed bytes. Under the declared AWS checksum contract, that closes the SDK defect without changing artifact content. It is no longer a create-only storage operation. Real checksum enforcement and copy behavior still need staging proof. The comment saying `IfNoneMatch` is “PutObject-only” should describe the pinned SDK limitation; the current [S3 CopyObject API](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html) supports the header. ## Fix these before merging the complete workflow | Severity | Where | Finding and consequence | Checked | Required change | | --- | --- | --- | --- | --- | -| 🔴 blocker · P1 | [apper `s3_service.py:472`](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/s3/s3_service.py#L472) | Every nonempty staged finalization passes `IfNoneMatch` to `CopyObject`, but the pinned SDK rejects it with `ParamValidationError` before sending a request. The S3 mocks hide this. | Ran SDK-model validation | Upgrade the compatible AWS SDK dependency set, or implement a create-only operation supported by the pin. Preserve destination immutability; `CopySourceIfNoneMatch` checks the source and is not a substitute. | -| 🔴 blocker · P1 | [CLI `artifacts.ts:123`](https://github.com/base44/cli/blob/652021ef670cdabca254a0962fdbd5e40c730120/packages/cli/src/core/version/artifacts.ts#L123) | A repo without `User.jsonc` sends no `User` schema. Python’s `schemas.extract()` always adds `User: {}`. Finalize stores the CLI omission unchanged; runtime treats it as deletion, and `UserCRUD` raises `EntitySchemaNotFoundError`. Ordinary user-management APIs break after publication. | Ran both producer extractions; read runtime consequence | Put built-in schema normalization at the shared server boundary. Compare the real CLI collector against Python source extraction, including absent/customized `User`. | -| 🔴 blocker · P1, rollout | [apper image pin](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/sandbox/base44_cli_version#L1) and [new invocation](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/sandbox/sandbox_build_operations.py#L519) | The sandbox pin remains `0.1.14`. Publishing a newer CLI package does not change the image’s installed CLI. The backend immediately relies on the new build behavior and `publish` command. | Read pin, Dockerfiles, and invocation | Release the CLI, update the image pin, rebuild and roll the relevant providers’ images, then verify the actual executable and smoke-test this path before enabling the backend switch. | -| 🟠 fix in this PR · P2 | [CLI `artifacts.ts:82`](https://github.com/base44/cli/blob/652021ef670cdabca254a0962fdbd5e40c730120/packages/cli/src/core/version/artifacts.ts#L82) | `Promise.all` starts one streaming hash per file without a concurrency bound. Under production Node with a 256-descriptor limit, a 1,500-file fixture fails with `EMFILE`, well below the advertised file-count ceiling. | Reproduced under Node 24 | Bound stat/hash work with the existing `p-map` utility. Upload concurrency does not limit this earlier phase. | -| 🟠 fix in this PR · P2 | [apper `versions_api.py:294`](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/build_deploy/versions_api.py#L294) and [receipt write:323](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/build_deploy/versions_api.py#L323) | The retry receipt is written to Redis after publication. If that write fails, the endpoint reports failure although the version is live. Retrying the same key can publish again; the isolated handler probe produced A → B → A. Concurrent misses are also unclaimed, and the receipt stores neither requested version nor target. | Ran post-publication failure probe; read other cases | Persist/recover request identity with the deployment and reject reuse for a different request. A receipt-cache failure must not misreport a known committed publication. | -| 🟠 fix in this PR · P2 | [apper `producer.py:137`](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/build_deploy/producer.py#L137) | The schema-byte budget counts only dictionary values. A schema supplied as a string larger than the budget passes declaration and is serialized into Redis; resource names are excluded from the budget too. Later rejection does not bound ingestion. | Ran actual validator with an oversized non-object value | Reject non-object schemas before saving; bound the complete serialized schema map, including names. | -| 🟠 fix in this PR · P2 | [apper `versions_api.py:243`](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/build_deploy/versions_api.py#L243) | An empty declaration can finalize into a version; neither declaration nor `validate_artifacts()` requires `index.html`. The requirement is checked only in `static_bundle.prepare()` during deploy. Create-version success therefore does not mean the admitted static artifact set is deployable. | Ran declaration validator; read finalize/store/prepare | Enforce static completeness at the shared version-validation boundary before recording the version. Keep fullstack validation separate. | -| 🟠 fix in this PR · P2 | [CLI `publish.ts:55`](https://github.com/base44/cli/blob/652021ef670cdabca254a0962fdbd5e40c730120/packages/cli/src/cli/commands/publish.ts#L55) | Output-directory resolution and artifact/resource collection run outside `tagStep`. A missing output directory emits JSON without `step`, so the sandbox cannot distinguish rejected build output from a transport failure. | Reproduced using the actual CLI | Tag the whole collection/create operation, including local validation, as `create_version`. Preserve the original error type. | +| 🔴 blocker · P1 | [apper `sandbox_build_operations.py:568`](https://github.com/base44-dev/apper/blob/9e1400550fce5f2b3f980e06028632af32c30bce/backend/app/user_apps/sandbox/sandbox_build_operations.py#L568) | The late-minted key still has the broad `APPS_DEPLOY` scope, and the build and publish execs share the sandbox without a new process-user boundary. Brief S4 requires the new endpoints only and protection from surviving build processes. The same scope also authorizes legacy auth-configuration mutation. | Read minting, execution, scope policy, and [legacy auth mutation](https://github.com/base44-dev/apper/blob/9e1400550fce5f2b3f980e06028632af32c30bce/backend/app/user_apps/backend_functions/builder_api.py#L505); no live exploit test | Enforce an endpoint restriction for this credential and isolate the credential-bearing publisher from build-owned processes. App-wide versus version-bound authority is a separate open choice under §9.1. | +| 🔴 blocker · P1, rollout | [sandbox CLI pin](https://github.com/base44-dev/apper/blob/9e1400550fce5f2b3f980e06028632af32c30bce/backend/app/user_apps/sandbox/base44_cli_version#L1) | The pin remains `0.1.14`. Releasing CLI #626 does not update the executable installed in either sandbox image, while the backend requires its new commands and build behavior. | Read pin and both [Modal](https://github.com/base44-dev/apper/blob/9e1400550fce5f2b3f980e06028632af32c30bce/sandbox/sandbox_app_runtime/modal/Dockerfile#L48) and [Cloudflare](https://github.com/base44-dev/apper/blob/9e1400550fce5f2b3f980e06028632af32c30bce/sandbox/sandbox_app_runtime/cloudflare/Dockerfile#L61) Dockerfiles | Release CLI, update the pin, rebuild and roll the images, and run the static smoke test before backend enablement. This is a release prerequisite. | +| 🟠 fix in this PR · P2 | [apper `versions_api.py:302`](https://github.com/base44-dev/apper/blob/9e1400550fce5f2b3f980e06028632af32c30bce/backend/app/user_apps/build_deploy/versions_api.py#L302), [failure paths](https://github.com/base44-dev/apper/blob/9e1400550fce5f2b3f980e06028632af32c30bce/backend/app/user_apps/build_deploy/versions_api.py#L328), [receipt write](https://github.com/base44-dev/apper/blob/9e1400550fce5f2b3f980e06028632af32c30bce/backend/app/user_apps/build_deploy/producer.py#L304) | A request claims its key, but failure never releases or completes the claim. A temporary preparation failure returns `502`; the same request then returns `409 already running` for the one-hour expiry even after the dependency recovers. If receipt writing fails after success and the response is lost, the caller also cannot recover the committed result. | Reproduced both cases using actual handler and producer bodies with storage/context doubles | Validate before claiming where possible. Give failures before publication a completed failure or safely released claim. Recover a committed deployment by request identity when receipt persistence fails. Do not blindly release a claim after publication might have happened. | +| 🟠 fix in this PR · P2 | [apper `builder_api.py:1398`](https://github.com/base44-dev/apper/blob/9e1400550fce5f2b3f980e06028632af32c30bce/backend/app/user_apps/app_deployments/builder_api.py#L1398), [new build](https://github.com/base44-dev/apper/blob/9e1400550fce5f2b3f980e06028632af32c30bce/backend/app/user_apps/sandbox/sandbox_build_operations.py#L519) | Builder still prepares the legacy frontend before reaching the new lane, which starts another sandbox and frontend build. Brief S1 and definition-of-done item 10 explicitly require one build. The outer preparation is older code; replacing reuse of its dist with a fresh CLI build introduces the second build. | Traced outer preparation through `_publish_from_recorded_version()` to sandbox publication | Route the enrolled flow through one preparation/build and carry its result forward; preserve the ordinary legacy flow. | +| 🟢 your call | [CLI `Base44Command.ts:20`](https://github.com/base44/cli/blob/85429f91eab25987e0199c1852daa79a2d663b67/packages/cli/src/cli/utils/command/Base44Command.ts#L20) | The common command lifecycle imports error metadata from the version publish composer. This couples general command handling to one feature. | Read imports | Move `tagStep`/`stepOf` to shared error infrastructure when convenient. | +| 🟢 your call | [committed review report](https://github.com/base44/cli/blob/85429f91eab25987e0199c1852daa79a2d663b67/pr-review-626-24525.md#L1) | The CLI commit includes these generated review reports, whose original contents refer to older heads. | Read PR file list and report | Remove review output from the product PR, or give it an intentional documentation home. The local reports are updated by this review. | -🔴 blocker — do not merge the workflow · 🟠 fix in this PR — belongs in these changes. +🔴 blocker — blocks the complete rollout · 🟠 fix in this PR — required branch work · 🟢 your call — optional cleanup. -## SOLID/KISS: keep the primitives, fix the shared rules +## SOLID/KISS supports the current structure | Principle | Assessment | | --- | --- | -| Single responsibility | Build, version storage, and deployment remain distinct. However, finalization’s verify → construct → create → save receipt → cleanup workflow exists only in the HTTP handler. Move that composition behind an ordinary Python service function. | -| Open/closed and interface segregation | Explicit calls per artifact kind and small request bodies are appropriate. A generic registry, matching interfaces for unrelated kinds, or new microservices would add work without solving these defects. | -| Substitution | In-memory and staged static storage share index construction, which is sound. The real producers are not interchangeable yet: their treatment of built-in `User` differs. The equivalence test hand-builds identical payloads and misses that boundary. | -| Dependency direction | CLI core remains free of UI dependencies. Move `tagStep`/`stepOf` into shared error infrastructure: the common command lifecycle should not import the feature’s publish composer. | -| Simplicity | Reusing the uploader and centralizing deploy-plus-domain-binding are good choices. Avoid treating post-publication Redis bookkeeping as a second authority. Keep the retained in-process path only if its supported use is explicit and tested against the same normalization rules. | +| Single responsibility | Build, record, and deploy remain separate. HTTP handlers may construct `BuildOutput` and call the shared operations; the brief explicitly prescribes this. No extra orchestration framework is needed. | +| Substitution | Shared index construction and server-side `User` normalization now align the two producer paths for the reviewed cases. | +| Dependency direction | CLI core remains independent of presentation. The shared command-to-feature import above is a small cleanup, not a reason to redesign the layers. | +| Simplicity | Bounded `p-map`, explicit artifact kinds, shared uploads, and shared deploy-plus-domain-binding are appropriate. Keep the in-process path required by B6. | +| Failure handling | The request claim needs a complete failure lifecycle. B4 requires request replay; it does not require the attempts framework, generations, outbox, or GC excluded by §2. | -## The design deviations are larger than naming or style +## Other scope distinctions remain relevant -| Design requirement | Actual behavior | Assessment | -| --- | --- | --- | -| Fullstack first; complete, versioned artifact descriptor — §§2.1, 19, 21 | Static file list only; `index.html` is mandatory; the backend explicitly refuses fullstack apps; Build returns a status message, not an artifact descriptor | Major scope deviation. Useful static transport work, but not the planned fullstack milestone. The wire lacks an explicit format version/site discriminator. | -| One checkout and one build — §§18.4, 20.1 | Outer `_prepare_commit_build()` still prepares the legacy artifact, then the new lane starts another sandbox and frontend build | The outer preparation predates this PR; the second frontend build comes from replacing reuse of `ExistingCommitDist` with a new CLI build. Pass one prepared version forward. | -| Committed dependencies or recorded pinned overlay — §4 | New path removes `update_packages`, but still calls the installer that runs `npm install --ignore-scripts` | Improvement, not lockfile-exact reproduction. Use an immutable install policy for this lane. | -| Serving authority survives admission being disabled — §19 | `runtime.current()` returns `None` when the flag is off; the description advertises flag-off rollback to legacy pointers | Inherited implementation, still contrary to the design. A kill switch is not a safe rollback once legacy pointers are stale. | -| Session-bound publishing capability — §16.1 | Late-minted, app-scoped `apps:deploy` key; no upload-session/version binding | App scoping and revocation help, but the key can deploy another known retained version of that app. The broader scope needs an explicit design decision; late injection does not isolate background build processes. | -| Unsupported resources fail explicitly — §§19, 20.1 | CLI collects only files/entities/agents; backend function refusal consults current app metadata | Local newly added function files can be omitted without refusal when the app’s metadata still reports no functions. Admission must account for the submitted build’s declared capabilities. The design’s unversioned-agent/skill admission rule is also not represented here. | -| Endpoint adds no behavior — §§17, 20.1 | Deploy-plus-bind is shared, but admission enforcement/finalization and retry handling remain in HTTP orchestration | Sharing helper functions is not enough: expose the same application operation to Python and HTTP callers. Keep transport parsing/auth/status mapping in the adapter. | -| Server verifies staged content — §7 | S3 verifies a signed SHA-256 at PUT time; finalize checks presence/size and copies | Reasonable documented substitution, conditional on real S3 enforcement. Presigned URLs are reusable until expiry; the signature fixes the allowed bytes, not the number of writes. | +| Topic | Assessment | +| --- | --- | +| Fullstack, manifest v2, Cloudflare deployment work | Explicitly outside this slice. Raw entity and agent schemas are in scope. | +| Standalone build envelope | A reusable artifact collector exists, but `base44 build --json` still returns status. C1’s command-envelope wording remains a contract clarification. | +| Function admission | Server admission checks current app metadata. Local function files newly added before metadata synchronization are not checked by the collector. Source-level refusal remains an admission gap to settle; supporting those functions is not requested. | +| Package installation | Removing `update_packages` satisfies the explicit S3 change. The inherited `npm install --ignore-scripts` reproducibility issue does not require an installer rewrite in this review. | +| Flag-off rollback | Disabling the existing flag restores legacy reads. That inherited behavior can expose stale legacy pointers; it is distinct from deploying an older stored version. | +| Upload verification | Signed SHA-256 verification at S3 PUT time is a documented substitution for backend re-hashing. The signature constrains bytes; it does not make a presigned URL single-use. See [AWS presigned URL semantics](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html). | -References: [design document](/Users/yurym/Desktop/build-deploy-design.md), [outer preparation](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/app_deployments/builder_api.py#L1398), [legacy frontend reuse](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/build_deploy/service.py#L217), [mutable installer](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/builder/git/npm_sandbox_driver.py#L587), [runtime flag check](https://github.com/base44-dev/apper/blob/b3f5e04fd77af265a2a0db4545fdb50625a1a30b/backend/app/user_apps/build_deploy/runtime.py#L53), [AWS presigned URL semantics](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html). +## Local verification does not establish release readiness -## The missing staging proof changes the merge decision +| Coverage | Result | +| --- | --- | +| CLI build and focused suite — ran it | Current build succeeded; all 49 tests passed across six version/build test files. | +| Targeted regressions — ran it | Node descriptor-limit test and pinned SDK-model validation passed. Backend helper probes verified normalization, schema budgets, static completeness, normal replay, and conflicting key rejection; failure replay reproduced the remaining defect. | +| Full backend suite — did not run locally | No configured backend virtual environment was available. Isolated probes use data-container and storage/context doubles; they do not exercise Pydantic, authentication, or real infrastructure. | +| Current CI — read status | CLI checks completed successfully. Backend checks were still running, with no reported failures at 13:00 UTC. | +| Real S3, deployed image, cold deploy, rollback — did not run | These remain staging gates from the static brief. No live credential-isolation test was run. | -Green unit tests cannot establish image rollout, S3 checksum enforcement, or reconstruction after the builder disappears. Full backend tests were not run locally in this review. Current CI also remains red: [CLI Windows failure](https://github.com/base44/cli/actions/runs/34963055505/job/104361139812), [backend shard failure](https://github.com/base44-dev/apper/actions/runs/34964708923/job/104366818745). +A green CLI suite cannot prove the image contains that CLI. A mocked copy cannot prove checksum enforcement. Neither establishes cold replay after the producer disappears. -## Verify the repaired workflow in this order +## Check the remaining behavior before rollout -1. Run create-version using the repository’s locked AWS SDK; expect successful conditional copy, plus refusal of a wrong-checksum upload against real S3. -2. Publish an app without `User.jsonc`; expect `User: {}` in the stored artifact set and working user-management endpoints. Repeat with customized User fields. -3. Collect a large fixture under a low descriptor limit; expect bounded hashing and success. Missing output must produce `step: create_version`. -4. Fail the retry-receipt write after publication, publish a newer version, and retry the original request; expect recovery of the original result without republishing it. -5. Reject oversized/non-object schemas and incomplete static output before version creation succeeds. -6. Run the pinned sandbox CLI once, finalize, destroy the sandbox, then deploy/redeploy/rollback from S3. For the stated design milestone, include SSR, a server route, and an asset from a real fullstack fixture. +1. Fail preparation once and retry the same key after recovery. Expect an accurate completed outcome or a safe retry, never a false claim that a finished request is still running. +2. Fail receipt persistence after publication and lose the response. Retry the same key; expect the original deployment reference without republishing. Also verify concurrent callers and conflicting version/target reuse. +3. Leave a process running after the build. Expect it to have no access to the publish credential; expect that credential to be refused by legacy mutation endpoints. +4. Trace one enrolled Builder publication. Expect one exact checkout/build and the intended released CLI inside the deployed image. +5. Publish a static zero-function app against real S3, including absent/customized `User`. Reject a wrong checksum, remove the sandbox and checkout, deploy the stored version, then roll back to an older one. Expect correct frontend/user schemas and no Cloudflare deployment calls. -**In one line:** These PRs add a useful static version-ingress path, but the current implementation can fail finalization, omit a required schema, and does not deliver the fullstack-first workflow. +**In one line:** Collection and version creation are repaired; backend retry recovery, single-build integration, credential restrictions, and image rollout remain. ## Decision -**Rework.** Fix the correctness findings and either implement the planned fullstack slice or explicitly revise the scope to a static transport slice. The complete rollout order is **CLI fixes → release → sandbox pin/image rollout and smoke test → backend enablement**. CLI release alone is insufficient. +**Merge after the remaining backend fixes and release gates.** The reviewed CLI fixes are sound and its checks are green. CLI can proceed as the gated prerequisite release, with report-file cleanup recommended. Backend enablement still needs the fixes above, a released CLI pinned into deployed images, green backend CI, and the static staging proof. Fullstack remains phase 2. From c71301bb550c4818044f8ae60a19733db349d382 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Wed, 16 Sep 2026 09:53:35 +0300 Subject: [PATCH 12/21] refactor: stop synthesizing a content type the s3 arm never sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AssetFile.contentType` exists for the Cloudflare arm, which puts it on each multipart part. The s3 arm echoes the type the server signed into the presigned URL — deriving a second opinion here is how the two diverge — so the version lane was filling the field with `application/octet-stream` purely to satisfy the shape. It read as though it decided what the file is served as, and it did not. The field is optional now, and the version lane omits it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/src/core/site/schema.ts | 3 ++- packages/cli/src/core/version/api.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/core/site/schema.ts b/packages/cli/src/core/site/schema.ts index 713a5760..67f45bff 100644 --- a/packages/cli/src/core/site/schema.ts +++ b/packages/cli/src/core/site/schema.ts @@ -37,7 +37,8 @@ export interface AssetFile { absolutePath: string; hash: string; size: number; - contentType: string; + /** Read by the cf arm only — the s3 arm echoes the type the server signed. */ + contentType?: string; } export interface AssetManifestResult { diff --git a/packages/cli/src/core/version/api.ts b/packages/cli/src/core/version/api.ts index 19926d16..4b518e78 100644 --- a/packages/cli/src/core/version/api.ts +++ b/packages/cli/src/core/version/api.ts @@ -97,12 +97,12 @@ export async function createVersion( filesByHash: new Map( artifacts.files.map((file) => [ file.digest, + // No contentType: the PUT echoes the one the server signed into the + // URL, and deriving a second opinion here is how they diverge. { absolutePath: file.absolutePath, hash: file.digest, size: file.size, - // The PUT echoes the server's signed value, never this one. - contentType: "application/octet-stream", }, ]), ), From 3fddea17441e0c1d5a8a1031cdfd8e8ddc50e5cf Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Wed, 16 Sep 2026 11:06:46 +0300 Subject: [PATCH 13/21] refactor: one walk for both lanes, and stop tracking the review files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Duplication (review comment).** `collectBuildOutput` had its own copy of the deployments lane's globby call, its `.assetsignore` handling, the `ALWAYS_IGNORED` set and even the comment about globby's `ignore`/`ignoreFiles` footgun. What counts as "a file this build produced" is one rule, and two collectors disagreeing about it would mean the two lanes publish different sets from the same directory. `walkBuildOutput` in `core/site/manifest.ts` is now that rule; both callers use it and then do their own per-file work. The hashes stay separate on purpose — `hashAsset` is a salted, truncated provider cache key, `digestFile` is full sha256 artifact identity. **Stray files.** `pr-review-626-24525.{md,html}` were committed by accident; untracked and gitignored. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 + packages/cli/src/core/site/manifest.ts | 40 +++++--- packages/cli/src/core/version/artifacts.ts | 27 +----- pr-review-626-24525.html | 79 ---------------- pr-review-626-24525.md | 103 --------------------- 5 files changed, 32 insertions(+), 221 deletions(-) delete mode 100644 pr-review-626-24525.html delete mode 100644 pr-review-626-24525.md diff --git a/.gitignore b/.gitignore index ed29b187..8b3bab56 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,7 @@ deno.lock # Python bytecode — from running the .github/scripts checks locally. __pycache__/ *.pyc + +# Local review artifacts +pr-review-*.md +pr-review-*.html diff --git a/packages/cli/src/core/site/manifest.ts b/packages/cli/src/core/site/manifest.ts index ef50fba0..3177bc7c 100644 --- a/packages/cli/src/core/site/manifest.ts +++ b/packages/cli/src/core/site/manifest.ts @@ -64,6 +64,29 @@ function getAssetContentType(filePath: string): string { * means a tenant can only collide with their own files, so a malicious upload * cannot poison another app's asset cache. */ +/** + * Every file a build emitted, as sorted forward-slash relative paths. + * + * Shared with the versions lane: what counts as "a file this build produced" is + * one rule — `.assetsignore` with full gitignore semantics, plus the names no + * build ever ships — and two collectors disagreeing about it would mean the two + * lanes publish different sets from the same directory. What each does with a + * path afterwards is its own business. + */ +export async function walkBuildOutput(outputDir: string): Promise { + // globby returns forward-slash paths on every platform. 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: outputDir, + dot: true, + onlyFiles: true, + followSymbolicLinks: false, + ignoreFiles: [ASSETS_IGNORE_FILE], + }); + return found.filter((path) => !ALWAYS_IGNORED.has(basename(path))).sort(); +} + export function hashAsset(appId: string, content: Buffer): string { return createHash("sha256") .update(Buffer.from(appId, "utf8")) @@ -100,20 +123,7 @@ export async function buildAssetManifest( const manifest: Record = {}; const filesByHash = new Map(); - // 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: [ASSETS_IGNORE_FILE], - }); - const relativeFilePaths = found.filter( - (path) => !ALWAYS_IGNORED.has(basename(path)), - ); + const relativeFilePaths = await walkBuildOutput(assetsDir); if (relativeFilePaths.length > MAX_ASSET_COUNT) { throw new InvalidInputError( @@ -121,7 +131,7 @@ export async function buildAssetManifest( ); } - for (const relativePath of relativeFilePaths.sort()) { + for (const relativePath of relativeFilePaths) { const absolutePath = join(assetsDir, ...relativePath.split("/")); const { size } = await stat(absolutePath); const hash = await hashAssetFile(appId, absolutePath); diff --git a/packages/cli/src/core/version/artifacts.ts b/packages/cli/src/core/version/artifacts.ts index ae20ac30..3edb2ce4 100644 --- a/packages/cli/src/core/version/artifacts.ts +++ b/packages/cli/src/core/version/artifacts.ts @@ -1,11 +1,12 @@ import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { stat } from "node:fs/promises"; -import { basename, join } from "node:path"; +import { join } from "node:path"; import { globby } from "globby"; import pMap from "p-map"; import { CONFIG_FILE_EXTENSION_GLOB } from "@/core/consts.js"; import { InvalidInputError } from "@/core/errors.js"; +import { walkBuildOutput } from "@/core/site/manifest.js"; import { pathExists, readJsonFile } from "@/core/utils/fs.js"; import type { ArtifactFile, ArtifactSet } from "@/core/version/schema.js"; @@ -20,15 +21,6 @@ const MAX_FILE_COUNT = 50_000; /** Open descriptors while hashing. Well under the 256 a production Node keeps. */ const HASH_CONCURRENCY = 32; -const ASSETS_IGNORE_FILE = ".assetsignore"; - -/** Never part of a frontend, whatever `.assetsignore` says. */ -const ALWAYS_IGNORED = new Set([ - ASSETS_IGNORE_FILE, - "wrangler.json", - ".dev.vars", -]); - /** The platform refuses a set without it; failing here saves the upload. */ const ENTRY = "index.html"; @@ -52,20 +44,7 @@ async function digestFile(absolutePath: string): Promise { export async function collectBuildOutput( outputDir: string, ): Promise { - // globby returns forward-slash paths on every platform, which is how the - // version 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: outputDir, - dot: true, - onlyFiles: true, - followSymbolicLinks: false, - ignoreFiles: [ASSETS_IGNORE_FILE], - }); - const relativePaths = found - .filter((path) => !ALWAYS_IGNORED.has(basename(path))) - .sort(); + const relativePaths = await walkBuildOutput(outputDir); if (relativePaths.length === 0) { throw new InvalidInputError( diff --git a/pr-review-626-24525.html b/pr-review-626-24525.html deleted file mode 100644 index 85896072..00000000 --- a/pr-review-626-24525.html +++ /dev/null @@ -1,79 +0,0 @@ -CLI #626 + apper #24525 — architecture review

Template: T3 · Review — re-review of the static CLI publishing slice.

-

Task: Assess CLI #626 and apper #24525 for correctness, SOLID/KISS, and the implementation brief.

-

Where we stand: Six correctness findings are resolved; backend changes and rollout requirements remain.

-

Last time: The accepted cohort was a static web frontend with zero backend functions.

-

Now: Review of CLI 85429f9 and apper 9e140055, checked on 15 September 2026.

-

Blockers: 2 · Blast radius: 🟠 shared publishing code. The blockers concern sandbox credentials and image rollout; retry recovery and the duplicate build also need fixes.

-

The fixes repair collection and version creation

-

Before. The CLI could exhaust file descriptors or omit failure-step information. Backend finalization used an unsupported SDK argument, omitted built-in User, and accepted invalid schema budgets or incomplete frontends.

-

After. Hashing is bounded, local collection failures identify create_version, the copy call fits the pinned SDK, server normalization restores User, and validation rejects those invalid inputs. Deployment requests now claim their retry key before publishing.

-

Why this shape. Shared validation and the existing uploader keep the implementation small. Claiming a request before publication is necessary, but the claim also needs a defined outcome when preparation or receipt persistence fails.

-

The governing scope is after-23460.md. The end-state design is background where that brief narrows the milestone. Fullstack remains phase 2 and is not a merge requirement.

-

Shared helpers determine the review’s reach

-
- - - - - - -
Changed surfaceDirect importing production filesEvidence
s3_service.py26Counted static imports at the reviewed backend head
kinds/schemas.py4Includes both source extraction and HTTP normalization
version.py3Shared version creation now rejects incomplete static frontends
service.py2Builder flow and versions API
sandbox_build_operations.py1Shared sandbox integration
Base44Command.ts1 direct barrel importerBarrel exports make the indirect reach larger; this is the shared command lifecycle
-

The HTML appendix contains counts for all 30 changed production modules. Counts describe static imports, including re-exports; they do not measure runtime calls.

-

Six findings are resolved at these commits

-
- - - - - - -
FindingCurrent resultChecked
Unsupported conditional CopyObject parameterRemoved. The actual current method’s parameters pass validation against the pinned botocore S3 model.Ran SDK-model validation; no AWS request
Missing built-in Userschemas.normalize_entities() adds User: {} when absent and preserves customized fields. HTTP finalization uses it.Ran actual normalization/extraction bodies with absent and customized User; read the revised parity test
Unbounded hashingUses existing p-map with bounded concurrency.Ran actual collector under production Node with 1,500 files and a 256-descriptor limit; succeeded
Schema-budget bypassNon-object payloads are rejected; complete maps, including names, count toward the budget.Ran actual validator with oversized names and non-object values; both rejected
Empty or unenterable frontendShared version validation rejects empty sets and sets missing index.html, for bytes and staged descriptors.Ran actual validation helper for both representations
Missing collection error stepOutput resolution, hashing, and resource reads are inside tagStep("create_version", ...).Built current CLI; regression test passed
-

The copy now overwrites an existing destination with the same content-addressed bytes. Under the declared AWS checksum contract, that closes the SDK defect without changing artifact content. It is no longer a create-only storage operation. Real checksum enforcement and copy behavior still need staging proof. The comment saying IfNoneMatch is “PutObject-only” should describe the pinned SDK limitation; the current S3 CopyObject API supports the header.

-

Fix these before merging the complete workflow

-
-
- - - - - - -
SeverityWhereFinding and consequenceCheckedRequired change
🔴 blocker · P1apper sandbox_build_operations.py:568The late-minted key still has the broad APPS_DEPLOY scope, and the build and publish execs share the sandbox without a new process-user boundary. Brief S4 requires the new endpoints only and protection from surviving build processes. The same scope also authorizes legacy auth-configuration mutation.Read minting, execution, scope policy, and legacy auth mutation; no live exploit testEnforce an endpoint restriction for this credential and isolate the credential-bearing publisher from build-owned processes. App-wide versus version-bound authority is a separate open choice under §9.1.
🔴 blocker · P1, rolloutsandbox CLI pinThe pin remains 0.1.14. Releasing CLI #626 does not update the executable installed in either sandbox image, while the backend requires its new commands and build behavior.Read pin and both Modal and Cloudflare DockerfilesRelease CLI, update the pin, rebuild and roll the images, and run the static smoke test before backend enablement. This is a release prerequisite.
🟠 fix in this PR · P2apper versions_api.py:302, failure paths, receipt writeA request claims its key, but failure never releases or completes the claim. A temporary preparation failure returns 502; the same request then returns 409 already running for the one-hour expiry even after the dependency recovers. If receipt writing fails after success and the response is lost, the caller also cannot recover the committed result.Reproduced both cases using actual handler and producer bodies with storage/context doublesValidate before claiming where possible. Give failures before publication a completed failure or safely released claim. Recover a committed deployment by request identity when receipt persistence fails. Do not blindly release a claim after publication might have happened.
🟠 fix in this PR · P2apper builder_api.py:1398, new buildBuilder still prepares the legacy frontend before reaching the new lane, which starts another sandbox and frontend build. Brief S1 and definition-of-done item 10 explicitly require one build. The outer preparation is older code; replacing reuse of its dist with a fresh CLI build introduces the second build.Traced outer preparation through _publish_from_recorded_version() to sandbox publicationRoute the enrolled flow through one preparation/build and carry its result forward; preserve the ordinary legacy flow.
🟢 your callCLI Base44Command.ts:20The common command lifecycle imports error metadata from the version publish composer. This couples general command handling to one feature.Read importsMove tagStep/stepOf to shared error infrastructure when convenient.
🟢 your callcommitted review reportThe CLI commit includes these generated review reports, whose original contents refer to older heads.Read PR file list and reportRemove review output from the product PR, or give it an intentional documentation home. The local reports are updated by this review.
-

🔴 blocker — blocks the complete rollout · 🟠 fix in this PR — required branch work · 🟢 your call — optional cleanup.

-

SOLID/KISS supports the current structure

-
- - - - - -
PrincipleAssessment
Single responsibilityBuild, record, and deploy remain separate. HTTP handlers may construct BuildOutput and call the shared operations; the brief explicitly prescribes this. No extra orchestration framework is needed.
SubstitutionShared index construction and server-side User normalization now align the two producer paths for the reviewed cases.
Dependency directionCLI core remains independent of presentation. The shared command-to-feature import above is a small cleanup, not a reason to redesign the layers.
SimplicityBounded p-map, explicit artifact kinds, shared uploads, and shared deploy-plus-domain-binding are appropriate. Keep the in-process path required by B6.
Failure handlingThe request claim needs a complete failure lifecycle. B4 requires request replay; it does not require the attempts framework, generations, outbox, or GC excluded by §2.
-

Other scope distinctions remain relevant

-
- - - - - - -
TopicAssessment
Fullstack, manifest v2, Cloudflare deployment workExplicitly outside this slice. Raw entity and agent schemas are in scope.
Standalone build envelopeA reusable artifact collector exists, but base44 build --json still returns status. C1’s command-envelope wording remains a contract clarification.
Function admissionServer admission checks current app metadata. Local function files newly added before metadata synchronization are not checked by the collector. Source-level refusal remains an admission gap to settle; supporting those functions is not requested.
Package installationRemoving update_packages satisfies the explicit S3 change. The inherited npm install --ignore-scripts reproducibility issue does not require an installer rewrite in this review.
Flag-off rollbackDisabling the existing flag restores legacy reads. That inherited behavior can expose stale legacy pointers; it is distinct from deploying an older stored version.
Upload verificationSigned SHA-256 verification at S3 PUT time is a documented substitution for backend re-hashing. The signature constrains bytes; it does not make a presigned URL single-use. See AWS presigned URL semantics.
-

Local verification does not establish release readiness

-
- - - - - -
CoverageResult
CLI build and focused suite — ran itCurrent build succeeded; all 49 tests passed across six version/build test files.
Targeted regressions — ran itNode descriptor-limit test and pinned SDK-model validation passed. Backend helper probes verified normalization, schema budgets, static completeness, normal replay, and conflicting key rejection; failure replay reproduced the remaining defect.
Full backend suite — did not run locallyNo configured backend virtual environment was available. Isolated probes use data-container and storage/context doubles; they do not exercise Pydantic, authentication, or real infrastructure.
Current CI — read statusCLI checks completed successfully. Backend checks were still running, with no reported failures at 13:00 UTC.
Real S3, deployed image, cold deploy, rollback — did not runThese remain staging gates from the static brief. No live credential-isolation test was run.
-

A green CLI suite cannot prove the image contains that CLI. A mocked copy cannot prove checksum enforcement. Neither establishes cold replay after the producer disappears.

-

Check the remaining behavior before rollout

-
    -
  1. Fail preparation once and retry the same key after recovery. Expect an accurate completed outcome or a safe retry, never a false claim that a finished request is still running.
  2. -
  3. Fail receipt persistence after publication and lose the response. Retry the same key; expect the original deployment reference without republishing. Also verify concurrent callers and conflicting version/target reuse.
  4. -
  5. Leave a process running after the build. Expect it to have no access to the publish credential; expect that credential to be refused by legacy mutation endpoints.
  6. -
  7. Trace one enrolled Builder publication. Expect one exact checkout/build and the intended released CLI inside the deployed image.
  8. -
  9. Publish a static zero-function app against real S3, including absent/customized User. Reject a wrong checksum, remove the sandbox and checkout, deploy the stored version, then roll back to an older one. Expect correct frontend/user schemas and no Cloudflare deployment calls.
  10. -
-

In one line: Collection and version creation are repaired; backend retry recovery, single-build integration, credential restrictions, and image rollout remain.

-
Direct importer counts for changed production modules

Computed from static imports at the reviewed heads. Barrel exports can hide many indirect callers; a direct count is not a runtime fan-out estimate.

RepositoryModuleDirect importing filesReferences
clipackages/cli/src/cli/commands/project/build.ts1packages/cli/src/cli/program.ts
clipackages/cli/src/cli/commands/publish.ts1packages/cli/src/cli/program.ts
clipackages/cli/src/cli/commands/versions/create.ts1packages/cli/src/cli/commands/versions/index.ts
clipackages/cli/src/cli/commands/versions/deploy.ts1packages/cli/src/cli/commands/versions/index.ts
clipackages/cli/src/cli/commands/versions/index.ts1packages/cli/src/cli/program.ts
clipackages/cli/src/cli/commands/versions/options.ts3packages/cli/src/cli/commands/publish.ts
packages/cli/src/cli/commands/versions/create.ts
packages/cli/src/cli/commands/versions/deploy.ts
clipackages/cli/src/cli/program.ts1packages/cli/src/cli/index.ts
clipackages/cli/src/cli/utils/command/Base44Command.ts1packages/cli/src/cli/utils/command/index.ts
clipackages/cli/src/core/site/git-hash.ts1packages/cli/src/core/site/index.ts
clipackages/cli/src/core/site/schema.ts8packages/cli/src/core/project/api.ts
packages/cli/src/core/site/api.ts
packages/cli/src/core/site/deploy.ts
packages/cli/src/core/site/deployment.ts
packages/cli/src/core/site/index.ts
packages/cli/src/core/site/manifest.ts
packages/cli/src/core/site/modules.ts
packages/cli/src/core/site/upload.ts
clipackages/cli/src/core/site/upload.ts3packages/cli/src/core/site/deployment.ts
packages/cli/src/core/site/index.ts
packages/cli/src/core/version/api.ts
clipackages/cli/src/core/version/api.ts2packages/cli/src/core/version/index.ts
packages/cli/src/core/version/publish.ts
clipackages/cli/src/core/version/artifacts.ts1packages/cli/src/core/version/index.ts
clipackages/cli/src/core/version/gate.ts2packages/cli/src/cli/program.ts
packages/cli/src/core/version/index.ts
clipackages/cli/src/core/version/index.ts5packages/cli/src/cli/commands/project/build.ts
packages/cli/src/cli/commands/publish.ts
packages/cli/src/cli/commands/versions/create.ts
packages/cli/src/cli/commands/versions/deploy.ts
packages/cli/src/cli/commands/versions/options.ts
clipackages/cli/src/core/version/project.ts1packages/cli/src/core/version/index.ts
clipackages/cli/src/core/version/publish.ts2packages/cli/src/cli/utils/command/Base44Command.ts
packages/cli/src/core/version/index.ts
clipackages/cli/src/core/version/schema.ts4packages/cli/src/core/version/api.ts
packages/cli/src/core/version/artifacts.ts
packages/cli/src/core/version/index.ts
packages/cli/src/core/version/publish.ts
apperbackend/app/user_apps/app_deployments/builder_api.py1backend/route_registry.py
apperbackend/app/user_apps/build_deploy/artifact_store.py8backend/app/user_apps/app_deployments/s3_dist_service.py
backend/app/user_apps/build_deploy/deploy.py
backend/app/user_apps/build_deploy/kinds/schemas.py
backend/app/user_apps/build_deploy/kinds/static_bundle.py
backend/app/user_apps/build_deploy/kinds/worker_bundles.py
backend/app/user_apps/build_deploy/producer.py
backend/app/user_apps/build_deploy/runtime.py
backend/app/user_apps/build_deploy/version.py
apperbackend/app/user_apps/build_deploy/contracts.py11backend/app/user_apps/build_deploy/build.py
backend/app/user_apps/build_deploy/deploy.py
backend/app/user_apps/build_deploy/kinds/schemas.py
backend/app/user_apps/build_deploy/kinds/static_bundle.py
backend/app/user_apps/build_deploy/kinds/worker_bundles.py
backend/app/user_apps/build_deploy/producer.py
backend/app/user_apps/build_deploy/runtime.py
backend/app/user_apps/build_deploy/service.py
backend/app/user_apps/build_deploy/store.py
backend/app/user_apps/build_deploy/version.py
backend/app/user_apps/build_deploy/versions_api.py
apperbackend/app/user_apps/build_deploy/kinds/schemas.py4backend/app/user_apps/build_deploy/build.py
backend/app/user_apps/build_deploy/deploy.py
backend/app/user_apps/build_deploy/version.py
backend/app/user_apps/build_deploy/versions_api.py
apperbackend/app/user_apps/build_deploy/kinds/static_bundle.py3backend/app/user_apps/build_deploy/build.py
backend/app/user_apps/build_deploy/deploy.py
backend/app/user_apps/build_deploy/version.py
apperbackend/app/user_apps/build_deploy/producer.py1backend/app/user_apps/build_deploy/versions_api.py
apperbackend/app/user_apps/build_deploy/service.py2backend/app/user_apps/app_deployments/builder_api.py
backend/app/user_apps/build_deploy/versions_api.py
apperbackend/app/user_apps/build_deploy/version.py3backend/app/user_apps/build_deploy/service.py
backend/app/user_apps/build_deploy/versions_api.py
backend/scripts/migrations/ensure_application_indexes.py
apperbackend/app/user_apps/build_deploy/versions_api.py1backend/route_registry.py
apperbackend/app/user_apps/s3/s3_service.py26backend/app/agent_ops/service.py
backend/app/ai/providers/llm_dump.py
backend/app/fullstack_hosting/service.py
backend/app/gdpr/data_sources/llm_dumps.py
backend/app/preview_frontend.py
backend/app/preview_frontend_bundle.py
backend/app/skills_registry/registry_service.py
backend/app/static_files_fallback.py
backend/app/user_apps/admin/core_integrations_api.py
backend/app/user_apps/admin/import_export.py
backend/app/user_apps/app_deployments/build_utils.py
backend/app/user_apps/app_deployments/dist_upload_service.py
backend/app/user_apps/app_deployments/s3_dist_service.py
backend/app/user_apps/app_security_scan/wiz/scan_service.py
backend/app/user_apps/backend_functions/git_archive.py
backend/app/user_apps/build_deploy/artifact_store.py
backend/app/user_apps/common/app_cleanup.py
backend/app/user_apps/git_storage/s3_provider.py
backend/app/user_apps/s3/archive_service.py
backend/app/user_apps/s3/s3_admin_router.py
backend/app/user_apps/s3/squash_git_history.py
backend/app/user_apps/sandbox/executors/git_operations_executor.py
backend/lib/common/lifespan.py
backend/scripts/apps/download_app_code.py
backend/scripts/benchmark_model/prompt_evaluation/services/judge_worker.py
backend/scripts/migrations/repair_superagent_s3_heads.py
apperbackend/app/user_apps/sandbox/sandbox_build_operations.py1backend/app/user_apps/sandbox/sandbox_integration.py
apperbackend/route_registry.py0No direct imports found

Decision

-

Merge after the remaining backend fixes and release gates. The reviewed CLI fixes are sound and its checks are green. CLI can proceed as the gated prerequisite release, with report-file cleanup recommended. Backend enablement still needs the fixes above, a released CLI pinned into deployed images, green backend CI, and the static staging proof. Fullstack remains phase 2.

\ No newline at end of file diff --git a/pr-review-626-24525.md b/pr-review-626-24525.md deleted file mode 100644 index c649d5ab..00000000 --- a/pr-review-626-24525.md +++ /dev/null @@ -1,103 +0,0 @@ -**Template:** T3 · Review — re-review of the static CLI publishing slice. -**Task:** Assess CLI #626 and apper #24525 for correctness, SOLID/KISS, and the implementation brief. -**Where we stand:** Six correctness findings are resolved; backend changes and rollout requirements remain. -**Last time:** The accepted cohort was a static web frontend with zero backend functions. -**Now:** Review of CLI `85429f9` and apper `9e140055`, checked on 15 September 2026. - -**Blockers: 2 · Blast radius: 🟠 shared publishing code.** The blockers concern sandbox credentials and image rollout; retry recovery and the duplicate build also need fixes. - -## The fixes repair collection and version creation - -**Before.** The CLI could exhaust file descriptors or omit failure-step information. Backend finalization used an unsupported SDK argument, omitted built-in `User`, and accepted invalid schema budgets or incomplete frontends. - -**After.** Hashing is bounded, local collection failures identify `create_version`, the copy call fits the pinned SDK, server normalization restores `User`, and validation rejects those invalid inputs. Deployment requests now claim their retry key before publishing. - -**Why this shape.** Shared validation and the existing uploader keep the implementation small. Claiming a request before publication is necessary, but the claim also needs a defined outcome when preparation or receipt persistence fails. - -The governing scope is [after-23460.md](/Users/yurym/Desktop/after-23460.md). The [end-state design](/Users/yurym/Desktop/build-deploy-design.md) is background where that brief narrows the milestone. Fullstack remains phase 2 and is not a merge requirement. - -## Shared helpers determine the review’s reach - -| Changed surface | Direct importing production files | Evidence | -| --- | --- | --- | -| `s3_service.py` | 26 | Counted static imports at the reviewed backend head | -| `kinds/schemas.py` | 4 | Includes both source extraction and HTTP normalization | -| `version.py` | 3 | Shared version creation now rejects incomplete static frontends | -| `service.py` | 2 | Builder flow and versions API | -| `sandbox_build_operations.py` | 1 | Shared sandbox integration | -| `Base44Command.ts` | 1 direct barrel importer | Barrel exports make the indirect reach larger; this is the shared command lifecycle | - -The HTML appendix contains counts for all 30 changed production modules. Counts describe static imports, including re-exports; they do not measure runtime calls. - -## Six findings are resolved at these commits - -| Finding | Current result | Checked | -| --- | --- | --- | -| Unsupported conditional `CopyObject` parameter | Removed. The actual current method’s parameters pass validation against the pinned botocore S3 model. | Ran SDK-model validation; no AWS request | -| Missing built-in `User` | `schemas.normalize_entities()` adds `User: {}` when absent and preserves customized fields. HTTP finalization uses it. | Ran actual normalization/extraction bodies with absent and customized `User`; read the revised parity test | -| Unbounded hashing | Uses existing `p-map` with bounded concurrency. | Ran actual collector under production Node with 1,500 files and a 256-descriptor limit; succeeded | -| Schema-budget bypass | Non-object payloads are rejected; complete maps, including names, count toward the budget. | Ran actual validator with oversized names and non-object values; both rejected | -| Empty or unenterable frontend | Shared version validation rejects empty sets and sets missing `index.html`, for bytes and staged descriptors. | Ran actual validation helper for both representations | -| Missing collection error step | Output resolution, hashing, and resource reads are inside `tagStep("create_version", ...)`. | Built current CLI; regression test passed | - -The copy now overwrites an existing destination with the same content-addressed bytes. Under the declared AWS checksum contract, that closes the SDK defect without changing artifact content. It is no longer a create-only storage operation. Real checksum enforcement and copy behavior still need staging proof. The comment saying `IfNoneMatch` is “PutObject-only” should describe the pinned SDK limitation; the current [S3 CopyObject API](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html) supports the header. - -## Fix these before merging the complete workflow - -| Severity | Where | Finding and consequence | Checked | Required change | -| --- | --- | --- | --- | --- | -| 🔴 blocker · P1 | [apper `sandbox_build_operations.py:568`](https://github.com/base44-dev/apper/blob/9e1400550fce5f2b3f980e06028632af32c30bce/backend/app/user_apps/sandbox/sandbox_build_operations.py#L568) | The late-minted key still has the broad `APPS_DEPLOY` scope, and the build and publish execs share the sandbox without a new process-user boundary. Brief S4 requires the new endpoints only and protection from surviving build processes. The same scope also authorizes legacy auth-configuration mutation. | Read minting, execution, scope policy, and [legacy auth mutation](https://github.com/base44-dev/apper/blob/9e1400550fce5f2b3f980e06028632af32c30bce/backend/app/user_apps/backend_functions/builder_api.py#L505); no live exploit test | Enforce an endpoint restriction for this credential and isolate the credential-bearing publisher from build-owned processes. App-wide versus version-bound authority is a separate open choice under §9.1. | -| 🔴 blocker · P1, rollout | [sandbox CLI pin](https://github.com/base44-dev/apper/blob/9e1400550fce5f2b3f980e06028632af32c30bce/backend/app/user_apps/sandbox/base44_cli_version#L1) | The pin remains `0.1.14`. Releasing CLI #626 does not update the executable installed in either sandbox image, while the backend requires its new commands and build behavior. | Read pin and both [Modal](https://github.com/base44-dev/apper/blob/9e1400550fce5f2b3f980e06028632af32c30bce/sandbox/sandbox_app_runtime/modal/Dockerfile#L48) and [Cloudflare](https://github.com/base44-dev/apper/blob/9e1400550fce5f2b3f980e06028632af32c30bce/sandbox/sandbox_app_runtime/cloudflare/Dockerfile#L61) Dockerfiles | Release CLI, update the pin, rebuild and roll the images, and run the static smoke test before backend enablement. This is a release prerequisite. | -| 🟠 fix in this PR · P2 | [apper `versions_api.py:302`](https://github.com/base44-dev/apper/blob/9e1400550fce5f2b3f980e06028632af32c30bce/backend/app/user_apps/build_deploy/versions_api.py#L302), [failure paths](https://github.com/base44-dev/apper/blob/9e1400550fce5f2b3f980e06028632af32c30bce/backend/app/user_apps/build_deploy/versions_api.py#L328), [receipt write](https://github.com/base44-dev/apper/blob/9e1400550fce5f2b3f980e06028632af32c30bce/backend/app/user_apps/build_deploy/producer.py#L304) | A request claims its key, but failure never releases or completes the claim. A temporary preparation failure returns `502`; the same request then returns `409 already running` for the one-hour expiry even after the dependency recovers. If receipt writing fails after success and the response is lost, the caller also cannot recover the committed result. | Reproduced both cases using actual handler and producer bodies with storage/context doubles | Validate before claiming where possible. Give failures before publication a completed failure or safely released claim. Recover a committed deployment by request identity when receipt persistence fails. Do not blindly release a claim after publication might have happened. | -| 🟠 fix in this PR · P2 | [apper `builder_api.py:1398`](https://github.com/base44-dev/apper/blob/9e1400550fce5f2b3f980e06028632af32c30bce/backend/app/user_apps/app_deployments/builder_api.py#L1398), [new build](https://github.com/base44-dev/apper/blob/9e1400550fce5f2b3f980e06028632af32c30bce/backend/app/user_apps/sandbox/sandbox_build_operations.py#L519) | Builder still prepares the legacy frontend before reaching the new lane, which starts another sandbox and frontend build. Brief S1 and definition-of-done item 10 explicitly require one build. The outer preparation is older code; replacing reuse of its dist with a fresh CLI build introduces the second build. | Traced outer preparation through `_publish_from_recorded_version()` to sandbox publication | Route the enrolled flow through one preparation/build and carry its result forward; preserve the ordinary legacy flow. | -| 🟢 your call | [CLI `Base44Command.ts:20`](https://github.com/base44/cli/blob/85429f91eab25987e0199c1852daa79a2d663b67/packages/cli/src/cli/utils/command/Base44Command.ts#L20) | The common command lifecycle imports error metadata from the version publish composer. This couples general command handling to one feature. | Read imports | Move `tagStep`/`stepOf` to shared error infrastructure when convenient. | -| 🟢 your call | [committed review report](https://github.com/base44/cli/blob/85429f91eab25987e0199c1852daa79a2d663b67/pr-review-626-24525.md#L1) | The CLI commit includes these generated review reports, whose original contents refer to older heads. | Read PR file list and report | Remove review output from the product PR, or give it an intentional documentation home. The local reports are updated by this review. | - -🔴 blocker — blocks the complete rollout · 🟠 fix in this PR — required branch work · 🟢 your call — optional cleanup. - -## SOLID/KISS supports the current structure - -| Principle | Assessment | -| --- | --- | -| Single responsibility | Build, record, and deploy remain separate. HTTP handlers may construct `BuildOutput` and call the shared operations; the brief explicitly prescribes this. No extra orchestration framework is needed. | -| Substitution | Shared index construction and server-side `User` normalization now align the two producer paths for the reviewed cases. | -| Dependency direction | CLI core remains independent of presentation. The shared command-to-feature import above is a small cleanup, not a reason to redesign the layers. | -| Simplicity | Bounded `p-map`, explicit artifact kinds, shared uploads, and shared deploy-plus-domain-binding are appropriate. Keep the in-process path required by B6. | -| Failure handling | The request claim needs a complete failure lifecycle. B4 requires request replay; it does not require the attempts framework, generations, outbox, or GC excluded by §2. | - -## Other scope distinctions remain relevant - -| Topic | Assessment | -| --- | --- | -| Fullstack, manifest v2, Cloudflare deployment work | Explicitly outside this slice. Raw entity and agent schemas are in scope. | -| Standalone build envelope | A reusable artifact collector exists, but `base44 build --json` still returns status. C1’s command-envelope wording remains a contract clarification. | -| Function admission | Server admission checks current app metadata. Local function files newly added before metadata synchronization are not checked by the collector. Source-level refusal remains an admission gap to settle; supporting those functions is not requested. | -| Package installation | Removing `update_packages` satisfies the explicit S3 change. The inherited `npm install --ignore-scripts` reproducibility issue does not require an installer rewrite in this review. | -| Flag-off rollback | Disabling the existing flag restores legacy reads. That inherited behavior can expose stale legacy pointers; it is distinct from deploying an older stored version. | -| Upload verification | Signed SHA-256 verification at S3 PUT time is a documented substitution for backend re-hashing. The signature constrains bytes; it does not make a presigned URL single-use. See [AWS presigned URL semantics](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html). | - -## Local verification does not establish release readiness - -| Coverage | Result | -| --- | --- | -| CLI build and focused suite — ran it | Current build succeeded; all 49 tests passed across six version/build test files. | -| Targeted regressions — ran it | Node descriptor-limit test and pinned SDK-model validation passed. Backend helper probes verified normalization, schema budgets, static completeness, normal replay, and conflicting key rejection; failure replay reproduced the remaining defect. | -| Full backend suite — did not run locally | No configured backend virtual environment was available. Isolated probes use data-container and storage/context doubles; they do not exercise Pydantic, authentication, or real infrastructure. | -| Current CI — read status | CLI checks completed successfully. Backend checks were still running, with no reported failures at 13:00 UTC. | -| Real S3, deployed image, cold deploy, rollback — did not run | These remain staging gates from the static brief. No live credential-isolation test was run. | - -A green CLI suite cannot prove the image contains that CLI. A mocked copy cannot prove checksum enforcement. Neither establishes cold replay after the producer disappears. - -## Check the remaining behavior before rollout - -1. Fail preparation once and retry the same key after recovery. Expect an accurate completed outcome or a safe retry, never a false claim that a finished request is still running. -2. Fail receipt persistence after publication and lose the response. Retry the same key; expect the original deployment reference without republishing. Also verify concurrent callers and conflicting version/target reuse. -3. Leave a process running after the build. Expect it to have no access to the publish credential; expect that credential to be refused by legacy mutation endpoints. -4. Trace one enrolled Builder publication. Expect one exact checkout/build and the intended released CLI inside the deployed image. -5. Publish a static zero-function app against real S3, including absent/customized `User`. Reject a wrong checksum, remove the sandbox and checkout, deploy the stored version, then roll back to an older one. Expect correct frontend/user schemas and no Cloudflare deployment calls. - -**In one line:** Collection and version creation are repaired; backend retry recovery, single-build integration, credential restrictions, and image rollout remain. - -## Decision - -**Merge after the remaining backend fixes and release gates.** The reviewed CLI fixes are sound and its checks are green. CLI can proceed as the gated prerequisite release, with report-file cleanup recommended. Backend enablement still needs the fixes above, a released CLI pinned into deployed images, green backend CI, and the static staging proof. Fullstack remains phase 2. From fe5c31358a25924be482c8367f447244e38a7ed4 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Wed, 16 Sep 2026 11:17:35 +0300 Subject: [PATCH 14/21] refactor: an environment serves a version; one commit per version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review comments from @netanelgilad. **`PATCH /environments/{name} { version_id }`** replaces `POST /versions/{id}/deployments`. An environment is the thing that serves, and it serves one version — so making a version live is editing that pointer, not creating a resource. The `Deployment` record the switch leaves behind is how the plane remembers what it prepared; it is returned so a caller can correlate a log line, not asked for. `versions deploy ` keeps its name and now points an environment at that version. **One commit, not two.** `source_commit` and `frontend_commit` were both sent and both stored, and the CLI put the same value in each. A version describes an app at a source, and its frontend and backend are that same app. `build` still refuses two inputs that name different commits — that check is what earns the single field. Co-Authored-By: Claude Opus 5 (1M context) --- docs/versions.md | 10 ++-- packages/cli/src/cli/commands/publish.ts | 2 +- .../cli/src/cli/commands/versions/deploy.ts | 37 ++++++++------- packages/cli/src/core/version/api.ts | 47 +++++++++++++------ packages/cli/src/core/version/publish.ts | 26 +++++++--- packages/cli/src/core/version/schema.ts | 12 +++-- packages/cli/tests/cli/publish.spec.ts | 31 +++++++----- .../cli/tests/cli/testkit/TestAPIServer.ts | 19 ++++---- 8 files changed, 117 insertions(+), 67 deletions(-) diff --git a/docs/versions.md b/docs/versions.md index 93e2493e..44051b42 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -28,7 +28,9 @@ Each of the three responses is parsed through its Zod schema and a mismatch rais What that does **not** catch is a change of meaning behind an unchanged shape. Nothing here does; the lane is small enough that both sides are reviewed together. -`deployVersion(versionId, options)` is one POST carrying a target name and an idempotency key. Nothing else is the caller's to say: the app comes from the credential, and so do the acting principal, the runtime environment variables, every artifact key, the manifest hash and the publication revision. The request models on the server forbid unknown fields, so sending one is an error rather than a silent drop. +`setEnvironmentVersion(environment, versionId, options)` is one `PATCH /environments/{name}` carrying the version id and an idempotency key. **An environment serves one version, so making a version live is editing that pointer — there is no deployment to create.** The `Deployment` record the switch leaves behind is how the plane remembers what it prepared, returned so a caller can correlate a log line. + +Nothing else is the caller's to say: the app comes from the credential, and so do the acting principal, the runtime environment variables, every artifact key, the manifest hash and the publication revision. The request models on the server forbid unknown fields, so sending one is an error rather than a silent drop. ## Why the digest is signed into the URL @@ -42,12 +44,14 @@ The practical consequence for this CLI: **send the checksum the server gave you, Deliberately **not** `entityResource.readAll` / `agentResource.readAll`. The platform's own extractor and validation are authoritative, and this CLI's stricter entity schema refuses real Builder apps — which is exactly why `site deploy` reads no resources at all. Re-introducing the strict parse here would reproduce that block. -## The commit is provenance, not identity +## One commit, and it is provenance rather than identity `resolveProvenanceCommit(projectRoot, explicit?)` in `core/site/git-hash.ts` returns `undefined` rather than failing when there is no checkout. A version is identified by its **content**; the commit is recorded beside it and never hashed, so a build outside a git checkout is still a complete version. That is the whole difference from `resolveGitHash`, whose caller addresses a deployment *by* the hash and therefore cannot go without one. +One commit, not two: an app's frontend and backend are the same app at the same source, so a version records a single `source_commit`. + ## A Builder repo carries no CLI config `resolvePublishTarget(projectRoot, overrides)` in `project.ts` fills in `npm run build` and `dist` **only when a repo has no config at all**, and **writes nothing**. The python driver it replaces used to overwrite `base44/config.jsonc` with a minimal config before building, destroying any checked-in configuration — and, for a full-stack app, its build command. @@ -66,7 +70,7 @@ A callback that throws synchronously is tagged too; `run().catch(...)` would let **`base44 versions create`** — record built output without serving it. A version can sit unpublished for as long as it likes. -**`base44 versions deploy [--target ]`** — serve a recorded version: no checkout, no build, no upload. Passing an older id is how a rollback is done. +**`base44 versions deploy [--target ]`** — point an environment at a recorded version: no checkout, no build, no upload. Passing an older id is how a rollback is done. `base44 build` is not part of this group and is not gated, but the lane depends on two things about it: it needs **no credential** (the publish sandbox builds before minting a key that can deploy), and it resolves its config through `resolvePublishTarget` (a Builder repo has none). What it builds and what it prints are unchanged. diff --git a/packages/cli/src/cli/commands/publish.ts b/packages/cli/src/cli/commands/publish.ts index 2495c159..016a2889 100644 --- a/packages/cli/src/cli/commands/publish.ts +++ b/packages/cli/src/cli/commands/publish.ts @@ -82,7 +82,7 @@ async function publishAction( log.message(theme.styles.dim(`version ${result.versionId}`)); } return { - outroMessage: `Deployment ${result.deploymentId}`, + outroMessage: `${result.environment} now serves ${result.versionId}`, stdout: jsonMode ? `${JSON.stringify(result, null, 2)}\n` : undefined, }; } diff --git a/packages/cli/src/cli/commands/versions/deploy.ts b/packages/cli/src/cli/commands/versions/deploy.ts index 2ce0fa19..67dac49d 100644 --- a/packages/cli/src/cli/commands/versions/deploy.ts +++ b/packages/cli/src/cli/commands/versions/deploy.ts @@ -1,43 +1,44 @@ import { randomUUID } from "node:crypto"; import type { Command } from "commander"; -import { targetOption } from "@/cli/commands/versions/options.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; -import { deployVersion } from "@/core/version/index.js"; +import { + DEFAULT_ENVIRONMENT, + setEnvironmentVersion, +} from "@/core/version/index.js"; -interface DeployOptions { - target?: string; -} - -/** Serve an existing version: no checkout, no build, no upload. A rollback is - * the same call with an older id. */ +/** Point an environment at an existing version: no checkout, no build, no + * upload. Pointing it at an older one is the rollback. */ async function deployAction( { runTask, jsonMode }: CLIContext, versionId: string, - options: DeployOptions, + options: { target?: string }, ): Promise { - const deployment = await runTask( - `Deploying version ${versionId}...`, + const environment = options.target ?? DEFAULT_ENVIRONMENT; + const result = await runTask( + `Pointing ${environment} at ${versionId}...`, async () => - await deployVersion(versionId, { - target: options.target, + await setEnvironmentVersion(environment, versionId, { idempotencyKey: randomUUID(), }), - { successMessage: "Version deployed", errorMessage: "Deploy failed" }, + { + successMessage: "Environment updated", + errorMessage: "Could not update the environment", + }, ); return { - outroMessage: `Deployment ${deployment.deploymentId}`, - stdout: jsonMode ? `${JSON.stringify(deployment, null, 2)}\n` : undefined, + outroMessage: `${result.name} now serves ${result.versionId}`, + stdout: jsonMode ? `${JSON.stringify(result, null, 2)}\n` : undefined, }; } export function getVersionDeployCommand(): Command { return new Base44Command("deploy") .description( - "Serve an already-recorded version (also how a rollback is done)", + "Point an environment at an already-recorded version (also the rollback)", ) .argument("", "The version to serve") - .addOption(targetOption()) + .option("--target ", "Environment to point at it") .action(deployAction); } diff --git a/packages/cli/src/core/version/api.ts b/packages/cli/src/core/version/api.ts index 4b518e78..45cf27d0 100644 --- a/packages/cli/src/core/version/api.ts +++ b/packages/cli/src/core/version/api.ts @@ -7,12 +7,12 @@ import type { ArtifactSet, CreateVersionProgress, CreateVersionResponse, - DeployVersionResponse, + EnvironmentResponse, } from "@/core/version/schema.js"; import { CreateVersionResponseSchema, DeclareVersionResponseSchema, - DeployVersionResponseSchema, + EnvironmentResponseSchema, } from "@/core/version/schema.js"; /** @@ -34,6 +34,18 @@ async function post( } } +async function patch( + path: string, + json: unknown, + doing: string, +): Promise { + try { + return await getAppClient().patch(path, { json, timeout: 180_000 }); + } catch (error) { + throw await ApiError.fromHttpError(error, doing); + } +} + function parse(schema: ZodType, body: unknown, what: string): T { const result = schema.safeParse(body); if (!result.success) { @@ -71,8 +83,9 @@ export async function createVersion( })), entities: artifacts.entities, agents: artifacts.agents, + // One commit: an app's frontend and backend are the same app at the + // same source. source_commit: options.sourceCommit, - frontend_commit: options.sourceCommit, }, "declaring a version", ) @@ -126,26 +139,32 @@ export async function createVersion( ); } -/** Serve a recorded version. The body carries a target name and a retry key; - * everything else is the platform's to resolve. */ -export async function deployVersion( +/** + * Point an environment at a recorded version. + * + * An environment serves one version, so making a version live is editing that + * pointer — there is no deployment to create. Pointing it at an older version is + * the rollback. + */ +export async function setEnvironmentVersion( + environment: string, versionId: string, - options: { target?: string; idempotencyKey?: string } = {}, -): Promise { + options: { idempotencyKey?: string } = {}, +): Promise { return parse( - DeployVersionResponseSchema, + EnvironmentResponseSchema, await ( - await post( - `versions/${encodeURIComponent(versionId)}/deployments`, + await patch( + `environments/${encodeURIComponent(environment)}`, { - ...(options.target ? { target: options.target } : {}), + version_id: versionId, ...(options.idempotencyKey ? { idempotency_key: options.idempotencyKey } : {}), }, - "deploying a version", + "setting the environment's version", ) ).json(), - "deploy", + "environment", ); } diff --git a/packages/cli/src/core/version/publish.ts b/packages/cli/src/core/version/publish.ts index 1ba59d95..a170d9be 100644 --- a/packages/cli/src/core/version/publish.ts +++ b/packages/cli/src/core/version/publish.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { createVersion, deployVersion } from "@/core/version/api.js"; +import { createVersion, setEnvironmentVersion } from "@/core/version/api.js"; import type { ArtifactSet, CreateVersionProgress, @@ -14,6 +14,9 @@ type PublishStep = "build" | "create_version" | "deploy"; const STEP = Symbol.for("base44.publishStep"); +/** The environment a publish points at unless told otherwise. */ +export const DEFAULT_ENVIRONMENT = "production"; + /** Tag an error with its step without wrapping it, so the original type, * status and request id still reach the envelope. */ export async function tagStep( @@ -39,6 +42,7 @@ export function stepOf(error: unknown): PublishStep | undefined { } interface PublishResult { + environment: string; versionId: string; manifestHash: string; deploymentId: string; @@ -64,11 +68,19 @@ export async function publishVersion( progress: options.progress, }), ); - const deployment = await tagStep("deploy", () => - deployVersion(version.versionId, { - target: options.target, - idempotencyKey: randomUUID(), - }), + const environment = await tagStep("deploy", () => + setEnvironmentVersion( + options.target ?? DEFAULT_ENVIRONMENT, + version.versionId, + { + idempotencyKey: randomUUID(), + }, + ), ); - return { ...version, ...deployment }; + return { + environment: environment.name, + versionId: environment.versionId, + manifestHash: environment.manifestHash, + deploymentId: environment.deploymentId, + }; } diff --git a/packages/cli/src/core/version/schema.ts b/packages/cli/src/core/version/schema.ts index eb51cebe..870d6ba2 100644 --- a/packages/cli/src/core/version/schema.ts +++ b/packages/cli/src/core/version/schema.ts @@ -66,14 +66,18 @@ export const CreateVersionResponseSchema = z export type CreateVersionResponse = z.infer; -export const DeployVersionResponseSchema = z +export const EnvironmentResponseSchema = z .object({ - deployment_id: z.string(), + name: z.string(), + version_id: z.string(), manifest_hash: z.string(), + deployment_id: z.string(), }) .transform((data) => ({ - deploymentId: data.deployment_id, + name: data.name, + versionId: data.version_id, manifestHash: data.manifest_hash, + deploymentId: data.deployment_id, })); -export type DeployVersionResponse = z.infer; +export type EnvironmentResponse = z.infer; diff --git a/packages/cli/tests/cli/publish.spec.ts b/packages/cli/tests/cli/publish.spec.ts index 646e0d85..fe6a353d 100644 --- a/packages/cli/tests/cli/publish.spec.ts +++ b/packages/cli/tests/cli/publish.spec.ts @@ -19,9 +19,11 @@ describe("publish command", () => { .mockPresignedUpload("/index.html") .mockPresignedUpload("/assets/app.js") .mockVersionFinalize({ version_id: "ver-1", manifest_hash: "sha256:abc" }) - .mockVersionDeploy({ - deployment_id: "dep-1", + .mockEnvironmentSet({ + name: "production", + version_id: "ver-1", manifest_hash: "sha256:abc", + deployment_id: "dep-1", }); }; @@ -82,7 +84,7 @@ describe("publish command", () => { ).toBe(INDEX); }); - it("carries only a target name and a retry key into the deploy", async () => { + it("names the environment in the path and the version in the body", async () => { // Everything else — the app, the principal, env vars — is the platform's to // resolve, and there is deliberately no field for any of them. t.givenEnv({ BASE44_VERSIONS_API: "1" }); @@ -92,10 +94,10 @@ describe("publish command", () => { const result = await t.run("publish", "--no-build"); t.expectResult(result).toSucceed(); - expect(t.api.versionDeployIds).toEqual(["ver-1"]); - expect(Object.keys(t.api.versionDeployRequests[0] as object)).toEqual([ - "idempotency_key", - ]); + expect(t.api.environmentNames).toEqual(["production"]); + expect( + Object.keys(t.api.versionDeployRequests[0] as object).sort(), + ).toEqual(["idempotency_key", "version_id"]); }); it("emits both references in the --json envelope", async () => { @@ -110,6 +112,7 @@ describe("publish command", () => { t.expectResult(result).toSucceed(); expect(JSON.parse(result.stdout)).toEqual({ + environment: "production", versionId: "ver-1", manifestHash: "sha256:abc", deploymentId: "dep-1", @@ -168,26 +171,30 @@ describe("publish command", () => { }); }); -describe("versions deploy command", () => { +describe("versions deploy points an environment", () => { const t = setupCLITests(); it("serves an existing version with no build and no upload", async () => { // Which is also what a rollback is: the same call with an older version id. t.givenEnv({ BASE44_VERSIONS_API: "1" }); await t.givenLoggedInWithProject(fixture("publishable")); - t.api.mockVersionDeploy({ - deployment_id: "dep-9", + t.api.mockEnvironmentSet({ + name: "production", + version_id: "ver-old", manifest_hash: "sha256:old", + deployment_id: "dep-9", }); const result = await t.run("versions", "deploy", "ver-old", "--json"); t.expectResult(result).toSucceed(); - expect(t.api.versionDeployIds).toEqual(["ver-old"]); + expect(t.api.environmentNames).toEqual(["production"]); expect(t.api.presignedUploadRequests).toEqual([]); expect(JSON.parse(result.stdout)).toEqual({ - deploymentId: "dep-9", + name: "production", + versionId: "ver-old", manifestHash: "sha256:old", + deploymentId: "dep-9", }); }); }); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index fd94a97e..a16dd0d6 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -355,7 +355,7 @@ interface ErrorResponse { // ─── ROUTE HANDLER TYPES ───────────────────────────────────── -type Method = "GET" | "POST" | "PUT" | "DELETE"; +type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; interface RouteEntry { method: Method; @@ -433,6 +433,7 @@ export class TestAPIServer { | "get" | "post" | "put" + | "patch" | "delete"; this.app[method](entry.path, entry.handler); } @@ -854,8 +855,8 @@ export class TestAPIServer { readonly versionDeclareRequests: unknown[] = []; /** Captured JSON bodies of POST versions/{id}/deployments requests. */ readonly versionDeployRequests: unknown[] = []; - /** Captured version ids the deploy call addressed. */ - readonly versionDeployIds: string[] = []; + /** Captured environment names the PATCH addressed. */ + readonly environmentNames: string[] = []; /** * Mock POST /api/apps/{appId}/versions. `uploads` is built from the declared @@ -900,16 +901,18 @@ export class TestAPIServer { ); } - mockVersionDeploy(response: { - deployment_id: string; + mockEnvironmentSet(response: { + name: string; + version_id: string; manifest_hash: string; + deployment_id: string; }): this { this.pendingRoutes.push({ - method: "POST", - path: `/api/apps/${this.appId}/versions/:versionId/deployments`, + method: "PATCH", + path: `/api/apps/${this.appId}/environments/:name`, handler: (req, res) => { this.versionDeployRequests.push(req.body); - this.versionDeployIds.push(String(req.params.versionId)); + this.environmentNames.push(String(req.params.name)); res.status(200).json(response); }, }); From 451886ca52112227632d76fa3f09444dda16b300 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Wed, 16 Sep 2026 14:06:31 +0300 Subject: [PATCH 15/21] feat(publish): declare a full-stack app's Worker alongside its frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Netanel's point on this PR: the full-stack deploy lane already knows how to read what a framework built, and this lane should not learn it a second time. It doesn't — `collectSiteWorker` reuses `detectFullStackArtifact`, `resolveWranglerConfig` and `collectModules` whole. A second reader would be a second opinion about what the build produced. Two consequences of a Worker being present: - The frontend comes from the Worker's own `assets.directory`, not from the project's `site.outputDirectory`. That is where a full-stack build puts the files the Worker serves, and publishing the output directory instead would declare the wrong frontend or none at all. - `main` is sent as the module set names it. The platform matches the entry against the names it was sent, so a surviving "./" would name a module nothing in the set provides — resolved by identity here rather than by position. Uploads now pair with declared files by POSITION rather than by path. The frontend and the Worker's modules are separate namespaces, so the same name can appear in both and mean two different files; resolving by path would upload one of them under the other's checksum. `uploadPresignedAssets` keeps resolving by path for the site lane, and the PUT they share is `putPresigned`. Nothing deploys this yet: the platform records the Worker on the version and refuses a full-stack app at admission. This is the transport, sent and stored. Co-Authored-By: Claude Opus 5 (1M context) --- docs/versions.md | 17 ++- packages/cli/src/cli/commands/publish.ts | 16 ++- packages/cli/src/core/site/upload.ts | 17 ++- packages/cli/src/core/version/api.ts | 81 ++++++++------ packages/cli/src/core/version/artifacts.ts | 62 ++++++++++- packages/cli/src/core/version/schema.ts | 19 ++++ packages/cli/tests/cli/publish.spec.ts | 80 ++++++++++++++ .../cli/tests/cli/testkit/TestAPIServer.ts | 11 +- .../cli/tests/core/version-artifacts.spec.ts | 104 ++++++++++++++++++ 9 files changed, 364 insertions(+), 43 deletions(-) diff --git a/docs/versions.md b/docs/versions.md index 44051b42..6a2fff31 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -18,8 +18,8 @@ It is **not** `src/core/site/` — see [Deployments](deployments.md). That lane `createVersion(artifacts, options)` in `api.ts` — three calls, in this order and no other, so nothing is recorded until the bytes are in place: -1. **Declare.** `POST versions` with `static_bundle` (path, size, digest per file), the raw `entities` and `agents` payloads, and `source_commit`. The response carries a `session_id` and one presigned PUT per file. -2. **Upload.** `uploadPresignedAssets` — the same function the static deployments lane uses, same `pMap` concurrency and same ky retry policy. Each PUT sends the server's `Content-Type` **and** its `x-amz-checksum-sha256` verbatim; deriving either locally would 403 on any mapping difference. +1. **Declare.** `POST versions` with `static_bundle` (path, size, digest per file), `site_worker` when the app has a server of its own, the raw `entities` and `agents` payloads, and `source_commit`. The response carries a `session_id` and one presigned PUT per declared file, in declared order. +2. **Upload.** `putPresigned` per file — the same PUT the static deployments lane uses, same `pMap` concurrency and same ky retry policy. Each PUT sends the server's `Content-Type` **and** its `x-amz-checksum-sha256` verbatim; deriving either locally would 403 on any mapping difference. Uploads are paired with declared files **by position**, not by path: the frontend and the Worker's modules are separate namespaces, so the same name can appear in both and mean two different files. 3. **Finalize.** `POST versions/{session_id}/finalize`, no body. The set was fixed at declare, so there is nothing left for the caller to change. The response carries `version_id` and `manifest_hash`, and no flag for "this content already existed" — the hash **is** the identity, so a caller asking whether a rebuild changed anything compares it against the last one. An existing version is not necessarily the one being served, so such a flag would be misleading anyway. @@ -32,6 +32,19 @@ What that does **not** catch is a change of meaning behind an unchanged shape. N Nothing else is the caller's to say: the app comes from the credential, and so do the acting principal, the runtime environment variables, every artifact key, the manifest hash and the publication revision. The request models on the server forbid unknown fields, so sending one is an error rather than a silent drop. +## An app with a server of its own + +`collectSiteWorker(projectRoot)` looks for `.wrangler/deploy/config.json` — the redirect file a `@cloudflare/vite-plugin` build leaves behind — and, when it is there, reuses the full-stack deploy lane whole: `detectFullStackArtifact`, `resolveWranglerConfig`, `collectModules`. No second reader, because a second reader is a second opinion about what the framework built. + +Two things follow from a Worker being present: + +- The frontend comes from the Worker's own `assets.directory`, not from the project's `site.outputDirectory`. That is where a full-stack build puts the files the Worker serves. +- `main` is sent as the module set names it, not as the config wrote it. The platform matches the entry against the names it was sent, so a surviving `./` would name a module nothing in the set provides. + +`compatibility_date` and `compatibility_flags` ride along. They are part of the Worker's **identity** on the platform, not metadata: the same modules under a different compatibility date are a different Worker. + +**Nothing deploys this yet.** The platform records the Worker on the version and stores its modules, and refuses a full-stack app at admission — so today this proves the transport, not a publish. + ## Why the digest is signed into the URL The platform pins content type, content length **and** sha256 into each presigned PUT, so S3 itself rejects a body that does not hash to the declared digest. The URL is permission to write exactly one payload, once — which is what lets the server commit those bytes with a server-side copy instead of reading them back to re-hash. A frontend of any size is recorded without its bytes passing through a worker. diff --git a/packages/cli/src/cli/commands/publish.ts b/packages/cli/src/cli/commands/publish.ts index 016a2889..9009727b 100644 --- a/packages/cli/src/cli/commands/publish.ts +++ b/packages/cli/src/cli/commands/publish.ts @@ -12,6 +12,7 @@ import { resolveProvenanceCommit } from "@/core/site/index.js"; import { collectBuildOutput, collectResources, + collectSiteWorker, publishVersion, requireOutputDir, resolvePublishTarget, @@ -59,10 +60,17 @@ async function publishAction( // Inside the tag: resolving the output directory and reading it are part // of producing the version, so a missing directory is a create_version // failure rather than an envelope with no step at all. - const artifacts = await tagStep("create_version", async () => ({ - files: await collectBuildOutput(requireOutputDir(target)), - ...(await collectResources(target.configDir, target)), - })); + const artifacts = await tagStep("create_version", async () => { + // A full-stack build puts the frontend where the Worker serves it from, + // which is not the project's own output directory. + const siteWorker = await collectSiteWorker(target.root); + const outputDir = siteWorker?.assetsDir ?? requireOutputDir(target); + return { + files: await collectBuildOutput(outputDir), + ...(siteWorker ? { siteWorker } : {}), + ...(await collectResources(target.configDir, target)), + }; + }); return await publishVersion(artifacts, { sourceCommit: gitHash, target: options.target, diff --git a/packages/cli/src/core/site/upload.ts b/packages/cli/src/core/site/upload.ts index 0012b634..8a9152be 100644 --- a/packages/cli/src/core/site/upload.ts +++ b/packages/cli/src/core/site/upload.ts @@ -185,7 +185,7 @@ async function buildBucketForm( * 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( +async function uploadPresignedAssets( uploads: PresignedAssetUpload[], assets: AssetManifestResult, options: { @@ -218,7 +218,20 @@ async function uploadPresignedAsset( `Server requested upload of unknown asset path: ${upload.path}`, ); } - const content = await readFile(file.absolutePath); + await putPresigned(upload, file.absolutePath); +} + +/** + * PUT one file to the URL the server signed for it. Which file that is, is the + * caller's to decide: the site lane resolves it by path, the version lane by + * the order it declared, because a version can declare two sets whose paths + * overlap. + */ +export async function putPresigned( + upload: PresignedAssetUpload, + absolutePath: string, +): Promise { + const content = await readFile(absolutePath); try { await ky.put(upload.url, { diff --git a/packages/cli/src/core/version/api.ts b/packages/cli/src/core/version/api.ts index 45cf27d0..68cc72d0 100644 --- a/packages/cli/src/core/version/api.ts +++ b/packages/cli/src/core/version/api.ts @@ -1,9 +1,15 @@ import type { KyResponse } from "ky"; +import pMap from "p-map"; import type { ZodType } from "zod"; import { getAppClient } from "@/core/clients/index.js"; -import { ApiError, SchemaValidationError } from "@/core/errors.js"; -import { uploadPresignedAssets } from "@/core/site/upload.js"; +import { + ApiError, + InternalError, + SchemaValidationError, +} from "@/core/errors.js"; +import { putPresigned } from "@/core/site/upload.js"; import type { + ArtifactFile, ArtifactSet, CreateVersionProgress, CreateVersionResponse, @@ -22,6 +28,10 @@ import { export const DEFAULT_VERSION_UPLOAD_CONCURRENCY = 8; export const MAX_VERSION_UPLOAD_CONCURRENCY = 16; +function declaredFile({ path, size, digest }: ArtifactFile) { + return { path, size, digest }; +} + async function post( path: string, json: unknown, @@ -76,11 +86,17 @@ export async function createVersion( await post( "versions", { - static_bundle: artifacts.files.map(({ path, size, digest }) => ({ - path, - size, - digest, - })), + static_bundle: artifacts.files.map(declaredFile), + ...(artifacts.siteWorker + ? { + site_worker: { + main: artifacts.siteWorker.main, + modules: artifacts.siteWorker.modules.map(declaredFile), + compatibility_date: artifacts.siteWorker.compatibilityDate, + compatibility_flags: artifacts.siteWorker.compatibilityFlags, + }, + } + : {}), entities: artifacts.entities, agents: artifacts.agents, // One commit: an app's frontend and backend are the same app at the @@ -93,37 +109,38 @@ export async function createVersion( "declare", ); + // Flat, in declared order — the frontend then the Worker's modules — because + // that is the order the server signed them in. Paired by POSITION and not by + // path: the two sets have separate namespaces, so a module and an asset may + // share a name and still be different files. + const declaredFiles = [ + ...artifacts.files, + ...(artifacts.siteWorker?.modules ?? []), + ]; + options.progress?.onDeclared?.({ - fileCount: artifacts.files.length, + fileCount: declaredFiles.length, owedFiles: declared.uploads.length, }); - await uploadPresignedAssets( + if (declared.uploads.length !== declaredFiles.length) { + throw new InternalError( + `Declared ${declaredFiles.length} files but the server signed ${declared.uploads.length} upload URLs.`, + ); + } + + let uploadedFiles = 0; + await pMap( declared.uploads, - { - manifest: Object.fromEntries( - artifacts.files.map((file) => [ - file.path, - { hash: file.digest, size: file.size }, - ]), - ), - filesByHash: new Map( - artifacts.files.map((file) => [ - file.digest, - // No contentType: the PUT echoes the one the server signed into the - // URL, and deriving a second opinion here is how they diverge. - { - absolutePath: file.absolutePath, - hash: file.digest, - size: file.size, - }, - ]), - ), - }, - { - concurrency: options.concurrency ?? DEFAULT_VERSION_UPLOAD_CONCURRENCY, - onProgress: options.progress?.onUpload, + async (upload, index) => { + await putPresigned(upload, declaredFiles[index].absolutePath); + uploadedFiles++; + options.progress?.onUpload?.({ + uploadedFiles, + totalFiles: declared.uploads.length, + }); }, + { concurrency: options.concurrency ?? DEFAULT_VERSION_UPLOAD_CONCURRENCY }, ); return parse( diff --git a/packages/cli/src/core/version/artifacts.ts b/packages/cli/src/core/version/artifacts.ts index 3edb2ce4..2252f8ea 100644 --- a/packages/cli/src/core/version/artifacts.ts +++ b/packages/cli/src/core/version/artifacts.ts @@ -1,14 +1,23 @@ import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { stat } from "node:fs/promises"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { globby } from "globby"; import pMap from "p-map"; import { CONFIG_FILE_EXTENSION_GLOB } from "@/core/consts.js"; import { InvalidInputError } from "@/core/errors.js"; import { walkBuildOutput } from "@/core/site/manifest.js"; +import { collectModules } from "@/core/site/modules.js"; +import { + detectFullStackArtifact, + resolveWranglerConfig, +} from "@/core/site/wrangler-config.js"; import { pathExists, readJsonFile } from "@/core/utils/fs.js"; -import type { ArtifactFile, ArtifactSet } from "@/core/version/schema.js"; +import type { + ArtifactFile, + ArtifactSet, + SiteWorkerArtifact, +} from "@/core/version/schema.js"; /** * What one declaration may cost the platform: a presigned URL per file, and the @@ -86,6 +95,55 @@ export async function collectBuildOutput( ); } +/** + * The app's own server, when the framework built one — otherwise `null`. + * + * Reuses the full-stack lane's collectors whole: the same redirect file, the + * same wrangler config, the same module set the legacy deploy sends to + * Cloudflare. A second reader here would be a second opinion about what the + * framework built. + */ +export async function collectSiteWorker( + projectRoot: string, +): Promise { + const redirectPath = await detectFullStackArtifact(projectRoot); + if (!redirectPath) { + return null; + } + + const config = await resolveWranglerConfig(redirectPath); + const modules = await collectModules(config); + // By identity, not by position: the platform matches the entry against the + // module NAMES it was sent, and `main` in the config may still carry a "./". + const entry = resolve(config.configDir, config.main); + const main = modules.find((m) => m.absolutePath === entry)?.name; + if (!main) { + throw new InvalidInputError( + `The Worker's entry module ${config.main} is not among the ${modules.length} modules collected from ${config.configDir}.`, + ); + } + + return { + main, + modules: await pMap( + modules, + async ({ name, absolutePath, size }) => ({ + path: name, + absolutePath, + size, + digest: await digestFile(absolutePath), + }), + { concurrency: HASH_CONCURRENCY }, + ), + compatibilityDate: config.compatibilityDate, + compatibilityFlags: config.compatibilityFlags, + assetsDir: + config.assetsDirectory && (await pathExists(config.assetsDirectory)) + ? config.assetsDirectory + : null, + }; +} + /** * The app's declared entities and agents, raw. * diff --git a/packages/cli/src/core/version/schema.ts b/packages/cli/src/core/version/schema.ts index 870d6ba2..ccbed153 100644 --- a/packages/cli/src/core/version/schema.ts +++ b/packages/cli/src/core/version/schema.ts @@ -16,9 +16,28 @@ export interface ArtifactFile { digest: string; } +/** + * The app's own server, when the framework built one. + * + * Its modules are files exactly like the frontend's, in their own namespace — + * `index.js` here is not `index.js` there — plus the settings they were built + * to run under, which the platform treats as part of the Worker's identity. + */ +export interface SiteWorkerArtifact { + main: string; + modules: ArtifactFile[]; + compatibilityDate: string | null; + compatibilityFlags: string[]; + /** Where the frontend is, for a full-stack build: the Worker's own assets + * directory, never the project's `site.outputDirectory`. */ + assetsDir: string | null; +} + /** Everything one build produced, as the create-version call describes it. */ export interface ArtifactSet { files: ArtifactFile[]; + /** Absent for an app with no server of its own — almost every app. */ + siteWorker?: SiteWorkerArtifact; /** Raw payloads by name. The server normalizes and hashes them. */ entities: Record; agents: Record; diff --git a/packages/cli/tests/cli/publish.spec.ts b/packages/cli/tests/cli/publish.spec.ts index fe6a353d..fbe34ef4 100644 --- a/packages/cli/tests/cli/publish.spec.ts +++ b/packages/cli/tests/cli/publish.spec.ts @@ -235,3 +235,83 @@ describe("the versions lane is gated", () => { expect(result.stdout).toContain("versions"); }); }); + +describe("publish command, for an app with a server of its own", () => { + const t = setupCLITests(); + + // The Worker's own build directory, and the assets directory it serves from. + const SERVER_INDEX = + 'import handler from "./assets/chunk-abc.js";\nexport default { fetch: handler };\n'; + const CLIENT_INDEX = "

Hello

\n"; + + const mockFullStackApi = () => + t.api + .mockVersionDeclare(SESSION) + .mockPresignedUpload("/index.html") + .mockPresignedUpload("/assets/app-123.js") + .mockPresignedUpload("/index.js") + .mockPresignedUpload("/index.js.map") + .mockPresignedUpload("/assets/chunk-abc.js") + .mockVersionFinalize({ version_id: "ver-1", manifest_hash: "sha256:abc" }) + .mockEnvironmentSet({ + name: "production", + version_id: "ver-1", + manifest_hash: "sha256:abc", + deployment_id: "dep-1", + }); + + async function publish() { + t.givenEnv({ BASE44_VERSIONS_API: "1" }); + await t.givenLoggedInWithProject(fixture("fullstack-project")); + mockFullStackApi(); + return await t.run("publish", "--no-build"); + } + + it("declares the Worker alongside the frontend, in one version", async () => { + // One build, one source commit, one version — the app's assets and the + // app's server are the same app. + const result = await publish(); + + t.expectResult(result).toSucceed(); + expect(t.api.versionDeclareRequests[0]).toMatchObject({ + site_worker: { + main: "index.js", + compatibility_date: "2025-04-01", + compatibility_flags: ["nodejs_compat"], + }, + }); + }); + + it("takes the frontend from where the Worker serves it, not from the project's output directory", async () => { + const result = await publish(); + + t.expectResult(result).toSucceed(); + const declared = t.api.versionDeclareRequests[0] as { + static_bundle: Array<{ path: string }>; + site_worker: { modules: Array<{ path: string }> }; + }; + expect(declared.static_bundle.map((f) => f.path)).toEqual([ + "assets/app-123.js", + "index.html", + ]); + expect(declared.site_worker.modules.map((m) => m.path).sort()).toEqual([ + "assets/chunk-abc.js", + "index.js", + "index.js.map", + ]); + }); + + it("puts each declared file's own bytes at the URL signed for it", async () => { + // Paired by position, not by path: `index.js` names a module here and an + // asset in other builds, and swapping the two would upload each under the + // other's checksum. + const result = await publish(); + + t.expectResult(result).toSucceed(); + const uploaded = new Map( + t.api.presignedUploadRequests.map((u) => [u.path, u.data.toString()]), + ); + expect(uploaded.get("/index.js")).toBe(SERVER_INDEX); + expect(uploaded.get("/index.html")).toBe(CLIENT_INDEX); + }); +}); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index a16dd0d6..7a4f317a 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -870,11 +870,20 @@ export class TestAPIServer { handler: (req, res) => { const body = req.body as { static_bundle: Array<{ path: string; size: number; digest: string }>; + site_worker?: { + modules: Array<{ path: string; size: number; digest: string }>; + }; }; this.versionDeclareRequests.push(body); + // Frontend first, then the Worker's modules — the slot order the server + // signs them in, which is what the client pairs uploads against. + const declared = [ + ...body.static_bundle, + ...(body.site_worker?.modules ?? []), + ]; res.status(200).json({ session_id: sessionId, - uploads: body.static_bundle.map((file) => ({ + uploads: declared.map((file) => ({ path: file.path, url: `${this.baseUrl}/presigned/${file.path}`, content_type: "application/octet-stream", diff --git a/packages/cli/tests/core/version-artifacts.spec.ts b/packages/cli/tests/core/version-artifacts.spec.ts index 65f641cf..d170894e 100644 --- a/packages/cli/tests/core/version-artifacts.spec.ts +++ b/packages/cli/tests/core/version-artifacts.spec.ts @@ -8,6 +8,7 @@ import { hashAsset } from "@/core/site/manifest.js"; import { collectBuildOutput, collectResources, + collectSiteWorker, } from "@/core/version/artifacts.js"; function sha256(content: string): string { @@ -162,3 +163,106 @@ describe("collectResources", () => { }); }); }); + +describe("collectSiteWorker", () => { + let projectRoot: string; + let distDir: string; + + async function writeFullStackBuild( + config: Record = {}, + ): Promise { + await mkdir(join(distDir, "client"), { recursive: true }); + await writeFile(join(distDir, "client", "index.html"), "

Hi

\n"); + await writeFile(join(distDir, "index.js"), "export default {};"); + await writeFile( + join(distDir, "wrangler.json"), + JSON.stringify({ + main: "index.js", + no_bundle: true, + rules: [{ type: "ESModule", globs: ["**/*.js"] }], + assets: { directory: "./client" }, + ...config, + }), + ); + await mkdir(join(projectRoot, ".wrangler", "deploy"), { recursive: true }); + await writeFile( + join(projectRoot, ".wrangler", "deploy", "config.json"), + JSON.stringify({ configPath: "../../dist/wrangler.json" }), + ); + } + + beforeEach(async () => { + projectRoot = await mkdtemp(join(tmpdir(), "b44-fullstack-")); + distDir = join(projectRoot, "dist"); + }); + + afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }); + }); + + it("reports no worker for an app that has no server of its own", async () => { + // Almost every app: there is no redirect file, so there is nothing to read. + expect(await collectSiteWorker(projectRoot)).toBeNull(); + }); + + it("describes each module the same way it describes a frontend file", async () => { + await writeFullStackBuild(); + + const worker = await collectSiteWorker(projectRoot); + + expect(worker?.modules).toEqual([ + { + path: "index.js", + absolutePath: join(distDir, "index.js"), + size: 18, + digest: sha256("export default {};"), + }, + ]); + }); + + it("names the entry as the module set names it, not as the config wrote it", async () => { + // The platform matches `main` against the module names it was sent, so a + // "./" that survived would name a module nothing in the set provides. + await writeFullStackBuild({ main: "./index.js" }); + + const worker = await collectSiteWorker(projectRoot); + + expect(worker?.main).toBe("index.js"); + }); + + it("carries the settings the modules were built for", async () => { + await writeFullStackBuild({ + compatibility_date: "2026-01-01", + compatibility_flags: ["nodejs_compat"], + }); + + const worker = await collectSiteWorker(projectRoot); + + expect(worker?.compatibilityDate).toBe("2026-01-01"); + expect(worker?.compatibilityFlags).toEqual(["nodejs_compat"]); + }); + + it("points the frontend at the worker's own assets directory", async () => { + // Not the project's `site.outputDirectory`: a full-stack build puts the + // frontend where the Worker serves it from. + await writeFullStackBuild(); + + const worker = await collectSiteWorker(projectRoot); + + expect(worker?.assetsDir).toBe(join(distDir, "client")); + expect(await collectBuildOutput(worker?.assetsDir as string)).toHaveLength( + 1, + ); + }); + + it("leaves the assets out of the module set", async () => { + // They are declared as the frontend. Declared twice, they would upload + // twice and land in two different prefixes. + await writeFullStackBuild(); + await writeFile(join(distDir, "client", "app.js"), "console.log(1);"); + + const worker = await collectSiteWorker(projectRoot); + + expect(worker?.modules.map((m) => m.path)).toEqual(["index.js"]); + }); +}); From 29a240564ff6967a884e6a8b9dfbf4664688402e Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Wed, 16 Sep 2026 14:19:14 +0300 Subject: [PATCH 16/21] fix(publish): a Worker's assets are optional, and never the project's output dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs from the same wrong assumption — that every build has a frontend the platform serves from S3. `?? requireOutputDir(target)` fired whenever a full-stack build had no assets directory, sending the publish to the project's own `site.outputDirectory`. That is not where a full-stack app's frontend lives, so it would have declared the wrong files or thrown a config error on a perfectly valid app. A Worker that answers every path itself has no assets at all, and that is now what gets declared: nothing. `collectBuildOutput` also required index.html unconditionally. That is the S3 serving contract — the platform answers any unmatched path with that one file — and it does not apply when a Worker serves: the legacy deploy lane reads an index only on its non-worker branch, because a server-rendered app renders its own HTML. The requirement is now the caller's to state. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/src/cli/commands/publish.ts | 16 ++++++-- packages/cli/src/core/version/artifacts.ts | 9 ++++- .../cli/tests/core/version-artifacts.spec.ts | 40 +++++++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/cli/commands/publish.ts b/packages/cli/src/cli/commands/publish.ts index 9009727b..d97b3eb8 100644 --- a/packages/cli/src/cli/commands/publish.ts +++ b/packages/cli/src/cli/commands/publish.ts @@ -61,12 +61,20 @@ async function publishAction( // of producing the version, so a missing directory is a create_version // failure rather than an envelope with no step at all. const artifacts = await tagStep("create_version", async () => { - // A full-stack build puts the frontend where the Worker serves it from, - // which is not the project's own output directory. + // Who serves the frontend decides where it is and what it must contain. + // With a Worker it is the Worker's own assets directory — which a + // server-rendered app may not have at all — never the project's + // `site.outputDirectory`. const siteWorker = await collectSiteWorker(target.root); - const outputDir = siteWorker?.assetsDir ?? requireOutputDir(target); + const assetsDir = siteWorker + ? siteWorker.assetsDir + : requireOutputDir(target); return { - files: await collectBuildOutput(outputDir), + files: assetsDir + ? await collectBuildOutput(assetsDir, { + requireEntry: siteWorker === null, + }) + : [], ...(siteWorker ? { siteWorker } : {}), ...(await collectResources(target.configDir, target)), }; diff --git a/packages/cli/src/core/version/artifacts.ts b/packages/cli/src/core/version/artifacts.ts index 2252f8ea..e4656f70 100644 --- a/packages/cli/src/core/version/artifacts.ts +++ b/packages/cli/src/core/version/artifacts.ts @@ -30,7 +30,11 @@ const MAX_FILE_COUNT = 50_000; /** Open descriptors while hashing. Well under the 256 a production Node keeps. */ const HASH_CONCURRENCY = 32; -/** The platform refuses a set without it; failing here saves the upload. */ +/** + * The entry file the platform serves for any unmatched path — but only when the + * platform is what serves. A Worker's own asset settings decide that instead, + * so a full-stack build is not required to have one. + */ const ENTRY = "index.html"; /** @@ -52,6 +56,7 @@ async function digestFile(absolutePath: string): Promise { */ export async function collectBuildOutput( outputDir: string, + options: { requireEntry?: boolean } = {}, ): Promise { const relativePaths = await walkBuildOutput(outputDir); @@ -70,7 +75,7 @@ export async function collectBuildOutput( `Too many files: found ${relativePaths.length}, the limit is ${MAX_FILE_COUNT}.`, ); } - if (!relativePaths.includes(ENTRY)) { + if (options.requireEntry !== false && !relativePaths.includes(ENTRY)) { throw new InvalidInputError( `${outputDir} has no ${ENTRY}, so nothing could enter the site.`, ); diff --git a/packages/cli/tests/core/version-artifacts.spec.ts b/packages/cli/tests/core/version-artifacts.spec.ts index d170894e..1ab098eb 100644 --- a/packages/cli/tests/core/version-artifacts.spec.ts +++ b/packages/cli/tests/core/version-artifacts.spec.ts @@ -200,6 +200,17 @@ describe("collectSiteWorker", () => { await rm(projectRoot, { recursive: true, force: true }); }); + it("reports a Worker with no assets directory rather than falling back", async () => { + // The fallback that used to live in `publish` sent a full-stack app to the + // project's own `site.outputDirectory`, which is not where its frontend is. + await writeFullStackBuild({ assets: undefined }); + + const worker = await collectSiteWorker(projectRoot); + + expect(worker).not.toBeNull(); + expect(worker?.assetsDir).toBeNull(); + }); + it("reports no worker for an app that has no server of its own", async () => { // Almost every app: there is no redirect file, so there is nothing to read. expect(await collectSiteWorker(projectRoot)).toBeNull(); @@ -266,3 +277,32 @@ describe("collectSiteWorker", () => { expect(worker?.modules.map((m) => m.path)).toEqual(["index.js"]); }); }); + +describe("who serves the frontend decides what it must contain", () => { + let outputDir: string; + + beforeEach(async () => { + outputDir = await mkdtemp(join(tmpdir(), "b44-serves-")); + await writeFile(join(outputDir, "app.js"), "console.log(1);"); + }); + + afterEach(async () => { + await rm(outputDir, { recursive: true, force: true }); + }); + + it("refuses a set with no entry when the platform is what serves", async () => { + // Any unmatched path is answered with that one file, so without it there is + // nothing to enter. + await expect(collectBuildOutput(outputDir)).rejects.toThrow( + InvalidInputError, + ); + }); + + it("accepts a set with no entry when a Worker serves", async () => { + // A server-rendered app renders its own HTML, and the Worker's asset + // settings decide what an unmatched path gets. + const files = await collectBuildOutput(outputDir, { requireEntry: false }); + + expect(files.map((f) => f.path)).toEqual(["app.js"]); + }); +}); From 92f165dd34fa4bee4e9e3bd11efea65059702610 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Wed, 16 Sep 2026 14:41:55 +0300 Subject: [PATCH 17/21] fix(build): never substitute an empty app id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `build` and `publish` both read `app?.id ?? ""`. Neither can reach the fallback today — both declare `requireAppContext`, so the lifecycle has already run `initAppContext`, which returns an app or throws on every path. The `??` was appeasing a type, not handling a state. It is worth removing anyway, because of what it would do if it ever fired. `execa` extends `process.env` rather than replacing it, so `VITE_BASE44_APP_ID: ""` overrides whatever the sandbox supplied; Vite inlines that at build time (`app-params.js` reads it); and the build, the upload and the publish all succeed. The failure surfaces in the visitor's browser, against an app id that addresses nothing. One `requireAppContext: false` — a plausible edit on a command that calls no API and already sets `requireAuth: false` — is all it takes, and `build.ts` even carried a comment saying the app id is required, two lines above the line defaulting it. `requireApp` narrows instead. It lives beside `ensureAppContext`, which is what put the value there. Co-Authored-By: Claude Opus 5 (1M context) --- .../cli/src/cli/commands/project/build.ts | 10 ++++---- packages/cli/src/cli/commands/publish.ts | 9 ++++---- packages/cli/src/cli/utils/command/index.ts | 1 + .../cli/src/cli/utils/command/middleware.ts | 23 +++++++++++++++++++ packages/cli/tests/core/require-app.spec.ts | 16 +++++++++++++ 5 files changed, 50 insertions(+), 9 deletions(-) create mode 100644 packages/cli/tests/core/require-app.spec.ts diff --git a/packages/cli/src/cli/commands/project/build.ts b/packages/cli/src/cli/commands/project/build.ts index 887b25c0..e13f2248 100644 --- a/packages/cli/src/cli/commands/project/build.ts +++ b/packages/cli/src/cli/commands/project/build.ts @@ -1,23 +1,23 @@ import type { Command } from "commander"; import { runSiteBuild } from "@/cli/commands/project/site-build.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; -import { Base44Command, theme } from "@/cli/utils/index.js"; +import { Base44Command, requireApp, theme } from "@/cli/utils/index.js"; import { resolvePublishTarget } from "@/core/version/index.js"; async function buildAction(ctx: CLIContext): Promise { - const { app } = ctx; + const app = requireApp(ctx); // Not readProjectConfig: a Builder repo carries no CLI config, and this is the // step a publish sandbox runs inside one. A present config still wins. - const target = await resolvePublishTarget(app?.projectRoot); + const target = await resolvePublishTarget(app.projectRoot); await runSiteBuild(ctx, { root: target.root, buildCommand: target.buildCommand, - appId: app?.id ?? "", + appId: app.id, }); return { - outroMessage: `Site built with app id ${theme.styles.bold(app?.id ?? "")}`, + outroMessage: `Site built with app id ${theme.styles.bold(app.id)}`, }; } diff --git a/packages/cli/src/cli/commands/publish.ts b/packages/cli/src/cli/commands/publish.ts index d97b3eb8..3ef40f22 100644 --- a/packages/cli/src/cli/commands/publish.ts +++ b/packages/cli/src/cli/commands/publish.ts @@ -7,7 +7,7 @@ import { targetOption, } from "@/cli/commands/versions/options.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; -import { Base44Command, theme } from "@/cli/utils/index.js"; +import { Base44Command, requireApp, theme } from "@/cli/utils/index.js"; import { resolveProvenanceCommit } from "@/core/site/index.js"; import { collectBuildOutput, @@ -38,8 +38,9 @@ async function publishAction( ctx: CLIContext, options: PublishOptions, ): Promise { - const { runTask, log, jsonMode, app } = ctx; - const target = await resolvePublishTarget(app?.projectRoot, { + const { runTask, log, jsonMode } = ctx; + const app = requireApp(ctx); + const target = await resolvePublishTarget(app.projectRoot, { outputDir: options.outputDir, }); @@ -48,7 +49,7 @@ async function publishAction( runSiteBuild(ctx, { root: target.root, buildCommand: target.buildCommand, - appId: app?.id ?? "", + appId: app.id, }), ); } diff --git a/packages/cli/src/cli/utils/command/index.ts b/packages/cli/src/cli/utils/command/index.ts index 70788e23..af21f5ba 100644 --- a/packages/cli/src/cli/utils/command/index.ts +++ b/packages/cli/src/cli/utils/command/index.ts @@ -1 +1,2 @@ export * from "./Base44Command.js"; +export * from "./middleware.js"; diff --git a/packages/cli/src/cli/utils/command/middleware.ts b/packages/cli/src/cli/utils/command/middleware.ts index 742287f0..593621ae 100644 --- a/packages/cli/src/cli/utils/command/middleware.ts +++ b/packages/cli/src/cli/utils/command/middleware.ts @@ -6,6 +6,8 @@ import { readAuth, seedAuthFromEnv, } from "@/core/auth/index.js"; +import { InternalError } from "@/core/errors.js"; +import type { AppContext } from "@/core/project/index.js"; import { initAppContext } from "@/core/project/index.js"; /** @@ -52,3 +54,24 @@ export async function ensureAppContext( ctx.app = appContext; ctx.errorReporter.setContext({ appId: appContext.id }); } + +/** + * The app this command resolved, narrowed. + * + * `CLIContext.app` is optional only because a handful of commands declare + * `requireAppContext: false`. Every other command has already been through + * {@link ensureAppContext}, which returns an app or throws — so for them the + * absent case is unreachable, and the optional type is what is inaccurate. + * + * Use this rather than defaulting at the call site. An app id substituted with + * `""` is not a missing value the build reports: Vite inlines it, so the build + * and the publish both succeed and the served app addresses no app at all. + */ +export function requireApp(ctx: Pick): AppContext { + if (!ctx.app) { + throw new InternalError( + "This command read an app context it never resolved — it is declared with requireAppContext: false.", + ); + } + return ctx.app; +} diff --git a/packages/cli/tests/core/require-app.spec.ts b/packages/cli/tests/core/require-app.spec.ts new file mode 100644 index 00000000..ef74ffd5 --- /dev/null +++ b/packages/cli/tests/core/require-app.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { requireApp } from "@/cli/utils/command/middleware.js"; +import { InternalError } from "@/core/errors.js"; + +describe("requireApp", () => { + it("hands back the app the lifecycle resolved", () => { + expect(requireApp({ app: { id: "app-1" } }).id).toBe("app-1"); + }); + + it("raises rather than letting a command run without one", () => { + // Unreachable for a command that did not opt out of app context — which is + // the point: the alternative was defaulting the id to "", and Vite inlines + // that into a dist whose SDK addresses no app, with nothing failing. + expect(() => requireApp({ app: undefined })).toThrow(InternalError); + }); +}); From 33f33d363b3eb94568421677ad9613ab153df34f Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Wed, 16 Sep 2026 14:49:03 +0300 Subject: [PATCH 18/21] refactor(site): one reader for a build's files, one for its Worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both lanes ask the same two questions of a build directory and were answering them separately. `resolveFullStackBuild` is now the single answer to "what did the framework build": the redirect file, the wrangler config, the modules, and an assets directory confirmed to exist. The deploy lane shapes that into a Cloudflare worker config; the versions lane hashes the modules into artifacts. Two readers of one directory eventually give two answers, and these two lanes are meant to agree — the full-stack path is the next one to move onto the versions API. `describeBuildOutput` is the same for a build's files: walk under `.assetsignore` rules, locate, size. The hash is deliberately NOT in it. A deployment keys assets by sha256(app id ‖ bytes) truncated to 32 — a per-tenant cache key — and a version addresses artifacts by a full sha256 signed into its upload URL. One value for both would either never dedupe or 403 a correct file. What they do share is the mechanism, `hashFileInto`, so neither writes the streaming loop again. Behaviour is unchanged; the only difference is that the asset walk now stats with bounded concurrency rather than one file at a time, which at the lane's own 100k ceiling it wanted anyway. `collectSiteWorker` claimed in its docstring to reuse the full-stack lane whole. It reused three collectors and re-did the orchestration around them. Now it does what it said. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/src/core/site/deployment.ts | 21 ++--- packages/cli/src/core/site/full-stack.ts | 46 +++++++++++ packages/cli/src/core/site/index.ts | 1 + packages/cli/src/core/site/manifest.ts | 77 +++++++++++++++---- packages/cli/src/core/version/artifacts.ts | 60 +++++---------- .../cli/tests/core/site-full-stack.spec.ts | 68 ++++++++++++++++ 6 files changed, 200 insertions(+), 73 deletions(-) create mode 100644 packages/cli/src/core/site/full-stack.ts create mode 100644 packages/cli/tests/core/site-full-stack.spec.ts diff --git a/packages/cli/src/core/site/deployment.ts b/packages/cli/src/core/site/deployment.ts index a73e3104..31869ab1 100644 --- a/packages/cli/src/core/site/deployment.ts +++ b/packages/cli/src/core/site/deployment.ts @@ -2,10 +2,9 @@ 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 { pathExists } from "@/core/utils/fs.js"; import { createDeployment, finalizeDeployment } from "./api.js"; +import { resolveFullStackBuild } from "./full-stack.js"; import { buildAssetManifest } from "./manifest.js"; -import { collectModules } from "./modules.js"; import type { AssetManifestResult, CreateDeploymentRequest, @@ -14,10 +13,6 @@ import type { } from "./schema.js"; import { uploadDeploymentAssets } from "./upload.js"; import type { ResolvedWranglerConfig } from "./wrangler-config.js"; -import { - detectFullStackArtifact, - resolveWranglerConfig, -} from "./wrangler-config.js"; type WorkerConfig = NonNullable; @@ -115,18 +110,12 @@ async function resolveWorkerBuild( projectRoot: string, progress?: DeploymentProgress, ): Promise { - const redirectPath = await detectFullStackArtifact(projectRoot); - if (!redirectPath) { + const built = await resolveFullStackBuild(projectRoot); + if (!built) { return null; } - const config = await resolveWranglerConfig(redirectPath); - - const assetsDir = - config.assetsDirectory && (await pathExists(config.assetsDirectory)) - ? config.assetsDirectory - : null; - + const { config, modules, assetsDir } = built; return { config: { main: config.main, @@ -134,7 +123,7 @@ async function resolveWorkerBuild( compatibility_flags: config.compatibilityFlags, assets: buildAssetsConfig(config.assetsConfig, progress), }, - modules: await collectModules(config), + modules, assetsDir, }; } diff --git a/packages/cli/src/core/site/full-stack.ts b/packages/cli/src/core/site/full-stack.ts new file mode 100644 index 00000000..deb05125 --- /dev/null +++ b/packages/cli/src/core/site/full-stack.ts @@ -0,0 +1,46 @@ +import { pathExists } from "@/core/utils/fs.js"; +import { collectModules } from "./modules.js"; +import type { WorkerModule } from "./schema.js"; +import type { ResolvedWranglerConfig } from "./wrangler-config.js"; +import { + detectFullStackArtifact, + resolveWranglerConfig, +} from "./wrangler-config.js"; + +interface FullStackBuild { + config: ResolvedWranglerConfig; + modules: WorkerModule[]; + /** + * The directory the Worker serves its assets from, confirmed to exist. + * `null` when the config declares none, or when this build produced none — + * a Worker that answers every path itself is a complete app. + */ + assetsDir: string | null; +} + +/** + * The full-stack artifact this project's build left behind, or `null` when it + * built a plain static site. + * + * ONE reader, for both lanes. "What did the framework build" has a single + * answer; two readers of the same directory would eventually give two, and the + * lane that publishes would disagree with the lane that deploys. + */ +export async function resolveFullStackBuild( + projectRoot: string, +): Promise { + const redirectPath = await detectFullStackArtifact(projectRoot); + if (!redirectPath) { + return null; + } + + const config = await resolveWranglerConfig(redirectPath); + return { + config, + modules: await collectModules(config), + assetsDir: + config.assetsDirectory && (await pathExists(config.assetsDirectory)) + ? config.assetsDirectory + : null, + }; +} diff --git a/packages/cli/src/core/site/index.ts b/packages/cli/src/core/site/index.ts index f36431f2..d95877b0 100644 --- a/packages/cli/src/core/site/index.ts +++ b/packages/cli/src/core/site/index.ts @@ -2,6 +2,7 @@ export * from "./api.js"; export * from "./config.js"; export * from "./deploy.js"; export * from "./deployment.js"; +export * from "./full-stack.js"; export * from "./git-hash.js"; export * from "./manifest.js"; export * from "./modules.js"; diff --git a/packages/cli/src/core/site/manifest.ts b/packages/cli/src/core/site/manifest.ts index 3177bc7c..cfca7eda 100644 --- a/packages/cli/src/core/site/manifest.ts +++ b/packages/cli/src/core/site/manifest.ts @@ -1,8 +1,10 @@ +import type { Hash } from "node:crypto"; import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { stat } from "node:fs/promises"; import { basename, extname, join } from "node:path"; import { globby } from "globby"; +import pMap from "p-map"; import { InvalidInputError } from "@/core/errors.js"; import type { AssetFile, @@ -67,13 +69,11 @@ function getAssetContentType(filePath: string): string { /** * Every file a build emitted, as sorted forward-slash relative paths. * - * Shared with the versions lane: what counts as "a file this build produced" is - * one rule — `.assetsignore` with full gitignore semantics, plus the names no - * build ever ships — and two collectors disagreeing about it would mean the two - * lanes publish different sets from the same directory. What each does with a - * path afterwards is its own business. + * What counts as "a file this build produced" is one rule — `.assetsignore` + * with full gitignore semantics, plus the names no build ever ships. Reached + * through {@link describeBuildOutput}, which is what both lanes call. */ -export async function walkBuildOutput(outputDir: string): Promise { +async function walkBuildOutput(outputDir: string): Promise { // globby returns forward-slash paths on every platform. 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. @@ -87,6 +87,53 @@ export async function walkBuildOutput(outputDir: string): Promise { return found.filter((path) => !ALWAYS_IGNORED.has(basename(path))).sort(); } +/** One file a build emitted, located and sized. What names it is the caller's. */ +interface BuildFile { + /** Build-relative, forward slashes, no leading "/". */ + path: string; + absolutePath: string; + size: number; +} + +/** Open descriptors while walking. Well under the 256 a production Node keeps. */ +const STAT_CONCURRENCY = 32; + +/** + * Every file {@link walkBuildOutput} found, with its location and size. + * + * Both lanes need this and neither needs the other's hash, so the hash is not + * here: a deployment keys assets by a salted, truncated cache key, a version + * addresses artifacts by a full sha256, and the two must never be one value. + */ +export async function describeBuildOutput( + outputDir: string, +): Promise { + const relativePaths = await walkBuildOutput(outputDir); + return await pMap( + relativePaths, + async (path) => { + const absolutePath = join(outputDir, ...path.split("/")); + return { path, absolutePath, size: (await stat(absolutePath)).size }; + }, + { concurrency: STAT_CONCURRENCY }, + ); +} + +/** + * Feed a file's bytes through a hash in chunks, so a large file never lands in + * memory whole. What the caller seeds and how it renders the result is what + * makes one of these a cache key and the other an identity. + */ +export async function hashFileInto( + hash: Hash, + absolutePath: string, +): Promise { + for await (const chunk of createReadStream(absolutePath)) { + hash.update(chunk); + } + return hash; +} + export function hashAsset(appId: string, content: Buffer): string { return createHash("sha256") .update(Buffer.from(appId, "utf8")) @@ -103,10 +150,10 @@ async function hashAssetFile( appId: string, absolutePath: string, ): Promise { - const hash = createHash("sha256").update(Buffer.from(appId, "utf8")); - for await (const chunk of createReadStream(absolutePath)) { - hash.update(chunk); - } + const hash = await hashFileInto( + createHash("sha256").update(Buffer.from(appId, "utf8")), + absolutePath, + ); return hash.digest("hex").slice(0, 32); } @@ -123,17 +170,15 @@ export async function buildAssetManifest( const manifest: Record = {}; const filesByHash = new Map(); - const relativeFilePaths = await walkBuildOutput(assetsDir); + const files = await describeBuildOutput(assetsDir); - if (relativeFilePaths.length > MAX_ASSET_COUNT) { + if (files.length > MAX_ASSET_COUNT) { throw new InvalidInputError( - `Too many static assets: found ${relativeFilePaths.length}, the limit is ${MAX_ASSET_COUNT} files.`, + `Too many static assets: found ${files.length}, the limit is ${MAX_ASSET_COUNT} files.`, ); } - for (const relativePath of relativeFilePaths) { - const absolutePath = join(assetsDir, ...relativePath.split("/")); - const { size } = await stat(absolutePath); + for (const { path: relativePath, absolutePath, size } of files) { const hash = await hashAssetFile(appId, absolutePath); manifest[`/${relativePath}`] = { hash, size }; diff --git a/packages/cli/src/core/version/artifacts.ts b/packages/cli/src/core/version/artifacts.ts index e4656f70..fc361bfb 100644 --- a/packages/cli/src/core/version/artifacts.ts +++ b/packages/cli/src/core/version/artifacts.ts @@ -1,17 +1,11 @@ import { createHash } from "node:crypto"; -import { createReadStream } from "node:fs"; -import { stat } from "node:fs/promises"; import { join, resolve } from "node:path"; import { globby } from "globby"; import pMap from "p-map"; import { CONFIG_FILE_EXTENSION_GLOB } from "@/core/consts.js"; import { InvalidInputError } from "@/core/errors.js"; -import { walkBuildOutput } from "@/core/site/manifest.js"; -import { collectModules } from "@/core/site/modules.js"; -import { - detectFullStackArtifact, - resolveWranglerConfig, -} from "@/core/site/wrangler-config.js"; +import { resolveFullStackBuild } from "@/core/site/full-stack.js"; +import { describeBuildOutput, hashFileInto } from "@/core/site/manifest.js"; import { pathExists, readJsonFile } from "@/core/utils/fs.js"; import type { ArtifactFile, @@ -42,10 +36,7 @@ const ENTRY = "index.html"; * {@link ArtifactFile.digest}. */ async function digestFile(absolutePath: string): Promise { - const hash = createHash("sha256"); - for await (const chunk of createReadStream(absolutePath)) { - hash.update(chunk); - } + const hash = await hashFileInto(createHash("sha256"), absolutePath); return `sha256:${hash.digest("hex")}`; } @@ -58,9 +49,9 @@ export async function collectBuildOutput( outputDir: string, options: { requireEntry?: boolean } = {}, ): Promise { - const relativePaths = await walkBuildOutput(outputDir); + const found = await describeBuildOutput(outputDir); - if (relativePaths.length === 0) { + if (found.length === 0) { throw new InvalidInputError( `No files found in ${outputDir}. Build the site before creating a version.`, { @@ -70,32 +61,23 @@ export async function collectBuildOutput( }, ); } - if (relativePaths.length > MAX_FILE_COUNT) { + if (found.length > MAX_FILE_COUNT) { throw new InvalidInputError( - `Too many files: found ${relativePaths.length}, the limit is ${MAX_FILE_COUNT}.`, + `Too many files: found ${found.length}, the limit is ${MAX_FILE_COUNT}.`, ); } - if (options.requireEntry !== false && !relativePaths.includes(ENTRY)) { + if (options.requireEntry !== false && !found.some((f) => f.path === ENTRY)) { throw new InvalidInputError( `${outputDir} has no ${ENTRY}, so nothing could enter the site.`, ); } - // Bounded: one open descriptor per file, and the advertised ceiling is 100k. + // Bounded: one open descriptor per file, and the advertised ceiling is 50k. // An unbounded Promise.all hits EMFILE at ~1.5k on a default descriptor limit, // long before any of the declared limits. return await pMap( - relativePaths, - async (path) => { - const absolutePath = join(outputDir, ...path.split("/")); - const { size } = await stat(absolutePath); - return { - path, - absolutePath, - size, - digest: await digestFile(absolutePath), - }; - }, + found, + async (file) => ({ ...file, digest: await digestFile(file.absolutePath) }), { concurrency: HASH_CONCURRENCY }, ); } @@ -103,21 +85,20 @@ export async function collectBuildOutput( /** * The app's own server, when the framework built one — otherwise `null`. * - * Reuses the full-stack lane's collectors whole: the same redirect file, the - * same wrangler config, the same module set the legacy deploy sends to - * Cloudflare. A second reader here would be a second opinion about what the - * framework built. + * Reads through {@link resolveFullStackBuild}, the same call the deploy lane + * makes, and only then differs: this lane hashes the modules into artifacts + * where that one shapes them into a Cloudflare config. What the framework built + * is one answer, given once. */ export async function collectSiteWorker( projectRoot: string, ): Promise { - const redirectPath = await detectFullStackArtifact(projectRoot); - if (!redirectPath) { + const built = await resolveFullStackBuild(projectRoot); + if (!built) { return null; } - const config = await resolveWranglerConfig(redirectPath); - const modules = await collectModules(config); + const { config, modules, assetsDir } = built; // By identity, not by position: the platform matches the entry against the // module NAMES it was sent, and `main` in the config may still carry a "./". const entry = resolve(config.configDir, config.main); @@ -142,10 +123,7 @@ export async function collectSiteWorker( ), compatibilityDate: config.compatibilityDate, compatibilityFlags: config.compatibilityFlags, - assetsDir: - config.assetsDirectory && (await pathExists(config.assetsDirectory)) - ? config.assetsDirectory - : null, + assetsDir, }; } diff --git a/packages/cli/tests/core/site-full-stack.spec.ts b/packages/cli/tests/core/site-full-stack.spec.ts new file mode 100644 index 00000000..ae4f82f8 --- /dev/null +++ b/packages/cli/tests/core/site-full-stack.spec.ts @@ -0,0 +1,68 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveFullStackBuild } from "@/core/site/full-stack.js"; + +describe("resolveFullStackBuild", () => { + let projectRoot: string; + let distDir: string; + + async function writeBuild(config: Record = {}) { + await mkdir(join(distDir, "client"), { recursive: true }); + await writeFile(join(distDir, "client", "index.html"), "

Hi

\n"); + await writeFile(join(distDir, "index.js"), "export default {};"); + await writeFile( + join(distDir, "wrangler.json"), + JSON.stringify({ + main: "index.js", + no_bundle: true, + rules: [{ type: "ESModule", globs: ["**/*.js"] }], + assets: { directory: "./client" }, + ...config, + }), + ); + await mkdir(join(projectRoot, ".wrangler", "deploy"), { recursive: true }); + await writeFile( + join(projectRoot, ".wrangler", "deploy", "config.json"), + JSON.stringify({ configPath: "../../dist/wrangler.json" }), + ); + } + + beforeEach(async () => { + projectRoot = await mkdtemp(join(tmpdir(), "b44-fs-")); + distDir = join(projectRoot, "dist"); + }); + + afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }); + }); + + it("reports nothing for a project that built a plain static site", async () => { + expect(await resolveFullStackBuild(projectRoot)).toBeNull(); + }); + + it("answers with the config, the modules and the assets directory", async () => { + await writeBuild(); + + const built = await resolveFullStackBuild(projectRoot); + + expect(built?.config.main).toBe("index.js"); + expect(built?.modules.map((m) => m.name)).toEqual(["index.js"]); + expect(built?.assetsDir).toBe(join(distDir, "client")); + }); + + it("reports no assets directory when the config declares none", async () => { + await writeBuild({ assets: undefined }); + + expect((await resolveFullStackBuild(projectRoot))?.assetsDir).toBeNull(); + }); + + it("reports no assets directory when the build produced none", async () => { + // Declared but absent: the Worker answers every path itself, which is a + // complete app — not a build to refuse. + await writeBuild({ assets: { directory: "./nothing-here" } }); + + expect((await resolveFullStackBuild(projectRoot))?.assetsDir).toBeNull(); + }); +}); From 34f55ed6936eaa48b5ac7aff3a94eef5aa9c4179 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Wed, 16 Sep 2026 15:00:30 +0300 Subject: [PATCH 19/21] feat(publish): a Worker carries the files it serves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `static_bundle` is not a description of an app's files — the platform reads it as "serve this from S3" and hands the prefix straight to the dist service. So a full-stack build declaring its assets there was asking for exactly the wrong thing: those files would be served raw, past `run_worker_first`, `not_found_handling` and every server-rendered route the Worker owns. They now ride as `site_worker.assets`, and the two are mutually exclusive on the wire — a version is a static app or a full-stack one, and the server refuses a declaration naming both. Backend functions are untouched by that rule; either kind may have them. `collectSiteWorker` gathers them itself, because what a Worker serves is part of what the Worker is, and the entry-file rule does not apply to it. `publish` no longer picks a directory: with a Worker there is simply no static bundle to declare. Uploads still pair by position; the order is now static frontend, then modules, then assets. Co-Authored-By: Claude Opus 5 (1M context) --- docs/versions.md | 12 +++++++---- packages/cli/src/cli/commands/publish.ts | 18 ++++++----------- packages/cli/src/core/version/api.ts | 10 ++++++---- packages/cli/src/core/version/artifacts.ts | 7 ++++++- packages/cli/src/core/version/schema.ts | 10 +++++++--- packages/cli/tests/cli/publish.spec.ts | 12 ++++++++--- .../cli/tests/cli/testkit/TestAPIServer.ts | 7 +++++-- .../cli/tests/core/version-artifacts.spec.ts | 20 +++++++++---------- 8 files changed, 57 insertions(+), 39 deletions(-) diff --git a/docs/versions.md b/docs/versions.md index 6a2fff31..fd73d113 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -18,7 +18,7 @@ It is **not** `src/core/site/` — see [Deployments](deployments.md). That lane `createVersion(artifacts, options)` in `api.ts` — three calls, in this order and no other, so nothing is recorded until the bytes are in place: -1. **Declare.** `POST versions` with `static_bundle` (path, size, digest per file), `site_worker` when the app has a server of its own, the raw `entities` and `agents` payloads, and `source_commit`. The response carries a `session_id` and one presigned PUT per declared file, in declared order. +1. **Declare.** `POST versions` with **either** `static_bundle` (path, size, digest per file) **or** `site_worker`, plus the raw `entities` and `agents` payloads and `source_commit`. The response carries a `session_id` and one presigned PUT per declared file, in declared order. 2. **Upload.** `putPresigned` per file — the same PUT the static deployments lane uses, same `pMap` concurrency and same ky retry policy. Each PUT sends the server's `Content-Type` **and** its `x-amz-checksum-sha256` verbatim; deriving either locally would 403 on any mapping difference. Uploads are paired with declared files **by position**, not by path: the frontend and the Worker's modules are separate namespaces, so the same name can appear in both and mean two different files. 3. **Finalize.** `POST versions/{session_id}/finalize`, no body. The set was fixed at declare, so there is nothing left for the caller to change. @@ -36,10 +36,14 @@ Nothing else is the caller's to say: the app comes from the credential, and so d `collectSiteWorker(projectRoot)` looks for `.wrangler/deploy/config.json` — the redirect file a `@cloudflare/vite-plugin` build leaves behind — and, when it is there, reuses the full-stack deploy lane whole: `detectFullStackArtifact`, `resolveWranglerConfig`, `collectModules`. No second reader, because a second reader is a second opinion about what the framework built. -Two things follow from a Worker being present: +A commit is a static app **or** a full-stack one, never both, and backend functions ride on either. So the two are mutually exclusive on the wire and declaring both is refused: -- The frontend comes from the Worker's own `assets.directory`, not from the project's `site.outputDirectory`. That is where a full-stack build puts the files the Worker serves. -- `main` is sent as the module set names it, not as the config wrote it. The platform matches the entry against the names it was sent, so a surviving `./` would name a module nothing in the set provides. +- **Static** — `static_bundle` is the frontend, and the platform serves it from S3. It must contain `index.html`, because any unmatched path is answered with that one file. +- **Full-stack** — `site_worker.assets` is the frontend, taken from the Worker's own `assets.directory`, and the Worker serves it. No entry file is required: its own `not_found_handling` decides. A Worker that answers every path itself declares no assets at all, which is a complete app. + +`static_bundle` is not a description — the platform reads it as *serve this from S3* and hands the prefix straight to the dist service. Naming a Worker's files there would serve them raw, past every route the Worker owns. That is why they live under `site_worker` instead. + +`main` is sent as the module set names it, not as the config wrote it. The platform matches the entry against the names it was sent, so a surviving `./` would name a module nothing in the set provides. `compatibility_date` and `compatibility_flags` ride along. They are part of the Worker's **identity** on the platform, not metadata: the same modules under a different compatibility date are a different Worker. diff --git a/packages/cli/src/cli/commands/publish.ts b/packages/cli/src/cli/commands/publish.ts index 3ef40f22..39838d64 100644 --- a/packages/cli/src/cli/commands/publish.ts +++ b/packages/cli/src/cli/commands/publish.ts @@ -62,20 +62,14 @@ async function publishAction( // of producing the version, so a missing directory is a create_version // failure rather than an envelope with no step at all. const artifacts = await tagStep("create_version", async () => { - // Who serves the frontend decides where it is and what it must contain. - // With a Worker it is the Worker's own assets directory — which a - // server-rendered app may not have at all — never the project's - // `site.outputDirectory`. + // One frontend, and who serves it. A Worker carries its own files, so + // there is no static bundle to declare beside it — naming them there + // would ask the platform to serve them from S3 instead. const siteWorker = await collectSiteWorker(target.root); - const assetsDir = siteWorker - ? siteWorker.assetsDir - : requireOutputDir(target); return { - files: assetsDir - ? await collectBuildOutput(assetsDir, { - requireEntry: siteWorker === null, - }) - : [], + files: siteWorker + ? [] + : await collectBuildOutput(requireOutputDir(target)), ...(siteWorker ? { siteWorker } : {}), ...(await collectResources(target.configDir, target)), }; diff --git a/packages/cli/src/core/version/api.ts b/packages/cli/src/core/version/api.ts index 68cc72d0..9190fcaf 100644 --- a/packages/cli/src/core/version/api.ts +++ b/packages/cli/src/core/version/api.ts @@ -92,6 +92,7 @@ export async function createVersion( site_worker: { main: artifacts.siteWorker.main, modules: artifacts.siteWorker.modules.map(declaredFile), + assets: artifacts.siteWorker.assets.map(declaredFile), compatibility_date: artifacts.siteWorker.compatibilityDate, compatibility_flags: artifacts.siteWorker.compatibilityFlags, }, @@ -109,13 +110,14 @@ export async function createVersion( "declare", ); - // Flat, in declared order — the frontend then the Worker's modules — because - // that is the order the server signed them in. Paired by POSITION and not by - // path: the two sets have separate namespaces, so a module and an asset may - // share a name and still be different files. + // Flat, in declared order — the static frontend, then the Worker's modules, + // then what it serves — because that is the order the server signed them in. + // Paired by POSITION and not by path: the sets have separate namespaces, so a + // module and an asset may share a name and still be different files. const declaredFiles = [ ...artifacts.files, ...(artifacts.siteWorker?.modules ?? []), + ...(artifacts.siteWorker?.assets ?? []), ]; options.progress?.onDeclared?.({ diff --git a/packages/cli/src/core/version/artifacts.ts b/packages/cli/src/core/version/artifacts.ts index fc361bfb..536115f8 100644 --- a/packages/cli/src/core/version/artifacts.ts +++ b/packages/cli/src/core/version/artifacts.ts @@ -121,9 +121,14 @@ export async function collectSiteWorker( }), { concurrency: HASH_CONCURRENCY }, ), + // Collected here, not by the caller: what a Worker serves is part of what + // the Worker IS, and the entry rule does not apply — its own asset settings + // decide what an unmatched path gets. + assets: assetsDir + ? await collectBuildOutput(assetsDir, { requireEntry: false }) + : [], compatibilityDate: config.compatibilityDate, compatibilityFlags: config.compatibilityFlags, - assetsDir, }; } diff --git a/packages/cli/src/core/version/schema.ts b/packages/cli/src/core/version/schema.ts index ccbed153..2186c380 100644 --- a/packages/cli/src/core/version/schema.ts +++ b/packages/cli/src/core/version/schema.ts @@ -26,15 +26,19 @@ export interface ArtifactFile { export interface SiteWorkerArtifact { main: string; modules: ArtifactFile[]; + /** + * The files this Worker serves. Not `ArtifactSet.files`: that one is served + * from S3, and naming these there would serve them past every route the + * Worker owns. Empty for a Worker that answers every path itself. + */ + assets: ArtifactFile[]; compatibilityDate: string | null; compatibilityFlags: string[]; - /** Where the frontend is, for a full-stack build: the Worker's own assets - * directory, never the project's `site.outputDirectory`. */ - assetsDir: string | null; } /** Everything one build produced, as the create-version call describes it. */ export interface ArtifactSet { + /** The frontend, to be served from S3. Empty when a Worker serves. */ files: ArtifactFile[]; /** Absent for an app with no server of its own — almost every app. */ siteWorker?: SiteWorkerArtifact; diff --git a/packages/cli/tests/cli/publish.spec.ts b/packages/cli/tests/cli/publish.spec.ts index fbe34ef4..812f42b3 100644 --- a/packages/cli/tests/cli/publish.spec.ts +++ b/packages/cli/tests/cli/publish.spec.ts @@ -282,15 +282,21 @@ describe("publish command, for an app with a server of its own", () => { }); }); - it("takes the frontend from where the Worker serves it, not from the project's output directory", async () => { + it("declares no static bundle — the files it serves are the Worker's", async () => { + // Naming them as a static bundle would ask the platform to serve them from + // S3, past every route the Worker owns. const result = await publish(); t.expectResult(result).toSucceed(); const declared = t.api.versionDeclareRequests[0] as { static_bundle: Array<{ path: string }>; - site_worker: { modules: Array<{ path: string }> }; + site_worker: { + modules: Array<{ path: string }>; + assets: Array<{ path: string }>; + }; }; - expect(declared.static_bundle.map((f) => f.path)).toEqual([ + expect(declared.static_bundle).toEqual([]); + expect(declared.site_worker.assets.map((f) => f.path)).toEqual([ "assets/app-123.js", "index.html", ]); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 7a4f317a..93ce8e25 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -872,14 +872,17 @@ export class TestAPIServer { static_bundle: Array<{ path: string; size: number; digest: string }>; site_worker?: { modules: Array<{ path: string; size: number; digest: string }>; + assets: Array<{ path: string; size: number; digest: string }>; }; }; this.versionDeclareRequests.push(body); - // Frontend first, then the Worker's modules — the slot order the server - // signs them in, which is what the client pairs uploads against. + // The static frontend, then the Worker's modules, then what it serves — + // the slot order the server signs them in, which is what the client + // pairs uploads against. const declared = [ ...body.static_bundle, ...(body.site_worker?.modules ?? []), + ...(body.site_worker?.assets ?? []), ]; res.status(200).json({ session_id: sessionId, diff --git a/packages/cli/tests/core/version-artifacts.spec.ts b/packages/cli/tests/core/version-artifacts.spec.ts index 1ab098eb..69dd60e0 100644 --- a/packages/cli/tests/core/version-artifacts.spec.ts +++ b/packages/cli/tests/core/version-artifacts.spec.ts @@ -200,15 +200,15 @@ describe("collectSiteWorker", () => { await rm(projectRoot, { recursive: true, force: true }); }); - it("reports a Worker with no assets directory rather than falling back", async () => { - // The fallback that used to live in `publish` sent a full-stack app to the - // project's own `site.outputDirectory`, which is not where its frontend is. + it("serves nothing static when the build declared no assets", async () => { + // A Worker that answers every path itself is a complete app, not a build + // to refuse — and there is no static bundle to declare beside it. await writeFullStackBuild({ assets: undefined }); const worker = await collectSiteWorker(projectRoot); expect(worker).not.toBeNull(); - expect(worker?.assetsDir).toBeNull(); + expect(worker?.assets).toEqual([]); }); it("reports no worker for an app that has no server of its own", async () => { @@ -253,16 +253,16 @@ describe("collectSiteWorker", () => { expect(worker?.compatibilityFlags).toEqual(["nodejs_compat"]); }); - it("points the frontend at the worker's own assets directory", async () => { - // Not the project's `site.outputDirectory`: a full-stack build puts the - // frontend where the Worker serves it from. + it("carries the files it serves, from its own assets directory", async () => { + // Not the project's `site.outputDirectory`, and not a static bundle beside + // it: these files are the Worker's to serve. await writeFullStackBuild(); const worker = await collectSiteWorker(projectRoot); - expect(worker?.assetsDir).toBe(join(distDir, "client")); - expect(await collectBuildOutput(worker?.assetsDir as string)).toHaveLength( - 1, + expect(worker?.assets.map((f) => f.path)).toEqual(["index.html"]); + expect(worker?.assets[0].absolutePath).toBe( + join(distDir, "client", "index.html"), ); }); From 3e22b28b43cd76275b849f4ff6990106b221b606 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Wed, 16 Sep 2026 15:09:47 +0300 Subject: [PATCH 20/21] docs: cut the comments that restate the code `// One commit: an app's frontend and backend are the same app at the same source.` sat above `source_commit: options.sourceCommit`. It said nothing the field name did not, and it was not alone. Deleted the narration and tightened the rest to the part a reader cannot derive: a measured number, an ordering requirement, a rejected alternative. 113 lines out, 53 in. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/src/cli/commands/publish.ts | 18 +++---- .../cli/src/cli/utils/command/middleware.ts | 15 +++--- packages/cli/src/core/site/full-stack.ts | 14 ++--- packages/cli/src/core/site/manifest.ts | 20 ++------ packages/cli/src/core/version/api.ts | 25 ++++----- packages/cli/src/core/version/artifacts.ts | 51 +++++-------------- packages/cli/src/core/version/schema.ts | 23 ++++----- 7 files changed, 53 insertions(+), 113 deletions(-) diff --git a/packages/cli/src/cli/commands/publish.ts b/packages/cli/src/cli/commands/publish.ts index 39838d64..7ce846c9 100644 --- a/packages/cli/src/cli/commands/publish.ts +++ b/packages/cli/src/cli/commands/publish.ts @@ -28,11 +28,9 @@ interface PublishOptions { } /** - * Build, record a version, and serve it. - * - * Not `site deploy`: that drives the legacy hosting lane, where `deploymentId` - * means a Cloudflare script rather than a deployment on this plane. Separate - * command and separate envelope names, so the two cannot be confused. + * Build, record a version, and serve it. Not `site deploy`, whose `deploymentId` + * means a Cloudflare script rather than a deployment on this plane — separate + * envelope names so the two cannot be confused. */ async function publishAction( ctx: CLIContext, @@ -58,13 +56,11 @@ async function publishAction( const result = await runTask( "Publishing...", async (updateMessage) => { - // Inside the tag: resolving the output directory and reading it are part - // of producing the version, so a missing directory is a create_version - // failure rather than an envelope with no step at all. + // Inside the tag, so a missing output directory reports as a + // create_version failure rather than an envelope with no step. const artifacts = await tagStep("create_version", async () => { - // One frontend, and who serves it. A Worker carries its own files, so - // there is no static bundle to declare beside it — naming them there - // would ask the platform to serve them from S3 instead. + // A Worker carries its own files; a static bundle beside it would ask + // the platform to serve them from S3 instead. const siteWorker = await collectSiteWorker(target.root); return { files: siteWorker diff --git a/packages/cli/src/cli/utils/command/middleware.ts b/packages/cli/src/cli/utils/command/middleware.ts index 593621ae..3fca8f47 100644 --- a/packages/cli/src/cli/utils/command/middleware.ts +++ b/packages/cli/src/cli/utils/command/middleware.ts @@ -56,16 +56,13 @@ export async function ensureAppContext( } /** - * The app this command resolved, narrowed. + * The app this command resolved. Optional on `CLIContext` only for the few + * commands declaring `requireAppContext: false`; everywhere else + * {@link ensureAppContext} has already returned one or thrown. * - * `CLIContext.app` is optional only because a handful of commands declare - * `requireAppContext: false`. Every other command has already been through - * {@link ensureAppContext}, which returns an app or throws — so for them the - * absent case is unreachable, and the optional type is what is inaccurate. - * - * Use this rather than defaulting at the call site. An app id substituted with - * `""` is not a missing value the build reports: Vite inlines it, so the build - * and the publish both succeed and the served app addresses no app at all. + * Narrow here rather than defaulting at the call site: an app id defaulted to + * `""` is inlined by Vite, so the build and the publish both succeed and the + * served app addresses no app. */ export function requireApp(ctx: Pick): AppContext { if (!ctx.app) { diff --git a/packages/cli/src/core/site/full-stack.ts b/packages/cli/src/core/site/full-stack.ts index deb05125..ee33cdaf 100644 --- a/packages/cli/src/core/site/full-stack.ts +++ b/packages/cli/src/core/site/full-stack.ts @@ -11,20 +11,16 @@ interface FullStackBuild { config: ResolvedWranglerConfig; modules: WorkerModule[]; /** - * The directory the Worker serves its assets from, confirmed to exist. - * `null` when the config declares none, or when this build produced none — - * a Worker that answers every path itself is a complete app. + * Where the Worker serves assets from, confirmed to exist. `null` is a Worker + * that answers every path itself — a complete app, not a broken build. */ assetsDir: string | null; } /** - * The full-stack artifact this project's build left behind, or `null` when it - * built a plain static site. - * - * ONE reader, for both lanes. "What did the framework build" has a single - * answer; two readers of the same directory would eventually give two, and the - * lane that publishes would disagree with the lane that deploys. + * The full-stack artifact this build left behind, or `null` for a plain static + * site. ONE reader for both lanes — two would eventually disagree about the + * same directory. */ export async function resolveFullStackBuild( projectRoot: string, diff --git a/packages/cli/src/core/site/manifest.ts b/packages/cli/src/core/site/manifest.ts index cfca7eda..fd79775e 100644 --- a/packages/cli/src/core/site/manifest.ts +++ b/packages/cli/src/core/site/manifest.ts @@ -67,11 +67,8 @@ function getAssetContentType(filePath: string): string { * cannot poison another app's asset cache. */ /** - * Every file a build emitted, as sorted forward-slash relative paths. - * - * What counts as "a file this build produced" is one rule — `.assetsignore` - * with full gitignore semantics, plus the names no build ever ships. Reached - * through {@link describeBuildOutput}, which is what both lanes call. + * Every file a build emitted, sorted. One rule for both lanes: `.assetsignore` + * with full gitignore semantics, plus the names no build ever ships. */ async function walkBuildOutput(outputDir: string): Promise { // globby returns forward-slash paths on every platform. Never pass `ignore` @@ -99,11 +96,8 @@ interface BuildFile { const STAT_CONCURRENCY = 32; /** - * Every file {@link walkBuildOutput} found, with its location and size. - * - * Both lanes need this and neither needs the other's hash, so the hash is not - * here: a deployment keys assets by a salted, truncated cache key, a version - * addresses artifacts by a full sha256, and the two must never be one value. + * Every file {@link walkBuildOutput} found, located and sized. No hash: the two + * lanes' hashes are different values and must never become one. */ export async function describeBuildOutput( outputDir: string, @@ -119,11 +113,7 @@ export async function describeBuildOutput( ); } -/** - * Feed a file's bytes through a hash in chunks, so a large file never lands in - * memory whole. What the caller seeds and how it renders the result is what - * makes one of these a cache key and the other an identity. - */ +/** Stream a file through a hash, so a large one never lands in memory whole. */ export async function hashFileInto( hash: Hash, absolutePath: string, diff --git a/packages/cli/src/core/version/api.ts b/packages/cli/src/core/version/api.ts index 9190fcaf..adb84dc0 100644 --- a/packages/cli/src/core/version/api.ts +++ b/packages/cli/src/core/version/api.ts @@ -22,8 +22,8 @@ import { } from "@/core/version/schema.js"; /** - * Measured on the sandbox's pipe: at 3, a 25.5k-asset app needed ~930s of the - * ~450s a build leaves. 16 failed a degraded pipe on 2026-07-02. + * Measured on the sandbox's pipe: 3 needed ~930s of the ~450s a build leaves for + * a 25.5k-asset app; 16 failed a degraded pipe on 2026-07-02. */ export const DEFAULT_VERSION_UPLOAD_CONCURRENCY = 8; export const MAX_VERSION_UPLOAD_CONCURRENCY = 16; @@ -68,9 +68,8 @@ function parse(schema: ZodType, body: unknown, what: string): T { } /** - * Declare the artifact set, upload what it names, and commit the version. In - * that order: an interrupted run leaves staged objects that expire, never a - * version naming files that are not there. + * Declare, upload, then commit — in that order, so an interrupted run leaves + * staged objects that expire rather than a version naming files that are gone. */ export async function createVersion( artifacts: ArtifactSet, @@ -100,8 +99,6 @@ export async function createVersion( : {}), entities: artifacts.entities, agents: artifacts.agents, - // One commit: an app's frontend and backend are the same app at the - // same source. source_commit: options.sourceCommit, }, "declaring a version", @@ -110,10 +107,8 @@ export async function createVersion( "declare", ); - // Flat, in declared order — the static frontend, then the Worker's modules, - // then what it serves — because that is the order the server signed them in. - // Paired by POSITION and not by path: the sets have separate namespaces, so a - // module and an asset may share a name and still be different files. + // Paired by POSITION, in the order the server signed them: the three sets have + // separate namespaces, so a module and an asset may share a path. const declaredFiles = [ ...artifacts.files, ...(artifacts.siteWorker?.modules ?? []), @@ -159,11 +154,9 @@ export async function createVersion( } /** - * Point an environment at a recorded version. - * - * An environment serves one version, so making a version live is editing that - * pointer — there is no deployment to create. Pointing it at an older version is - * the rollback. + * Point an environment at a recorded version. An environment serves one version, + * so this is a pointer edit, not a deployment — and an older version is the + * rollback. */ export async function setEnvironmentVersion( environment: string, diff --git a/packages/cli/src/core/version/artifacts.ts b/packages/cli/src/core/version/artifacts.ts index 536115f8..6ad252c6 100644 --- a/packages/cli/src/core/version/artifacts.ts +++ b/packages/cli/src/core/version/artifacts.ts @@ -13,38 +13,22 @@ import type { SiteWorkerArtifact, } from "@/core/version/schema.js"; -/** - * What one declaration may cost the platform: a presigned URL per file, and the - * whole set held in Redis until it finalizes. ~2x the largest frontend ever - * measured through the build sandbox (25.5k assets). Must match the server's - * ceiling — declaring more only earns a rejection after the walk. - */ +/** Must match the server's ceiling: declaring more only earns a late rejection. */ const MAX_FILE_COUNT = 50_000; /** Open descriptors while hashing. Well under the 256 a production Node keeps. */ const HASH_CONCURRENCY = 32; -/** - * The entry file the platform serves for any unmatched path — but only when the - * platform is what serves. A Worker's own asset settings decide that instead, - * so a full-stack build is not required to have one. - */ +/** Served for any unmatched path — but only when the platform is what serves. */ const ENTRY = "index.html"; -/** - * Full sha256 over a file's bytes, streamed. Deliberately not `hashAsset` — see - * {@link ArtifactFile.digest}. - */ +/** Deliberately not `hashAsset` — see {@link ArtifactFile.digest}. */ async function digestFile(absolutePath: string): Promise { const hash = await hashFileInto(createHash("sha256"), absolutePath); return `sha256:${hash.digest("hex")}`; } -/** - * Walk a build's output directory and describe every file in it. Honors - * `.assetsignore` by the same rules the site collector uses, so the two lanes - * cannot disagree about what a build produced. - */ +/** Every file a build emitted, addressed and hashed. */ export async function collectBuildOutput( outputDir: string, options: { requireEntry?: boolean } = {}, @@ -72,9 +56,7 @@ export async function collectBuildOutput( ); } - // Bounded: one open descriptor per file, and the advertised ceiling is 50k. - // An unbounded Promise.all hits EMFILE at ~1.5k on a default descriptor limit, - // long before any of the declared limits. + // Bounded: an unbounded Promise.all hits EMFILE at ~1.5k open descriptors. return await pMap( found, async (file) => ({ ...file, digest: await digestFile(file.absolutePath) }), @@ -83,12 +65,8 @@ export async function collectBuildOutput( } /** - * The app's own server, when the framework built one — otherwise `null`. - * - * Reads through {@link resolveFullStackBuild}, the same call the deploy lane - * makes, and only then differs: this lane hashes the modules into artifacts - * where that one shapes them into a Cloudflare config. What the framework built - * is one answer, given once. + * The app's own server, when the framework built one — otherwise `null`. Reads + * through {@link resolveFullStackBuild}, the same call the deploy lane makes. */ export async function collectSiteWorker( projectRoot: string, @@ -121,9 +99,7 @@ export async function collectSiteWorker( }), { concurrency: HASH_CONCURRENCY }, ), - // Collected here, not by the caller: what a Worker serves is part of what - // the Worker IS, and the entry rule does not apply — its own asset settings - // decide what an unmatched path gets. + // No entry rule: the Worker's own asset settings answer an unmatched path. assets: assetsDir ? await collectBuildOutput(assetsDir, { requireEntry: false }) : [], @@ -133,14 +109,11 @@ export async function collectSiteWorker( } /** - * The app's declared entities and agents, raw. - * - * Not the validated resource readers: the platform's extractor is authoritative, - * and this CLI's stricter entity schema refuses real Builder apps — which is why - * `site deploy` reads no resources at all. + * The app's declared entities and agents, raw — deliberately not the validated + * resource readers, whose stricter entity schema refuses real Builder apps. * - * Keyed by path with the schema extension stripped, the name the platform - * derives from the same file, so `agents/support/triage.jsonc` is `support/triage`. + * Keyed the way the platform names the same file: `agents/support/triage.jsonc` + * is `support/triage`. */ async function readRawResources(dir: string): Promise> { if (!(await pathExists(dir))) { diff --git a/packages/cli/src/core/version/schema.ts b/packages/cli/src/core/version/schema.ts index 2186c380..4eb8f322 100644 --- a/packages/cli/src/core/version/schema.ts +++ b/packages/cli/src/core/version/schema.ts @@ -1,12 +1,10 @@ import { z } from "zod"; /** - * A file the build produced. - * - * `digest` is a full sha256 over the bytes — artifact identity, signed into the - * upload URL so S3 refuses any other body. Not `hashAsset`, which truncates - * sha256(app id ‖ bytes) to key a provider's asset cache; conflating the two - * gives a file that uploads fine and never dedupes. + * A file the build produced. `digest` is a full sha256, signed into the upload + * URL so S3 refuses any other body — NOT `hashAsset`, which truncates + * sha256(app id ‖ bytes) to key a provider cache. Conflating them gives a file + * that uploads fine and never dedupes. */ export interface ArtifactFile { /** Build-relative, forward slashes, no leading "/". */ @@ -17,19 +15,16 @@ export interface ArtifactFile { } /** - * The app's own server, when the framework built one. - * - * Its modules are files exactly like the frontend's, in their own namespace — - * `index.js` here is not `index.js` there — plus the settings they were built - * to run under, which the platform treats as part of the Worker's identity. + * The app's own server. Each file set is its own namespace — `index.js` as a + * module is not `index.js` as an asset — and the settings are part of the + * Worker's identity, not metadata. */ export interface SiteWorkerArtifact { main: string; modules: ArtifactFile[]; /** - * The files this Worker serves. Not `ArtifactSet.files`: that one is served - * from S3, and naming these there would serve them past every route the - * Worker owns. Empty for a Worker that answers every path itself. + * The files this Worker serves. Not `ArtifactSet.files`, which the platform + * serves from S3 — past every route the Worker owns. */ assets: ArtifactFile[]; compatibilityDate: string | null; From 3b34f16c860aa4eb8b9ec40510d71c275213159b Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Wed, 16 Sep 2026 15:41:56 +0300 Subject: [PATCH 21/21] fix: one collector, and a step tag that cannot destroy its error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from review, two of them real defects. `versions create` never called `collectSiteWorker`. A full-stack app recorded through it would declare the Worker's own files as `static_bundle` — the one thing the platform reads as "serve from S3", past every route the Worker owns. The either/or rule this lane just gained was enforceable on the wire and side-stepped by a second collector. There is now one, `collectArtifacts`, and both commands go through it. `tagStep` called `Object.defineProperty` on whatever it caught. On a frozen or non-extensible error that throws a TypeError from inside the catch, so the original — message, status, request id — is lost entirely. Worse than untagged. Guarded with `Object.isExtensible`. `resolvePublishTarget` ran outside every tag, so an invalid `config.jsonc` reached the envelope with no step at all. It is `create_version`, for the reason the neighbouring test already gives: local validation is part of producing the version. Plus what the review found stale or dead: the upload progress claimed "N of N" from a number a guard two lines down forces to be equal; `versions deploy` declared `--target` inline while `publish` used the shared option the PR says is shared; the `hashAsset` docstring had been orphaned above a different function; two comments blamed file descriptors for a bound that is the libuv thread pool; `git-hash` promised `null` and returns `undefined`; and three doc claims had drifted from the code. Also added the path assertion the review asked for: an order drift between declared files and signed URLs now fails before the first PUT rather than as an S3 checksum rejection part way through. Co-Authored-By: Claude Opus 5 (1M context) --- docs/versions.md | 6 +- packages/cli/src/cli/commands/publish.ts | 32 +++------ .../cli/src/cli/commands/versions/create.ts | 35 ++++----- .../cli/src/cli/commands/versions/deploy.ts | 3 +- packages/cli/src/core/site/git-hash.ts | 2 +- packages/cli/src/core/site/manifest.ts | 12 ++-- packages/cli/src/core/version/api.ts | 15 ++-- packages/cli/src/core/version/artifacts.ts | 22 +++++- packages/cli/src/core/version/project.ts | 2 +- packages/cli/src/core/version/publish.ts | 9 ++- packages/cli/src/core/version/schema.ts | 2 +- .../cli/tests/cli/testkit/TestAPIServer.ts | 2 +- .../cli/tests/core/version-artifacts.spec.ts | 71 +++++++++++++++++++ .../cli/tests/core/version-publish.spec.ts | 13 ++++ 14 files changed, 164 insertions(+), 62 deletions(-) diff --git a/docs/versions.md b/docs/versions.md index fd73d113..2c7f098b 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -19,7 +19,7 @@ It is **not** `src/core/site/` — see [Deployments](deployments.md). That lane `createVersion(artifacts, options)` in `api.ts` — three calls, in this order and no other, so nothing is recorded until the bytes are in place: 1. **Declare.** `POST versions` with **either** `static_bundle` (path, size, digest per file) **or** `site_worker`, plus the raw `entities` and `agents` payloads and `source_commit`. The response carries a `session_id` and one presigned PUT per declared file, in declared order. -2. **Upload.** `putPresigned` per file — the same PUT the static deployments lane uses, same `pMap` concurrency and same ky retry policy. Each PUT sends the server's `Content-Type` **and** its `x-amz-checksum-sha256` verbatim; deriving either locally would 403 on any mapping difference. Uploads are paired with declared files **by position**, not by path: the frontend and the Worker's modules are separate namespaces, so the same name can appear in both and mean two different files. +2. **Upload.** `putPresigned` per file — the same PUT the static deployments lane uses, same ky retry policy. Each PUT sends the server's `Content-Type` **and** its `x-amz-checksum-sha256` verbatim; deriving either locally would 403 on any mapping difference. Uploads are paired with declared files **by position**, not by path: the frontend and the Worker's modules are separate namespaces, so the same name can appear in both and mean two different files. 3. **Finalize.** `POST versions/{session_id}/finalize`, no body. The set was fixed at declare, so there is nothing left for the caller to change. The response carries `version_id` and `manifest_hash`, and no flag for "this content already existed" — the hash **is** the identity, so a caller asking whether a rebuild changed anything compares it against the last one. An existing version is not necessarily the one being served, so such a flag would be misleading anyway. @@ -34,7 +34,7 @@ Nothing else is the caller's to say: the app comes from the credential, and so d ## An app with a server of its own -`collectSiteWorker(projectRoot)` looks for `.wrangler/deploy/config.json` — the redirect file a `@cloudflare/vite-plugin` build leaves behind — and, when it is there, reuses the full-stack deploy lane whole: `detectFullStackArtifact`, `resolveWranglerConfig`, `collectModules`. No second reader, because a second reader is a second opinion about what the framework built. +`collectSiteWorker(projectRoot)` reads through `resolveFullStackBuild` — the same call `site deploy` makes — which looks for `.wrangler/deploy/config.json`, the redirect file a `@cloudflare/vite-plugin` build leaves behind. No second reader, because a second reader is a second opinion about what the framework built. `publish` and `versions create` both collect through one `collectArtifacts`, for the same reason. A commit is a static app **or** a full-stack one, never both, and backend functions ride on either. So the two are mutually exclusive on the wire and declaring both is refused: @@ -83,7 +83,7 @@ A callback that throws synchronously is tagged too; `run().catch(...)` would let ## Commands -**`base44 publish [--no-build] [--output-dir ] [--target ] [--git-hash ] [--concurrency ]`** — build, record a version, serve it. Under `--json`, stdout is a single `{versionId, manifestHash, deploymentId}` document. +**`base44 publish [--no-build] [--output-dir ] [--target ] [--git-hash ] [--concurrency ]`** — build, record a version, serve it. Under `--json`, stdout is a single `{environment, versionId, manifestHash, deploymentId}` document. **`base44 versions create`** — record built output without serving it. A version can sit unpublished for as long as it likes. diff --git a/packages/cli/src/cli/commands/publish.ts b/packages/cli/src/cli/commands/publish.ts index 7ce846c9..84e355e0 100644 --- a/packages/cli/src/cli/commands/publish.ts +++ b/packages/cli/src/cli/commands/publish.ts @@ -10,11 +10,8 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command, requireApp, theme } from "@/cli/utils/index.js"; import { resolveProvenanceCommit } from "@/core/site/index.js"; import { - collectBuildOutput, - collectResources, - collectSiteWorker, + collectArtifacts, publishVersion, - requireOutputDir, resolvePublishTarget, tagStep, } from "@/core/version/index.js"; @@ -38,9 +35,11 @@ async function publishAction( ): Promise { const { runTask, log, jsonMode } = ctx; const app = requireApp(ctx); - const target = await resolvePublishTarget(app.projectRoot, { - outputDir: options.outputDir, - }); + // Tagged: a config this command cannot read is a version it cannot produce, + // the same reason `collectArtifacts` is tagged below. + const target = await tagStep("create_version", () => + resolvePublishTarget(app.projectRoot, { outputDir: options.outputDir }), + ); if (options.build !== false) { await tagStep("build", () => @@ -58,25 +57,16 @@ async function publishAction( async (updateMessage) => { // Inside the tag, so a missing output directory reports as a // create_version failure rather than an envelope with no step. - const artifacts = await tagStep("create_version", async () => { - // A Worker carries its own files; a static bundle beside it would ask - // the platform to serve them from S3 instead. - const siteWorker = await collectSiteWorker(target.root); - return { - files: siteWorker - ? [] - : await collectBuildOutput(requireOutputDir(target)), - ...(siteWorker ? { siteWorker } : {}), - ...(await collectResources(target.configDir, target)), - }; - }); + const artifacts = await tagStep("create_version", () => + collectArtifacts(target), + ); return await publishVersion(artifacts, { sourceCommit: gitHash, target: options.target, concurrency: options.concurrency, progress: { - onDeclared: ({ fileCount, owedFiles }) => - updateMessage(`Uploading ${owedFiles} of ${fileCount} files`), + onDeclared: ({ fileCount }) => + updateMessage(`Uploading ${fileCount} files`), onUpload: ({ uploadedFiles, totalFiles }) => updateMessage(`Uploaded ${uploadedFiles} of ${totalFiles} files`), }, diff --git a/packages/cli/src/cli/commands/versions/create.ts b/packages/cli/src/cli/commands/versions/create.ts index 067bcd59..a781255e 100644 --- a/packages/cli/src/cli/commands/versions/create.ts +++ b/packages/cli/src/cli/commands/versions/create.ts @@ -5,13 +5,11 @@ import { outputDirOption, } from "@/cli/commands/versions/options.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; -import { Base44Command } from "@/cli/utils/index.js"; +import { Base44Command, requireApp } from "@/cli/utils/index.js"; import { resolveProvenanceCommit } from "@/core/site/index.js"; import { - collectBuildOutput, - collectResources, + collectArtifacts, createVersion, - requireOutputDir, resolvePublishTarget, } from "@/core/version/index.js"; @@ -23,10 +21,11 @@ interface CreateOptions { /** Record a build that already exists. No build of its own, and no deploy. */ async function createAction( - { runTask, jsonMode, app }: CLIContext, + ctx: CLIContext, options: CreateOptions, ): Promise { - const target = await resolvePublishTarget(app?.projectRoot, { + const { runTask, jsonMode } = ctx; + const target = await resolvePublishTarget(requireApp(ctx).projectRoot, { outputDir: options.outputDir, }); const gitHash = await resolveProvenanceCommit(target.root, options.gitHash); @@ -34,22 +33,16 @@ async function createAction( const version = await runTask( "Creating version...", async (updateMessage) => - await createVersion( - { - files: await collectBuildOutput(requireOutputDir(target)), - ...(await collectResources(target.configDir, target)), + await createVersion(await collectArtifacts(target), { + sourceCommit: gitHash, + concurrency: options.concurrency, + progress: { + onDeclared: ({ fileCount }) => + updateMessage(`Uploading ${fileCount} files`), + onUpload: ({ uploadedFiles, totalFiles }) => + updateMessage(`Uploaded ${uploadedFiles} of ${totalFiles} files`), }, - { - sourceCommit: gitHash, - concurrency: options.concurrency, - progress: { - onDeclared: ({ fileCount, owedFiles }) => - updateMessage(`Uploading ${owedFiles} of ${fileCount} files`), - onUpload: ({ uploadedFiles, totalFiles }) => - updateMessage(`Uploaded ${uploadedFiles} of ${totalFiles} files`), - }, - }, - ), + }), { successMessage: "Version created", errorMessage: "Create version failed", diff --git a/packages/cli/src/cli/commands/versions/deploy.ts b/packages/cli/src/cli/commands/versions/deploy.ts index 67dac49d..a1db89c7 100644 --- a/packages/cli/src/cli/commands/versions/deploy.ts +++ b/packages/cli/src/cli/commands/versions/deploy.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import type { Command } from "commander"; +import { targetOption } from "@/cli/commands/versions/options.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { @@ -39,6 +40,6 @@ export function getVersionDeployCommand(): Command { "Point an environment at an already-recorded version (also the rollback)", ) .argument("", "The version to serve") - .option("--target ", "Environment to point at it") + .addOption(targetOption()) .action(deployAction); } diff --git a/packages/cli/src/core/site/git-hash.ts b/packages/cli/src/core/site/git-hash.ts index 84778c67..d4fe4d05 100644 --- a/packages/cli/src/core/site/git-hash.ts +++ b/packages/cli/src/core/site/git-hash.ts @@ -26,7 +26,7 @@ export async function resolveGitHash( } /** - * The commit this build came from, or `null` when there is none. + * The commit this build came from, or `undefined` when there is none. * * For a version the commit is PROVENANCE — recorded, never hashed, and not part * of what the version is — so a build outside a checkout is still a complete diff --git a/packages/cli/src/core/site/manifest.ts b/packages/cli/src/core/site/manifest.ts index fd79775e..608663cf 100644 --- a/packages/cli/src/core/site/manifest.ts +++ b/packages/cli/src/core/site/manifest.ts @@ -61,11 +61,6 @@ function getAssetContentType(filePath: string): string { ); } -/** - * 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. - */ /** * Every file a build emitted, sorted. One rule for both lanes: `.assetsignore` * with full gitignore semantics, plus the names no build ever ships. @@ -92,7 +87,7 @@ interface BuildFile { size: number; } -/** Open descriptors while walking. Well under the 256 a production Node keeps. */ +/** Bounded so a large build cannot flood the libuv thread pool. */ const STAT_CONCURRENCY = 32; /** @@ -124,6 +119,11 @@ export async function hashFileInto( return hash; } +/** + * 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") .update(Buffer.from(appId, "utf8")) diff --git a/packages/cli/src/core/version/api.ts b/packages/cli/src/core/version/api.ts index adb84dc0..59a9a3fb 100644 --- a/packages/cli/src/core/version/api.ts +++ b/packages/cli/src/core/version/api.ts @@ -115,16 +115,23 @@ export async function createVersion( ...(artifacts.siteWorker?.assets ?? []), ]; - options.progress?.onDeclared?.({ - fileCount: declaredFiles.length, - owedFiles: declared.uploads.length, - }); + options.progress?.onDeclared?.({ fileCount: declaredFiles.length }); if (declared.uploads.length !== declaredFiles.length) { throw new InternalError( `Declared ${declaredFiles.length} files but the server signed ${declared.uploads.length} upload URLs.`, ); } + // Necessary, not sufficient — but it catches an order drift here rather than + // as an S3 checksum rejection part way through the uploads. + const drifted = declared.uploads.findIndex( + (upload, index) => upload.path !== declaredFiles[index].path, + ); + if (drifted !== -1) { + throw new InternalError( + `Upload ${drifted} is signed for ${declared.uploads[drifted].path}, but that slot declared ${declaredFiles[drifted].path}.`, + ); + } let uploadedFiles = 0; await pMap( diff --git a/packages/cli/src/core/version/artifacts.ts b/packages/cli/src/core/version/artifacts.ts index 6ad252c6..5de76b6e 100644 --- a/packages/cli/src/core/version/artifacts.ts +++ b/packages/cli/src/core/version/artifacts.ts @@ -7,6 +7,8 @@ import { InvalidInputError } from "@/core/errors.js"; import { resolveFullStackBuild } from "@/core/site/full-stack.js"; import { describeBuildOutput, hashFileInto } from "@/core/site/manifest.js"; import { pathExists, readJsonFile } from "@/core/utils/fs.js"; +import type { PublishTarget } from "@/core/version/project.js"; +import { requireOutputDir } from "@/core/version/project.js"; import type { ArtifactFile, ArtifactSet, @@ -16,7 +18,7 @@ import type { /** Must match the server's ceiling: declaring more only earns a late rejection. */ const MAX_FILE_COUNT = 50_000; -/** Open descriptors while hashing. Well under the 256 a production Node keeps. */ +/** One open descriptor per file in flight; an unbounded fan-out hits EMFILE. */ const HASH_CONCURRENCY = 32; /** Served for any unmatched path — but only when the platform is what serves. */ @@ -142,3 +144,21 @@ export async function collectResources( ]); return { entities, agents }; } + +/** + * Everything one build produced, ready to declare. + * + * One reader, so `publish` and `versions create` cannot disagree about what a + * build left behind — a second would eventually declare a Worker's own files as + * a static bundle, which is the one thing the platform reads as "serve from S3". + */ +export async function collectArtifacts( + target: PublishTarget, +): Promise { + const siteWorker = await collectSiteWorker(target.root); + return { + files: siteWorker ? [] : await collectBuildOutput(requireOutputDir(target)), + ...(siteWorker ? { siteWorker } : {}), + ...(await collectResources(target.configDir, target)), + }; +} diff --git a/packages/cli/src/core/version/project.ts b/packages/cli/src/core/version/project.ts index f9c8acad..6f41689d 100644 --- a/packages/cli/src/core/version/project.ts +++ b/packages/cli/src/core/version/project.ts @@ -8,7 +8,7 @@ import type { ProjectWithPaths } from "@/core/project/types.js"; const DEFAULT_BUILD_COMMAND = "npm run build"; const DEFAULT_OUTPUT_DIRECTORY = "dist"; -interface PublishTarget { +export interface PublishTarget { root: string; /** Where `entitiesDir` and `agentsDir` are resolved from. */ configDir: string; diff --git a/packages/cli/src/core/version/publish.ts b/packages/cli/src/core/version/publish.ts index a170d9be..d4ca39d4 100644 --- a/packages/cli/src/core/version/publish.ts +++ b/packages/cli/src/core/version/publish.ts @@ -28,7 +28,14 @@ export async function tagStep( // too — `run().catch(...)` would let that one escape untagged. return await run(); } catch (error) { - if (error !== null && typeof error === "object" && !(STEP in error)) { + // `isExtensible` too: a library that freezes its errors would turn the + // tag into a TypeError and lose the original entirely. + if ( + error !== null && + typeof error === "object" && + !(STEP in error) && + Object.isExtensible(error) + ) { Object.defineProperty(error, STEP, { value: step, enumerable: false }); } throw error; diff --git a/packages/cli/src/core/version/schema.ts b/packages/cli/src/core/version/schema.ts index 4eb8f322..27ecfbb6 100644 --- a/packages/cli/src/core/version/schema.ts +++ b/packages/cli/src/core/version/schema.ts @@ -43,7 +43,7 @@ export interface ArtifactSet { } export interface CreateVersionProgress { - onDeclared?: (info: { fileCount: number; owedFiles: number }) => void; + onDeclared?: (info: { fileCount: number }) => void; onUpload?: (progress: { uploadedFiles: number; totalFiles: number }) => void; } diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 93ce8e25..9d05abd6 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -853,7 +853,7 @@ export class TestAPIServer { /** Captured JSON bodies of POST versions (declare) requests. */ readonly versionDeclareRequests: unknown[] = []; - /** Captured JSON bodies of POST versions/{id}/deployments requests. */ + /** Captured JSON bodies of PATCH environments/{name} requests. */ readonly versionDeployRequests: unknown[] = []; /** Captured environment names the PATCH addressed. */ readonly environmentNames: string[] = []; diff --git a/packages/cli/tests/core/version-artifacts.spec.ts b/packages/cli/tests/core/version-artifacts.spec.ts index 69dd60e0..3ce7ff9d 100644 --- a/packages/cli/tests/core/version-artifacts.spec.ts +++ b/packages/cli/tests/core/version-artifacts.spec.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { InvalidInputError } from "@/core/errors.js"; import { hashAsset } from "@/core/site/manifest.js"; import { + collectArtifacts, collectBuildOutput, collectResources, collectSiteWorker, @@ -306,3 +307,73 @@ describe("who serves the frontend decides what it must contain", () => { expect(files.map((f) => f.path)).toEqual(["app.js"]); }); }); + +describe("collectArtifacts", () => { + let projectRoot: string; + + async function fullStackProject(): Promise { + const dist = join(projectRoot, "dist"); + await mkdir(join(dist, "client"), { recursive: true }); + await writeFile(join(dist, "client", "index.html"), "

Hi

\n"); + await writeFile(join(dist, "index.js"), "export default {};"); + await writeFile( + join(dist, "wrangler.json"), + JSON.stringify({ + main: "index.js", + no_bundle: true, + rules: [{ type: "ESModule", globs: ["**/*.js"] }], + assets: { directory: "./client" }, + }), + ); + await mkdir(join(projectRoot, ".wrangler", "deploy"), { recursive: true }); + await writeFile( + join(projectRoot, ".wrangler", "deploy", "config.json"), + JSON.stringify({ configPath: "../../dist/wrangler.json" }), + ); + } + + function target() { + return { + root: projectRoot, + configDir: join(projectRoot, "base44"), + outputDir: join(projectRoot, "dist", "client"), + entitiesDir: "entities", + agentsDir: "agents", + }; + } + + beforeEach(async () => { + projectRoot = await mkdtemp(join(tmpdir(), "b44-collect-")); + }); + + afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }); + }); + + it("never declares a Worker's own files as a static bundle", async () => { + // Every command collects through here for this reason: a second collector + // would eventually name them in `files`, which the platform reads as + // "serve from S3" — past every route the Worker owns. + await fullStackProject(); + + const artifacts = await collectArtifacts(target()); + + expect(artifacts.files).toEqual([]); + expect(artifacts.siteWorker?.assets.map((f) => f.path)).toEqual([ + "index.html", + ]); + }); + + it("declares a static bundle when no Worker built", async () => { + await mkdir(join(projectRoot, "dist", "client"), { recursive: true }); + await writeFile( + join(projectRoot, "dist", "client", "index.html"), + "

Hi

\n", + ); + + const artifacts = await collectArtifacts(target()); + + expect(artifacts.files.map((f) => f.path)).toEqual(["index.html"]); + expect(artifacts.siteWorker).toBeUndefined(); + }); +}); diff --git a/packages/cli/tests/core/version-publish.spec.ts b/packages/cli/tests/core/version-publish.spec.ts index 0a054000..06814155 100644 --- a/packages/cli/tests/core/version-publish.spec.ts +++ b/packages/cli/tests/core/version-publish.spec.ts @@ -50,3 +50,16 @@ describe("which step a publish broke in", () => { expect(stepOf(new Error("unrelated"))).toBeUndefined(); }); }); + +describe("an error the step cannot be attached to", () => { + it("survives a frozen error rather than replacing it", async () => { + // A library that freezes its errors would otherwise turn the tag into a + // TypeError, losing the message, status and request id entirely. + const frozen = Object.freeze(new ApiError("frozen", { statusCode: 418 })); + + await expect(tagStep("deploy", () => Promise.reject(frozen))).rejects.toBe( + frozen, + ); + expect(stepOf(frozen)).toBeUndefined(); + }); +});