Conversation
ff1d672 to
9b9e94e
Compare
🚀 Package Preview Available!Install this PR's preview build with npm: npm i @base44-preview/cli@0.1.15-pr.626.da7a7d8Prefer not to change any import paths? Install using npm alias so your code still imports npm i "base44@npm:@base44-preview/cli@0.1.15-pr.626.da7a7d8"Or add it to your {
"dependencies": {
"base44": "npm:@base44-preview/cli@0.1.15-pr.626.da7a7d8"
}
}
Preview published to npm registry — try new features instantly! |
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 <id> 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
9b9e94e to
c473ba9
Compare
`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) <noreply@anthropic.com>
`--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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
**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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
| entities: artifacts.entities, | ||
| agents: artifacts.agents, | ||
| source_commit: options.sourceCommit, | ||
| frontend_commit: options.sourceCommit, |
There was a problem hiding this comment.
why both?
and source_commit should be optional (for BaaS)
There was a problem hiding this comment.
Done in fe5c313. frontend_commit is gone from the wire, the session, the contract and the log — a version describes an app at a source, and its frontend and backend are that same app. build still refuses two inputs naming different commits, which is what earns the single field. And source_commit was already optional server-side (str | None); it stays that way, so a build outside a checkout is still a complete version.
| DeployVersionResponseSchema, | ||
| await ( | ||
| await post( | ||
| `versions/${encodeURIComponent(versionId)}/deployments`, |
There was a problem hiding this comment.
let's make it an API on an Environment object
PATCH /environments/
{ version: ... }
There was a problem hiding this comment.
Done in fe5c313 — agreed, this is the truer model. PATCH /environments/{name} { version_id } replaces POST /versions/{id}/deployments, on its own router rather than under versions, since an environment outlives every version it has pointed at. The Deployment record still exists internally (it is what the conditional pointer write points at) and comes back in the response so a log line can be correlated — but nobody asks for one.
| @@ -0,0 +1,146 @@ | |||
| import { createHash } from "node:crypto"; | |||
There was a problem hiding this comment.
check how to reduce duplication here with the deployments api code
There was a problem hiding this comment.
Done in 3fddea1. It was worse than similar: my collector had its own copy of the globby call, the .assetsignore handling, the ALWAYS_IGNORED set and even the comment about globby's ignore/ignoreFiles footgun. Two collectors disagreeing would mean the two lanes publish different sets from the same directory. walkBuildOutput in core/site/manifest.ts is now the one rule; both callers use it and do their own per-file work. The hashes stay separate deliberately — hashAsset is a salted, truncated provider cache key, digestFile is full sha256 artifact identity.
There was a problem hiding this comment.
Done in 3fddea1 — my mistake, those were local review artifacts. Untracked and added to .gitignore.
**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) <noreply@anthropic.com>
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 <id>` 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Note
Description
Adds a versions lane to the CLI: a version is one immutable record of everything a build produced — every frontend file, the app's own Worker when the framework built one, and the raw entity and agent payloads the app declares — and a deployment is that version made live at an environment. Recording and serving are separate acts, which makes a rollback a deploy of an older version rather than a second code path. The lane ships as
base44 publish,base44 versions createandbase44 versions deploy <version-id>, all gated behindBASE44_VERSIONS_APIso they are not registered at all until the platform side is ready. It exists to replace the python driver the platform's build sandbox runs today — which overwrotebase44/config.jsoncwith a minimal config before building, destroying checked-in configuration.Related Issue
None
Type of Change
Changes Made
New
src/core/version/moduleapi.ts— the three HTTP calls, in order:POST versions(declare the artifact set, get asession_idand one presigned PUT per file) → upload →POST versions/{session_id}/finalize. Nothing is recorded until the bytes are in place, so an interrupt leaves staged objects that expire rather than a version naming files that are not there. Uploads pair with declared files by position — the frontend then the Worker's modules, the order the server signed them in — because the two sets are separate namespaces and the same path can appear in both.setEnvironmentVersionis onePATCH /environments/{name}with an idempotency key: an environment serves one version, so making a version live is editing that pointer.artifacts.ts— walks the build output and streams a fullsha256:digest per file (bounded at 32 open descriptors);collectSiteWorkerdetects a full-stack build and describes its modules the same way, reusing the existing full-stack deploy lane'sdetectFullStackArtifact/resolveWranglerConfig/collectModuleswhole rather than reading the framework's output a second time; and readsentities//agents/raw, keyed the way the platform names them (agents/support/triage.jsonc→support/triage). Deliberately not the validated resource readers: the CLI's stricter entity schema refuses real Builder apps. Refuses an empty set, a set with noindex.html, and more thanMAX_FILE_COUNT(50 000, matching the server's ceiling).publish.ts— orchestration, plustagStep/stepOf: attachesbuild/create_version/deployto a failure through a non-enumerable symbol, leaving the original error type, message, status and request id intact.Base44Commandwrites it into the--jsonerror envelope asstep.project.ts—resolvePublishTargetfills innpm run buildanddistonly when a repo has no config at all, and writes nothing. A config that is present wins field by field; one that omits a field still gets today's error.requireOutputDirraises at collection, not resolution.gate.ts—versionsApiEnabled(), read in exactly one place (program.ts). Separate fromBASE44_DEPLOYMENTS_APIso the two lanes can roll out apart.schema.ts— Zod wire schemas with snake_case→camelCase transforms; a response mismatch raisesSchemaValidationErrornaming the field.Full-stack apps — when
.wrangler/deploy/config.jsonis present, the version declares asite_worker(entry module, module set with digests,compatibility_date,compatibility_flags) alongside the frontend, and the frontend is taken from the Worker's ownassets.directoryrather than the project'ssite.outputDirectory.mainis sent as the module set names it, resolved by identity, so a surviving./cannot name a module the set does not provide. Nothing deploys this yet — the platform stores it and still refuses a full-stack app at admission — so this is the transport, sent and stored.New commands (
publish.ts,versions/{create,deploy,index,options}.ts) — shared flags (--output-dir,--target,--git-hash,--concurrency) built in one place so two--helpscreens cannot describe the same flag differently.--concurrencydefaults to 8 and caps at 16;--jsonemits a single{versionId, manifestHash, deploymentId}document viaRunCommandResult.stdout.Shared with the existing site lane
walkBuildOutput()frombuildAssetManifestso both lanes apply one rule for what a build produced (.assetsignoresemantics + always-ignored names).putPresigned()fromuploadPresignedAssetso both lanes share one PUT while each decides which file a signed URL is for (the site lane by path, the version lane by declared order). It forwards the server'sx-amz-checksum-sha256verbatim when present, which is what makes S3 itself reject a body other than the declared one.AssetFile.contentTypebecame optional — only the cf arm reads it, the s3 arm echoes what the server signed.resolveProvenanceCommit(): returnsundefinedoutside a checkout, because a version is identified by its content and the commit is recorded beside it, never hashed.base44 build— no longer requires a credential (it calls no API, and the sandbox builds before minting a key that can deploy) and no longer requires a linked CLI config; it resolves throughresolvePublishTarget. What it builds and prints is unchanged.Docs — new
docs/versions.md(linked fromdocs/AGENTS.md) covering the two-hashes distinction, the declare→upload→finalize order, why the digest is signed into the URL, the full-stack Worker, raw resource payloads, the measured concurrency and file-count bounds, the env gate, and the sandbox's two-exec invocation.Testing
npm test)4 new spec files plus additions to
build.spec.ts(~785 lines): end-to-end publish coverage (digests declared, resources sent raw, checksum header forwarded, environment named in the path and version in the body,--jsonenvelope, step tagging,--no-build), a full-stack app (Worker declared in the same version, frontend taken from the Worker's assets directory, each declared file's own bytes at the URL signed for it), gate on/off/--help, artifact collection (nested paths,.assetsignore, descriptor exhaustion on a large set, missing entry point, module set vs. assets), publish-target resolution (including "writes nothing" and "leaves a checked-in config exactly as it was"), andtagStepsemantics. Testkit gainedPATCHrouting,mockVersionDeclare/mockVersionFinalize/mockEnvironmentSet/mockVersionDeclareErrorwith request capture, and apublishablefixture. Unchecked boxes are not claims of failure — this description was generated in an environment that could not execute the suite; CI is the source of truth.Checklist
docs/(AGENTS.md) if I made architectural changesAdditional Notes
The lane is inert until
BASE44_VERSIONS_API=1, so nothing here is reachable by a user on this release. Three things worth a reviewer's attention: thebase44 buildauth relaxation is the one change visible outside the gate;AssetFile.contentTypebecoming optional and theputPresignedextraction touch the existing site deploy lane; and the full-stack Worker declaration is transport only, with no platform arm behind it yet. The.gitignoreadditions (pr-review-*.md/.html) are local review scratch files.🤖 Generated by Claude | 2026-09-16 11:08 UTC | 451886c