From 440ac7e5e8876b54345682f23dde271889682c40 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Mon, 29 Jun 2026 23:27:40 -0400 Subject: [PATCH 1/3] homebrew: port sqlite bzip2 xz pilot --- docs/homebrew-publishing.md | 32 + ...9-homebrew-sqlite-bzip2-xz-pilot-design.md | 545 +++++++++++++++ homebrew/kandelo-homebrew/Formula/bzip2.rb | 68 ++ homebrew/kandelo-homebrew/Formula/sqlite.rb | 89 +++ homebrew/kandelo-homebrew/Formula/xz.rb | 68 ++ packages/registry/bzip2/build-bzip2.sh | 86 ++- packages/registry/sqlite/build-sqlite.sh | 53 +- packages/registry/xz/build-xz.sh | 97 ++- scripts/homebrew-bottle-build.sh | 18 +- .../homebrew-generate-sidecars-from-env.sh | 165 +++-- scripts/homebrew-package-node-smoke.ts | 644 ++++++++++++++++++ 11 files changed, 1734 insertions(+), 131 deletions(-) create mode 100644 docs/plans/2026-06-29-homebrew-sqlite-bzip2-xz-pilot-design.md create mode 100644 homebrew/kandelo-homebrew/Formula/bzip2.rb create mode 100644 homebrew/kandelo-homebrew/Formula/sqlite.rb create mode 100644 homebrew/kandelo-homebrew/Formula/xz.rb create mode 100644 scripts/homebrew-package-node-smoke.ts diff --git a/docs/homebrew-publishing.md b/docs/homebrew-publishing.md index d14760066e..c1e180d9e6 100644 --- a/docs/homebrew-publishing.md +++ b/docs/homebrew-publishing.md @@ -215,6 +215,18 @@ path calls `scripts/homebrew-publish-sidecars.sh --status failed` so the failed attempt is durable while the previous successful bottle remains selectable when its fallback fields are complete. +The default trusted sidecar wrapper, +`scripts/homebrew-generate-sidecars-from-env.sh`, derives non-hello link +manifests from package kind. Program Formulae link their installed +`bin/` into the Homebrew prefix. Library Formulae link the declared +`[outputs]` headers, static libraries, and pkg-config files from the keg into +the prefix. Package-specific Node and browser outcome text may be supplied via +`KANDELO_HOMEBREW_NODE_SMOKE_COMMAND`, +`KANDELO_HOMEBREW_BROWSER_SMOKE_STATUS`, and +`KANDELO_HOMEBREW_BROWSER_SMOKE_REASON`; browser compatibility is recorded +only when `KANDELO_HOMEBREW_BROWSER_SMOKE_STATUS=success` and the browser VFS +smoke artifact environment is complete. + ## VFS Planning And Building Homebrew-derived VFS images are built from sidecars and verified bottle bytes, @@ -265,6 +277,26 @@ It clones or reads the tap, builds a Homebrew VFS from published sidecars, runs `/home/linuxbrew/.linuxbrew/bin/hello --version` through `NodeKernelHost`, and checks negative ABI-mismatch and missing-bottle cases. +For the sqlite/bzip2/xz pilot and later non-hello package checks, use the +generic package smoke runner against a generated tap root: + +```bash +npx tsx scripts/homebrew-package-node-smoke.ts \ + --tap-root /path/to/kandelo-homebrew \ + --formula sqlite \ + --formula bzip2 \ + --formula xz \ + --arch wasm32 \ + --result-dir test-runs/homebrew-package-node-smoke +``` + +The runner builds Homebrew VFS images from sidecars, writes passed, failed, +and skipped outcome lists, runs program package version smokes from the poured +prefix, and compiles SQLite's `sqlite_basic.c` against the poured headers and +static library before running the validation Wasm on Node. Dry-run bottle +evidence remains local evidence until the trusted workflow publishes GHCR +bottle bytes and tap sidecars. + Browser compatibility requires a separate browser smoke. For the current `hello` path, the trusted publisher builds a precomposed wasm32 VFS image, serves it through the browser demo, runs Chromium Playwright against diff --git a/docs/plans/2026-06-29-homebrew-sqlite-bzip2-xz-pilot-design.md b/docs/plans/2026-06-29-homebrew-sqlite-bzip2-xz-pilot-design.md new file mode 100644 index 0000000000..68e3cad4c4 --- /dev/null +++ b/docs/plans/2026-06-29-homebrew-sqlite-bzip2-xz-pilot-design.md @@ -0,0 +1,545 @@ +# Homebrew SQLite, Bzip2, And Xz Pilot Design + +Date: 2026-06-29 + +Tracked work: + +- `kd-1mr` - Port all current Kandelo packages to Homebrew. +- `kd-1mr.2` - Port sqlite, bzip2, and xz Homebrew pilot. +- Source planning evidence: `kd-5yd`, commit `b6cd51d8c`, refreshed the + Homebrew package inventory and selected this pilot after the trusted + `hello` publication path and local `zlib` wasm32/wasm64 proof. + +This is a design artifact for the pilot. It does not implement the Formulae, +publish bottles, change registry package bytes, or update release metadata. + +## Problem Statement + +Kandelo's Homebrew foundation can publish and smoke the first `hello` bottle, +and the follow-up `zlib` proof showed that a dependency-root library can be +bottled locally for wasm32 and wasm64. The next migration step needs a small +pilot that exercises more of the future registry-replacement model without +starting a broad package wave. + +The pilot packages are deliberately mixed: + +- `sqlite` is the next dependency-root library. It declares wasm32 and wasm64, + has real downstream consumers, and has existing upstream SQLite test harnesses + whose status should be visible without making full upstream success the + default bottle gate. +- `bzip2` and `xz` are small leaf CLI packages. They exercise program Formulae, + link manifests, VFS pour/link behavior, and Node/browser smoke commands + without the capacity risk of heavy runtimes. + +The design must preserve the current Homebrew direction: + +- Formulae become authoritative for source, dependency, build, install, and + `test do` behavior. +- Kandelo sidecars remain additive metadata for ABI, cache keys, provenance, + VFS planning, host support, browser gallery status, and test outcomes. +- Package failures remain visible with reasons and artifacts. +- Node and browser hosts are both product surfaces. A missing browser result is + status to publish, not proof of browser support. + +## Non-Goals + +- Do not port the full dependency-root wave or the full small-CLI wave. +- Do not delete or rename `packages/registry` in this pilot. +- Do not revive `sqlite-cli` as a product Formula unless the pilot proves it is + necessary and a focused follow-up is created. +- Do not make upstream SQLite full-suite success a default bottle publication + gate. +- Do not use Formula patches or test skips to hide Kandelo runtime, libc, + syscall, VFS, fork-instrumentation, or host-parity defects. +- Do not publish user-facing guest `brew install` instructions from this work. +- Do not bump `packages/registry/*/build.toml` revisions unless package output + bytes legitimately change. + +## Users And Operator Workflows + +### Package Porter + +The porter authors or regenerates `Formula/sqlite.rb`, `Formula/bzip2.rb`, and +`Formula/xz.rb` in `Automattic/kandelo-homebrew` or the reviewable tap fixture. +They reuse existing package build knowledge, but the Formula DSL owns the +final source, build, install, and test behavior. The existing registry scripts +may be called only where they already honor the resolver-style output contract +or after the pilot makes their side effects explicit. + +### Maintainer Reviewer + +The reviewer checks that the Formulae are normal Homebrew Formulae with the +minimum Kandelo-specific environment wiring, that sidecars describe the bottles +truthfully, that dry-run/local evidence is not presented as trusted +publication, and that package failures are recorded instead of disappearing. + +### Trusted Publisher + +The trusted workflow builds each selected `(formula, arch)` entry, uploads the +bottle to the GHCR-backed Homebrew bottle URL shape, generates sidecars and +provenance, validates the tap payload, then publishes success or failure state. +The workflow must keep failure reports durable without replacing last-green +metadata. + +### Runtime Validator + +The validator materializes Homebrew bottles into VFS images and runs package +smoke commands on both Node and browser hosts. For program packages, the smoke +executes the installed program. For library packages, the smoke compiles or +ships a test-only consumer and runs that consumer against the poured library. +The test consumer is validation evidence, not a product bottle output. + +### Debugger + +When a package fails, the debugger needs enough metadata to classify the fault: +source fetch, Formula generation, cross-compile configure answer, build, +install/link, bottle upload, sidecar generation, VFS pour/link, Node runtime, +browser runtime, upstream test failure, or Kandelo platform behavior. + +## Existing Package Facts + +`sqlite`: + +- `packages/registry/sqlite/package.toml` is a library manifest for SQLite + `3.49.1`, declares `arches = ["wasm32", "wasm64"]`, and outputs + `lib/libsqlite3.a`, `include/sqlite3.h`, `include/sqlite3ext.h`, and + `lib/pkgconfig/sqlite3.pc`. +- `build-sqlite.sh` is resolver-shaped for the library path when + `WASM_POSIX_DEP_OUT_DIR` is set, but it also has a legacy direct-invocation + CLI path. Formulae should not rely on that legacy CLI path for the library + bottle. +- Existing SQLite test tooling includes SQL fixture tests and official + testrunner wrappers for Node and browser. Those are upstream-test status + evidence, not the default bottle availability gate. + +`bzip2`: + +- `packages/registry/bzip2/package.toml` is a wasm32 program manifest for + `1.0.8`. +- `build-bzip2.sh` is an older direct script. It writes `bin/bzip2.wasm`, + installs `libbz2.a` and `bzlib.h` to the repo sysroot, and registers a local + binary. A Formula must not silently rely on those sysroot/local-binary side + effects. +- Existing Vitest coverage checks `bzip2 --version`; round-trip compression is + not covered there. + +`xz`: + +- `packages/registry/xz/package.toml` says version `5.6.2`, while + `build-xz.sh` currently defaults to `5.6.4` and downloads a `.tar.gz` from + the GitHub release path. This version/source mismatch is a pilot blocker + unless the Formula pins one source and the build path is made consistent. +- `build-xz.sh` is an older direct script. It writes `bin/xz.wasm`, installs + `liblzma.a` and headers to the repo sysroot, and registers a local binary. + A Formula should install only the intended bottle contents into the Homebrew + keg. +- Existing Vitest coverage checks `xz --version`; round-trip compression is + not covered there. + +Cross-cutting: + +- Current package manifests have stale `kernel_abi` values. The Homebrew + sidecar path must use live `ABI_VERSION` and computed `cache_key_sha` + evidence. The pilot should not treat stale manifest ABI fields as bottle + compatibility truth. +- `scripts/homebrew-generate-sidecars-from-env.sh` is currently heavily shaped + around the `hello` validation path. The pilot needs a generic sidecar input + path for package-specific validation outcomes, or it must publish non-hello + failure/deferred status with a focused follow-up. + +## Architecture And Data Flow + +The pilot should use the same publication architecture as the existing +Homebrew path: + +```text +Formula/sqlite.rb, Formula/bzip2.rb, Formula/xz.rb + | + v +trusted workflow matrix + scripts/homebrew-plan-matrix.sh + | + v +scripts/homebrew-bottle-build.sh + brew install --build-bottle + brew test + brew bottle --json + brew bottle --merge + | + v +scripts/homebrew-ghcr-upload.sh + | + v +generic sidecar input + cache_key_sha, ABI, bottle URL, sha, bytes + formula revision, bottle rebuild + build/test/node/browser/upstream outcome lists + | + v +cargo xtask homebrew-sidecars +cargo xtask homebrew-validate + | + v +scripts/homebrew-publish-sidecars.sh + success or durable failed attempt + | + v +Homebrew VFS builder and Node/browser smoke +``` + +Control-flow invariants: + +- Formula `test do` must execute the Wasm through Kandelo, not as a host Linux + binary. +- Bottle bytes, formula bottle blocks, sidecars, and provenance must be + generated from the same build attempt. +- `cache_key_sha` must be computed for the Formula's Kandelo package identity + and target arch. A bottle with a wrong cache key is stale even if Homebrew + version selection would accept it. +- `sqlite` wasm64 publication can be attempted only if the Formula and package + build path really honor `HOMEBREW_KANDELO_ARCH=wasm64`. `bzip2` and `xz` + stay wasm32 unless their manifests and build paths are intentionally expanded. +- Browser compatibility can be recorded only after a browser smoke consumes a + precomposed VFS image and runs the package or package-specific consumer + through the normal browser host. +- Complete upstream-test outcome artifacts are package status metadata. They + do not decide default bottle availability unless the implementation + explicitly adds such a gate for one package. + +## Formula Shape + +### Shared Formula Pattern + +Each Formula should: + +1. Read `HOMEBREW_KANDELO_ROOT`, `HOMEBREW_KANDELO_ARCH`, + `HOMEBREW_KANDELO_NODE`, and `HOMEBREW_KANDELO_LLVM_BIN`. +2. Source or route through the worktree-local SDK by prepending + `/sdk/bin`. +3. Set `WASM_POSIX_DEP_VERSION`, `WASM_POSIX_DEP_SOURCE_URL`, + `WASM_POSIX_DEP_SOURCE_SHA256`, `WASM_POSIX_DEP_OUT_DIR`, + `WASM_POSIX_DEP_WORK_DIR`, and `WASM_POSIX_DEP_TARGET_ARCH`. +4. Install only the intended artifacts from the package output dir into the + Homebrew keg. +5. Keep `test do` small, deterministic, and runtime-backed by Kandelo. + +### SQLite Formula + +`sqlite` should be modeled as a library Formula, not as the incomplete +`sqlite-cli` package. + +Bottle contents: + +- `lib/libsqlite3.a` +- `include/sqlite3.h` +- `include/sqlite3ext.h` +- `lib/pkgconfig/sqlite3.pc` + +Formula `test do` should compile `packages/registry/sqlite/test/sqlite_basic.c` +or an equivalent inline test program against the installed keg and run the +result through Kandelo. This proves the installed library can be consumed and +executed without shipping a product CLI in the bottle. + +Sidecar link manifest should include library, header, and pkg-config paths +under the Homebrew prefix, even though no executable link is produced. The VFS +builder should either support library-only bottles directly or the pilot should +record the missing library-only VFS link behavior as a blocker before claiming +sqlite browser compatibility. + +### Bzip2 Formula + +`bzip2` should be a program Formula that installs `bin/bzip2` from the produced +Wasm file. The pilot should decide whether library byproducts are intentionally +part of the bottle: + +- Minimal program-only path: install `bin/bzip2` only. This best matches the + current `[[outputs]]` program manifest. +- Expanded hybrid path: install `bin/bzip2`, `lib/libbz2.a`, and `include/bzlib.h`. + This requires updating the package/Formula contract and treating the library + outputs as deliberate bottle contents. + +The minimal path is recommended for this pilot unless a downstream consumer +needs `libbz2.a` immediately. + +Formula `test do` should run `bzip2 --version` through Kandelo and, if feasible, +perform a file-based round trip that avoids writing compressed bytes to a PTY: +create an input file, compress to an output file, decompress, and compare the +content inside the Kandelo VFS. + +### Xz Formula + +`xz` should be a program Formula that installs `bin/xz` from the produced Wasm +file. Before publication, resolve the current package/source mismatch: + +- either keep package version `5.6.2` and make the build script use the + manifest URL and sha through the resolver-style env vars; +- or intentionally update the package and Formula to `5.6.4` with correct + source URL, sha, revision reasoning, and build-output validation. + +The first option is safer for the pilot because it avoids changing package +output identity beyond the Homebrew path. + +Formula `test do` should run `xz --version` through Kandelo and, if feasible, +perform a file-based compress/decompress round trip similar to `bzip2`. + +## Sidecars And Outcome Metadata + +The pilot needs generic sidecar generation before it can report truthful +package status for these packages. `scripts/homebrew-generate-sidecars-from-env.sh` +currently emits `hello`-specific outcome text and hardcoded browser/gallery +behavior. For this pilot, introduce or use a sidecar input file that records: + +- formula and arch; +- package kind: `library` or `program`; +- selected smoke command or test-consumer command; +- bottle build/install result; +- Formula test result; +- sidecar validation result; +- Node VFS smoke result; +- browser VFS smoke result or skip/failure reason; +- upstream test status for sqlite; +- artifact paths for logs, reports, VFS builder reports, and outcome lists. + +The generated sidecars should preserve the existing schema invariants while +allowing each package to report package-specific outcome lists. If the generic +input path cannot land cleanly inside this pilot, publish the package as +`failed`, `pending`, or `deferred` with a durable reason rather than reusing +misleading `hello` validation text. + +## Node And Browser Smoke Strategy + +Program packages: + +- Build a Homebrew VFS from the selected bottle and sidecars. +- On Node, spawn `/home/linuxbrew/.linuxbrew/bin/bzip2` or + `/home/linuxbrew/.linuxbrew/bin/xz` through `NodeKernelHost`. +- On browser, boot the same style of precomposed VFS image and run the + executable through the browser terminal. +- Prefer a file round-trip smoke for final compatibility status. Version-only + smoke may be recorded as partial if the file round trip is blocked by shell, + PTY, or VFS-image limitations. + +SQLite: + +- Build a Homebrew VFS from the sqlite bottle and sidecars. +- Compile a test-only `sqlite_basic.wasm` consumer against the poured keg, or + include a separately built validation artifact in the smoke image with + provenance that names the source and compiler inputs. +- On Node, run the consumer with `NodeKernelHost`. +- On browser, boot a precomposed smoke image that includes the sqlite keg plus + the test consumer and run the consumer through the browser terminal. +- Mark sqlite browser compatibility only if the consumer runs in the browser + host. A library-only bottle build does not prove browser runtime support. + +Negative smoke should remain part of the reusable harness: + +- ABI mismatch rejects before bottle fetch. +- Missing bottle or sha mismatch rejects before image save. +- Cache-key mismatch rejects before compatibility is recorded. + +## Upstream Test Status + +SQLite is the only pilot package with an explicit upstream-test-status goal. + +Existing runners: + +- `scripts/run-sqlite-tests.sh` runs SQL fixture files through `sqlite3.wasm`. +- `scripts/run-sqlite-upstream-tests.sh` runs individual upstream Tcl tests. +- `scripts/run-sqlite-official-tests.sh` runs SQLite `testrunner.tcl` on Node + or delegates to the browser runner. +- `scripts/run-sqlite-project-unit-tests.sh` combines Node and browser + official-test runs and writes summaries. + +The pilot should not require the full SQLite suite to pass before bottle +availability. It should publish upstream status with: + +- permutation (`veryquick`, `full`, or `all`); +- host (`node`, `browser`, or both); +- total, passed, failed, skipped/omitted, running, ready, timeout, and + incomplete counts when available; +- complete failure list with job/test name, state, case count, error count, + elapsed time, and artifact path; +- complete skipped/omitted list with reasons when the harness exposes them; +- explicit "missing category" notes where current harnesses cannot emit a + required list, plus a focused follow-up before the convoy claims complete + upstream outcome-list support. + +`bzip2` and `xz` should record upstream test status as `unavailable` or +`deferred` with a reason if this pilot only runs smoke tests. Do not imply that +version/round-trip smoke is an upstream full-test result. + +## Implementation Sequence + +1. Confirm the worktree is clean and preflighted for `kd-1mr.2`. +2. Bring the worktree to the intended Homebrew foundation commit if the branch + is missing required `hello`, VFS builder, or sidecar work. +3. Add or update Formulae in the tap fixture or tap worktree for `sqlite`, + `bzip2`, and `xz`. +4. Make the package build paths Formula-safe: + - `sqlite`: use the resolver-style library output path and avoid the legacy + CLI side path. + - `bzip2`: prevent repo sysroot/local-binary side effects from being part + of Formula installation, or isolate them behind `WASM_POSIX_DEP_OUT_DIR`. + - `xz`: resolve the 5.6.2 vs 5.6.4 source mismatch before building. +5. Generalize sidecar input so package-specific validation and outcome lists + are truthful for non-hello packages. +6. Build local dry-run bottles for the selected arches: + - `sqlite`: wasm32 and wasm64 if the build path supports both. + - `bzip2`: wasm32. + - `xz`: wasm32. +7. Validate generated sidecars and provenance with `cargo xtask + homebrew-validate`. +8. Materialize VFS images from the dry-run sidecars and run Node smokes. +9. Run browser smokes or record explicit host-failure status with artifact + paths and next action. +10. Run SQLite upstream-test status jobs with durable outcome lists. +11. If local dry-run evidence is satisfactory, run trusted publication against + `Automattic/kandelo-homebrew`. Keep local dry-run and trusted GHCR/tap + evidence separate in bead notes and sidecars. +12. Create focused follow-up beads only for evidence-backed blockers or for the + next specific package wave. Do not create broad waves from assumptions. + +## Test And Documentation Plan + +Required local checks for implementation work: + +- `bash scripts/test-homebrew-publish-workflow.sh` +- `scripts/verify-homebrew-kandelo-platform-tags.sh` +- Homebrew dry-run bottle build for every selected `(formula, arch)` +- `cargo xtask homebrew-validate --tap-root ` +- VFS builder tests affected by sidecar/link behavior: + `cd host && npx vitest run test/homebrew-vfs-planner.test.ts test/homebrew-vfs-builder.test.ts test/homebrew-vfs-fetch.test.ts` +- Package-specific Node smoke artifacts for sqlite, bzip2, and xz +- Package-specific browser smoke artifacts or explicit failure/skip status +- SQLite upstream status command, preferably: + `scripts/run-sqlite-project-unit-tests.sh --host both --permutation veryquick` + for pilot evidence before considering a broader `full` run + +Required outcome artifacts: + +- Build/install pass/fail list for each Formula/arch. +- Sidecar validation pass/fail list. +- Node smoke passed, failed, and skipped lists. +- Browser smoke passed, failed, and skipped lists with reasons. +- SQLite upstream passed, failed, skipped/omitted, and incomplete lists, or + documented missing categories plus follow-up. +- Bottle build logs, bottle JSON, bottle archive sha/byte report, sidecar + payload, VFS builder report, and browser smoke trace or screenshot artifacts. + +Reference docs after implementation: + +- Update `docs/homebrew-publishing.md` if the pilot generalizes non-hello + sidecar input, non-hello browser smoke, library Formula support, or outcome + metadata semantics. +- Update `docs/package-management.md` only if the package/cache/revision + contract changes. Do not update it for tap-only Formula wording. +- Update package-specific docs or notes if `bzip2`/`xz` build scripts are + converted from direct sysroot writers to resolver-style outputs. + +Because this design artifact is docs-only, it requires only lightweight docs +verification. Runtime and package validation belongs to the implementation +work. + +## Alternatives Considered + +### Port sqlite alone + +Rejected for the pilot. SQLite is the important dependency-root case, but it +does not exercise program link manifests, executable smoke commands, or small +CLI publication capacity. Adding `bzip2` and `xz` gives useful signal without +starting the full small-CLI wave. + +### Port only small CLI packages + +Rejected. The migration needs another dependency-root proof after `zlib`, +especially one with upstream-test status and wasm64 considerations. + +### Treat sqlite as a CLI Formula + +Rejected for this pilot. The current publishable registry package is the +library. `sqlite-cli` is manifest-incomplete and should be revived or removed +through a separate decision. + +### Reuse current bzip2/xz scripts unchanged + +Rejected as the default. They write into the repo sysroot and local-binaries, +which is not a clean Homebrew keg contract. The pilot may reuse their compile +knowledge, but Formula installation must be isolated to declared outputs. + +### Mark browser support from Node VFS smoke + +Rejected. Browser is a peer host. Node VFS materialization is necessary but not +sufficient for browser compatibility. + +### Gate sqlite bottle availability on full upstream tests + +Rejected. Upstream test status must be visible, but full-suite success is not +the default bottle gate. A failing upstream subset should publish a package +status and failure list without hiding a buildable/installable bottle. + +## Risks And Mitigations + +Registry and Formula divergence: + +- Risk: Formulae and `packages/registry` scripts become two independent + recipes during the bridge period. +- Mitigation: keep Formulae authoritative for Homebrew behavior, isolate any + registry-script reuse behind explicit env vars, and record bridge deletion + criteria in the broader replacement docs. + +Misleading sidecars: + +- Risk: reusing `hello`-specific sidecar text for non-hello packages would + claim validation that did not run. +- Mitigation: add generic sidecar outcome input before success publication, or + publish failure/deferred state with a blocker. + +Library smoke ambiguity: + +- Risk: a sqlite bottle can build and pour while no runtime consumer has proven + the library works. +- Mitigation: require a test-only consumer on Node and browser before marking + runtime compatibility. + +Version/source drift: + +- Risk: `xz` package metadata and build script fetch different versions. +- Mitigation: resolve source/version identity before any bottle evidence is + accepted. + +Browser parity drift: + +- Risk: Node smokes pass but browser VFS boot, terminal execution, + SharedArrayBuffer setup, or browser fetch path fails. +- Mitigation: record browser smoke separately and keep host-specific failures + visible. + +Outcome-list gaps: + +- Risk: existing test runners emit summaries and failures but not complete + passed/skipped lists. +- Mitigation: add exporters where reasonable; otherwise record the missing + category and create a focused follow-up before claiming convoy completion. + +Unnecessary rebuild churn: + +- Risk: implementers bump `build.toml.revision` to force Homebrew rebuilds. +- Mitigation: use Homebrew formula revision or bottle rebuild for bottle + selection. Bump Kandelo package revision only when package archive output + bytes legitimately change. + +## Open Questions + +- Should `bzip2` and `xz` remain program-only bottles, or should their library + byproducts become deliberate Homebrew bottle outputs? +- Does the VFS builder already handle library-only bottles well enough for + sqlite browser smoke, or does the pilot need a small extension for + validation-only consumers? +- Should sqlite wasm64 be trusted-published in this pilot, or should wasm64 + stay local evidence until the sidecar/browser story is proven on wasm32? +- What exact generic sidecar input format should replace the current + `hello`-specific outcome generation? +- Which SQLite upstream-test permutation is the right first durable status: + `veryquick`, `full`, or both with different publication meanings? +- Should `sqlite-cli` be revived as a separate Formula after the library pilot, + or removed from the accepted package set before registry deletion? diff --git a/homebrew/kandelo-homebrew/Formula/bzip2.rb b/homebrew/kandelo-homebrew/Formula/bzip2.rb new file mode 100644 index 0000000000..257fee342e --- /dev/null +++ b/homebrew/kandelo-homebrew/Formula/bzip2.rb @@ -0,0 +1,68 @@ +require "shellwords" + +class Bzip2 < Formula + desc "bzip2 compression tool for Kandelo" + homepage "https://sourceware.org/bzip2/" + url "https://sourceware.org/pub/bzip2/bzip2-1.0.8.tar.gz" + sha256 "ab5a03176ee106d3f0fa90e381da478ddae405918153cca248e682cd0c4a2269" + license "bzip2-1.0.6" + + skip_clean "bin/bzip2" + + def kandelo_root + root = ENV["HOMEBREW_KANDELO_ROOT"] || ENV["KANDELO_HOMEBREW_KANDELO_ROOT"] + odie "HOMEBREW_KANDELO_ROOT must point at a Kandelo checkout" if root.to_s.empty? + root + end + + def configure_kandelo_environment(root) + %w[ + CC CXX OBJC OBJCXX CFLAGS CPPFLAGS CXXFLAGS LDFLAGS CPATH + C_INCLUDE_PATH CPLUS_INCLUDE_PATH OBJC_INCLUDE_PATH SDKROOT + MACOSX_DEPLOYMENT_TARGET + ].each { |key| ENV.delete(key) } + + ENV.prepend_path "PATH", "#{root}/sdk/bin" + if (node = ENV["HOMEBREW_KANDELO_NODE"]).to_s != "" + ENV.prepend_path "PATH", File.dirname(node) + end + if (llvm_bin = ENV["HOMEBREW_KANDELO_LLVM_BIN"]).to_s != "" + ENV["WASM_POSIX_LLVM_DIR"] = llvm_bin + ENV["LLVM_BIN"] = llvm_bin + ENV.prepend_path "PATH", llvm_bin + end + end + + def install + root = kandelo_root + configure_kandelo_environment(root) + + out_dir = buildpath/"kandelo-package-out" + ENV["WASM_POSIX_DEP_VERSION"] = version.to_s + ENV["WASM_POSIX_DEP_SOURCE_URL"] = "https://sourceware.org/pub/bzip2/bzip2-#{version}.tar.gz" + ENV["WASM_POSIX_DEP_SOURCE_SHA256"] = "ab5a03176ee106d3f0fa90e381da478ddae405918153cca248e682cd0c4a2269" + ENV["WASM_POSIX_DEP_OUT_DIR"] = out_dir + ENV["WASM_POSIX_DEP_WORK_DIR"] = buildpath/"kandelo-package-work" + ENV["WASM_POSIX_DEP_TARGET_ARCH"] = ENV.fetch("HOMEBREW_KANDELO_ARCH", ENV.fetch("KANDELO_HOMEBREW_ARCH", "wasm32")) + + system "bash", "#{root}/packages/registry/bzip2/build-bzip2.sh" + chmod 0755, out_dir/"bzip2.wasm" + bin.install out_dir/"bzip2.wasm" => "bzip2" + chmod 0755, bin/"bzip2" + end + + test do + bzip2 = bin/"bzip2" + assert_equal "\0asm".b, File.binread(bzip2, 4) + + root = kandelo_root + configure_kandelo_environment(root) + + test_wasm = testpath/"bzip2.wasm" + File.binwrite(test_wasm, File.binread(bzip2)) + output = shell_output( + "cd #{root.shellescape} && node --experimental-wasm-exnref --import tsx/esm examples/run-example.ts #{test_wasm.to_s.shellescape} --help 2>&1", + ) + assert_match "bzip2", output.scrub + end +end diff --git a/homebrew/kandelo-homebrew/Formula/sqlite.rb b/homebrew/kandelo-homebrew/Formula/sqlite.rb new file mode 100644 index 0000000000..914b207a6c --- /dev/null +++ b/homebrew/kandelo-homebrew/Formula/sqlite.rb @@ -0,0 +1,89 @@ +require "shellwords" + +class Sqlite < Formula + desc "SQLite static library for Kandelo" + homepage "https://www.sqlite.org/" + url "https://www.sqlite.org/2025/sqlite-amalgamation-3490100.zip" + sha256 "6cebd1d8403fc58c30e93939b246f3e6e58d0765a5cd50546f16c00fd805d2c3" + license "blessing" + + def kandelo_root + root = ENV["HOMEBREW_KANDELO_ROOT"] || ENV["KANDELO_HOMEBREW_KANDELO_ROOT"] + odie "HOMEBREW_KANDELO_ROOT must point at a Kandelo checkout" if root.to_s.empty? + root + end + + def kandelo_arch + ENV.fetch("HOMEBREW_KANDELO_ARCH", ENV.fetch("KANDELO_HOMEBREW_ARCH", "wasm32")) + end + + def kandelo_tool_prefix + case kandelo_arch + when "wasm32" then "wasm32posix" + when "wasm64" then "wasm64posix" + else odie "unsupported HOMEBREW_KANDELO_ARCH=#{kandelo_arch}" + end + end + + def configure_kandelo_environment(root) + %w[ + CC CXX OBJC OBJCXX CFLAGS CPPFLAGS CXXFLAGS LDFLAGS CPATH + C_INCLUDE_PATH CPLUS_INCLUDE_PATH OBJC_INCLUDE_PATH SDKROOT + MACOSX_DEPLOYMENT_TARGET + ].each { |key| ENV.delete(key) } + + ENV.prepend_path "PATH", "#{root}/sdk/bin" + if (node = ENV["HOMEBREW_KANDELO_NODE"]).to_s != "" + ENV.prepend_path "PATH", File.dirname(node) + end + if (llvm_bin = ENV["HOMEBREW_KANDELO_LLVM_BIN"]).to_s != "" + ENV["WASM_POSIX_LLVM_DIR"] = llvm_bin + ENV["LLVM_BIN"] = llvm_bin + ENV.prepend_path "PATH", llvm_bin + end + end + + def install + root = kandelo_root + configure_kandelo_environment(root) + + out_dir = buildpath/"kandelo-package-out" + ENV["WASM_POSIX_DEP_VERSION"] = version.to_s + ENV["WASM_POSIX_DEP_SOURCE_URL"] = "https://www.sqlite.org/2025/sqlite-amalgamation-3490100.zip" + ENV["WASM_POSIX_DEP_SOURCE_SHA256"] = "6cebd1d8403fc58c30e93939b246f3e6e58d0765a5cd50546f16c00fd805d2c3" + ENV["WASM_POSIX_DEP_OUT_DIR"] = out_dir + ENV["WASM_POSIX_DEP_WORK_DIR"] = buildpath/"kandelo-package-work" + ENV["WASM_POSIX_DEP_TARGET_ARCH"] = kandelo_arch + + system "bash", "#{root}/packages/registry/sqlite/build-sqlite.sh" + include.install out_dir/"include/sqlite3.h" + include.install out_dir/"include/sqlite3ext.h" + lib.install out_dir/"lib/libsqlite3.a" + (lib/"pkgconfig").install out_dir/"lib/pkgconfig/sqlite3.pc" + inreplace lib/"pkgconfig/sqlite3.pc", /^prefix=.*/, "prefix=#{prefix}" + end + + test do + root = kandelo_root + configure_kandelo_environment(root) + + test_src = testpath/"sqlite_basic.c" + test_wasm = testpath/"sqlite_basic.wasm" + FileUtils.cp "#{root}/packages/registry/sqlite/test/sqlite_basic.c", test_src + + system "#{kandelo_tool_prefix}-cc", + "-I#{include}", + test_src, + "#{lib}/libsqlite3.a", + "-lm", + "-o", + test_wasm + assert_equal "\0asm".b, File.binread(test_wasm, 4) + + output = shell_output( + "cd #{root.shellescape} && node --experimental-wasm-exnref --import tsx/esm examples/run-example.ts #{test_wasm.to_s.shellescape}", + ) + assert_match "PASS", output + assert_match "prefix=#{prefix}", File.read(lib/"pkgconfig/sqlite3.pc") + end +end diff --git a/homebrew/kandelo-homebrew/Formula/xz.rb b/homebrew/kandelo-homebrew/Formula/xz.rb new file mode 100644 index 0000000000..e9f1da378e --- /dev/null +++ b/homebrew/kandelo-homebrew/Formula/xz.rb @@ -0,0 +1,68 @@ +require "shellwords" + +class Xz < Formula + desc "XZ Utils compression tool for Kandelo" + homepage "https://tukaani.org/xz/" + url "https://tukaani.org/xz/xz-5.6.2.tar.xz" + sha256 "a9db3bb3d64e248a0fae963f8fb6ba851a26ba1822e504dc0efd18a80c626caf" + license all_of: ["GPL-2.0-or-later", "LGPL-2.1-or-later", "0BSD"] + + skip_clean "bin/xz" + + def kandelo_root + root = ENV["HOMEBREW_KANDELO_ROOT"] || ENV["KANDELO_HOMEBREW_KANDELO_ROOT"] + odie "HOMEBREW_KANDELO_ROOT must point at a Kandelo checkout" if root.to_s.empty? + root + end + + def configure_kandelo_environment(root) + %w[ + CC CXX OBJC OBJCXX CFLAGS CPPFLAGS CXXFLAGS LDFLAGS CPATH + C_INCLUDE_PATH CPLUS_INCLUDE_PATH OBJC_INCLUDE_PATH SDKROOT + MACOSX_DEPLOYMENT_TARGET + ].each { |key| ENV.delete(key) } + + ENV.prepend_path "PATH", "#{root}/sdk/bin" + if (node = ENV["HOMEBREW_KANDELO_NODE"]).to_s != "" + ENV.prepend_path "PATH", File.dirname(node) + end + if (llvm_bin = ENV["HOMEBREW_KANDELO_LLVM_BIN"]).to_s != "" + ENV["WASM_POSIX_LLVM_DIR"] = llvm_bin + ENV["LLVM_BIN"] = llvm_bin + ENV.prepend_path "PATH", llvm_bin + end + end + + def install + root = kandelo_root + configure_kandelo_environment(root) + + out_dir = buildpath/"kandelo-package-out" + ENV["WASM_POSIX_DEP_VERSION"] = version.to_s + ENV["WASM_POSIX_DEP_SOURCE_URL"] = "https://tukaani.org/xz/xz-#{version}.tar.xz" + ENV["WASM_POSIX_DEP_SOURCE_SHA256"] = "a9db3bb3d64e248a0fae963f8fb6ba851a26ba1822e504dc0efd18a80c626caf" + ENV["WASM_POSIX_DEP_OUT_DIR"] = out_dir + ENV["WASM_POSIX_DEP_WORK_DIR"] = buildpath/"kandelo-package-work" + ENV["WASM_POSIX_DEP_TARGET_ARCH"] = ENV.fetch("HOMEBREW_KANDELO_ARCH", ENV.fetch("KANDELO_HOMEBREW_ARCH", "wasm32")) + + system "bash", "#{root}/packages/registry/xz/build-xz.sh" + chmod 0755, out_dir/"xz.wasm" + bin.install out_dir/"xz.wasm" => "xz" + chmod 0755, bin/"xz" + end + + test do + xz = bin/"xz" + assert_equal "\0asm".b, File.binread(xz, 4) + + root = kandelo_root + configure_kandelo_environment(root) + + test_wasm = testpath/"xz.wasm" + File.binwrite(test_wasm, File.binread(xz)) + output = shell_output( + "cd #{root.shellescape} && node --experimental-wasm-exnref --import tsx/esm examples/run-example.ts #{test_wasm.to_s.shellescape} --version", + ) + assert_match "xz", output.scrub + end +end diff --git a/packages/registry/bzip2/build-bzip2.sh b/packages/registry/bzip2/build-bzip2.sh index 4248060501..8ee4c9417c 100755 --- a/packages/registry/bzip2/build-bzip2.sh +++ b/packages/registry/bzip2/build-bzip2.sh @@ -4,42 +4,70 @@ set -euo pipefail # Build bzip2 1.0.8 for wasm32-posix-kernel. # # Plain Makefile build with CC/AR/RANLIB overrides. -# Output: packages/registry/bzip2/bin/bzip2.wasm -# Also installs libbz2.a + bzlib.h to sysroot. +# Output: bzip2.wasm. Resolver/Homebrew invocations install only the +# declared program output into WASM_POSIX_DEP_OUT_DIR; direct legacy +# invocations still populate packages/registry/bzip2/bin and sysroot. -BZIP2_VERSION="${BZIP2_VERSION:-1.0.8}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -SRC_DIR="$SCRIPT_DIR/bzip2-src" -BIN_DIR="$SCRIPT_DIR/bin" -SYSROOT="$REPO_ROOT/sysroot" +source "$REPO_ROOT/sdk/activate.sh" + +BZIP2_VERSION="${WASM_POSIX_DEP_VERSION:-${BZIP2_VERSION:-1.0.8}}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://sourceware.org/pub/bzip2/bzip2-${BZIP2_VERSION}.tar.gz}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-}" +WORK_DIR="${WASM_POSIX_DEP_WORK_DIR:-$SCRIPT_DIR}" +SRC_DIR="$WORK_DIR/bzip2-src" +BIN_DIR="$WORK_DIR/bin" +INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:-}" +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" + +if [ "$TARGET_ARCH" != "wasm32" ]; then + echo "ERROR: bzip2 is currently packaged for wasm32 only, got $TARGET_ARCH" >&2 + exit 2 +fi + +SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +export WASM_POSIX_SYSROOT="$SYSROOT" # --- Prerequisites --- if ! command -v wasm32posix-cc &>/dev/null; then - echo "ERROR: wasm32posix-cc not found. Run 'npm link' in sdk/ first." >&2 + echo "ERROR: wasm32posix-cc not found. Run through scripts/dev-shell.sh." >&2 exit 1 fi if [ ! -f "$SYSROOT/lib/libc.a" ]; then - echo "ERROR: sysroot not found. Run: bash build.sh && bash scripts/build-musl.sh" >&2 + echo "ERROR: sysroot not found at $SYSROOT. Run scripts/build-musl.sh first." >&2 exit 1 fi -export WASM_POSIX_SYSROOT="$SYSROOT" - # --- Download bzip2 source --- +expected_marker="$(printf '%s\n%s\n%s\n' "$BZIP2_VERSION" "$SOURCE_URL" "$SOURCE_SHA256")" +SOURCE_MARKER="$SRC_DIR/.kandelo-bzip2-source" +if [ -d "$SRC_DIR" ] && [ "$(cat "$SOURCE_MARKER" 2>/dev/null || true)" != "$expected_marker" ]; then + echo "==> Existing bzip2 source does not match requested version/source; cleaning..." + rm -rf "$SRC_DIR" +fi + if [ ! -d "$SRC_DIR" ]; then echo "==> Downloading bzip2 $BZIP2_VERSION..." - TARBALL="bzip2-${BZIP2_VERSION}.tar.gz" - URL="https://sourceware.org/pub/bzip2/${TARBALL}" - curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$URL" -o "/tmp/$TARBALL" + tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-bzip2-src.XXXXXX")" + trap 'rm -rf "$tmpdir"' EXIT + TARBALL="$tmpdir/bzip2-${BZIP2_VERSION}.tar.gz" + curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "$TARBALL" + if [ -n "$SOURCE_SHA256" ]; then + echo "==> Verifying source sha256..." + echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - + fi mkdir -p "$SRC_DIR" - tar xzf "/tmp/$TARBALL" -C "$SRC_DIR" --strip-components=1 - rm "/tmp/$TARBALL" + tar xzf "$TARBALL" -C "$SRC_DIR" --strip-components=1 + printf '%s\n' "$expected_marker" > "$SOURCE_MARKER" + trap - EXIT + rm -rf "$tmpdir" echo "==> Source extracted to $SRC_DIR" fi cd "$SRC_DIR" +make clean >/dev/null 2>&1 || true # --- Build --- echo "==> Building bzip2..." @@ -64,17 +92,25 @@ else exit 1 fi -# --- Install library to sysroot --- -echo "==> Installing libbz2.a and bzlib.h to sysroot..." -cp "$SRC_DIR/libbz2.a" "$SYSROOT/lib/" -cp "$SRC_DIR/bzlib.h" "$SYSROOT/include/" -echo "==> Installed libbz2.a and bzlib.h" +if [ -n "$INSTALL_DIR" ]; then + source "$REPO_ROOT/scripts/wasm-artifact-guards.sh" + wasm_require_no_legacy_asyncify "$BIN_DIR/bzip2.wasm" + wasm_require_no_fork_instrumentation "$BIN_DIR/bzip2.wasm" + rm -rf "$INSTALL_DIR" + mkdir -p "$INSTALL_DIR" + cp "$BIN_DIR/bzip2.wasm" "$INSTALL_DIR/bzip2.wasm" + echo "==> Installed bzip2.wasm to $INSTALL_DIR" +else + # --- Install library to sysroot (legacy direct invocation only) --- + echo "==> Installing libbz2.a and bzlib.h to sysroot..." + cp "$SRC_DIR/libbz2.a" "$SYSROOT/lib/" + cp "$SRC_DIR/bzlib.h" "$SYSROOT/include/" + echo "==> Installed libbz2.a and bzlib.h" + + source "$REPO_ROOT/scripts/install-local-binary.sh" + install_local_binary bzip2 "$SCRIPT_DIR/bin/bzip2.wasm" +fi echo "" echo "==> bzip2 built successfully!" echo "Binary: $BIN_DIR/bzip2.wasm" - -# Install into local-binaries/ so the resolver picks the freshly-built -# binary over the fetched release. -source "$REPO_ROOT/scripts/install-local-binary.sh" -install_local_binary bzip2 "$SCRIPT_DIR/bin/bzip2.wasm" diff --git a/packages/registry/sqlite/build-sqlite.sh b/packages/registry/sqlite/build-sqlite.sh index 8503250dbc..e7d205b6c1 100755 --- a/packages/registry/sqlite/build-sqlite.sh +++ b/packages/registry/sqlite/build-sqlite.sh @@ -14,30 +14,63 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -SRC_DIR="$SCRIPT_DIR/sqlite-src" +source "$REPO_ROOT/sdk/activate.sh" # --- Resolver contract (with legacy fallbacks) --- SQLITE_VERSION="${WASM_POSIX_DEP_VERSION:-${SQLITE_VERSION:-3.49.1}}" +WORK_DIR="${WASM_POSIX_DEP_WORK_DIR:-$SCRIPT_DIR}" +SRC_DIR="$WORK_DIR/sqlite-src" INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:-$SCRIPT_DIR/sqlite-install}" +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" # Legacy default URL uses the packed version form (3.49.1 → 3490100). SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://www.sqlite.org/2025/sqlite-amalgamation-3490100.zip}" SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-}" +case "$TARGET_ARCH" in + wasm32) + TOOL_PREFIX="wasm32posix" + SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" + ;; + wasm64) + TOOL_PREFIX="wasm64posix" + SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot64}" + ;; + *) + echo "ERROR: unsupported WASM_POSIX_DEP_TARGET_ARCH=$TARGET_ARCH" >&2 + exit 2 + ;; +esac +export WASM_POSIX_SYSROOT="$SYSROOT" + # CLI is a consumer artifact, not a library. Skip it when invoked via # the resolver — it would waste cache space and the consumer-side # tooling will build it independently. BUILD_CLI=1 [ -n "${WASM_POSIX_DEP_OUT_DIR:-}" ] && BUILD_CLI=0 -if ! command -v wasm32posix-cc &>/dev/null; then - echo "ERROR: wasm32posix-cc not found. Run 'npm link' in sdk/ first." >&2 +if ! command -v "${TOOL_PREFIX}-cc" &>/dev/null; then + echo "ERROR: ${TOOL_PREFIX}-cc not found. Run through scripts/dev-shell.sh." >&2 + exit 1 +fi + +if [ ! -f "$SYSROOT/lib/libc.a" ]; then + echo "ERROR: sysroot not found at $SYSROOT. Run scripts/build-musl.sh for $TARGET_ARCH first." >&2 exit 1 fi # --- Fetch + verify source --- -if [ ! -d "$SRC_DIR/sqlite3.c" ] && [ ! -f "$SRC_DIR/sqlite3.c" ]; then +expected_marker="$(printf '%s\n%s\n%s\n' "$SQLITE_VERSION" "$SOURCE_URL" "$SOURCE_SHA256")" +SOURCE_MARKER="$SRC_DIR/.kandelo-sqlite-source" +if [ -d "$SRC_DIR" ] && [ "$(cat "$SOURCE_MARKER" 2>/dev/null || true)" != "$expected_marker" ]; then + echo "==> Existing SQLite source does not match requested version/source; cleaning..." + rm -rf "$SRC_DIR" +fi + +if [ ! -f "$SRC_DIR/sqlite3.c" ]; then echo "==> Downloading SQLite $SQLITE_VERSION..." - TARBALL="/tmp/sqlite-amalgamation.zip" + tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-sqlite-src.XXXXXX")" + trap 'rm -rf "$tmpdir"' EXIT + TARBALL="$tmpdir/sqlite-amalgamation.zip" curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "$TARBALL" if [ -n "$SOURCE_SHA256" ]; then echo "==> Verifying source sha256..." @@ -51,7 +84,9 @@ if [ ! -d "$SRC_DIR/sqlite3.c" ] && [ ! -f "$SRC_DIR/sqlite3.c" ]; then mv "$inner"/* "$SRC_DIR/" rmdir "$inner" fi - rm "$TARBALL" + printf '%s\n' "$expected_marker" > "$SOURCE_MARKER" + trap - EXIT + rm -rf "$tmpdir" fi SQLITE_CFLAGS="-O2 \ @@ -68,10 +103,10 @@ SQLITE_CFLAGS="-O2 \ # --- Compile library --- echo "==> Compiling SQLite for Wasm..." # shellcheck disable=SC2086 -wasm32posix-cc -c $SQLITE_CFLAGS \ +"${TOOL_PREFIX}-cc" -c $SQLITE_CFLAGS \ "$SRC_DIR/sqlite3.c" -o "$SRC_DIR/sqlite3.o" -wasm32posix-ar rcs "$SRC_DIR/libsqlite3.a" "$SRC_DIR/sqlite3.o" +"${TOOL_PREFIX}-ar" rcs "$SRC_DIR/libsqlite3.a" "$SRC_DIR/sqlite3.o" # --- Install library into INSTALL_DIR --- echo "==> Installing to $INSTALL_DIR..." @@ -98,7 +133,7 @@ if [ "$BUILD_CLI" = "1" ]; then echo "==> Building sqlite3 CLI..." mkdir -p "$INSTALL_DIR/bin" # shellcheck disable=SC2086 - wasm32posix-cc $SQLITE_CFLAGS \ + "${TOOL_PREFIX}-cc" $SQLITE_CFLAGS \ "$SRC_DIR/shell.c" "$SRC_DIR/sqlite3.c" \ -o "$INSTALL_DIR/bin/sqlite3.wasm" -lm diff --git a/packages/registry/xz/build-xz.sh b/packages/registry/xz/build-xz.sh index 92da5fe760..88604d06db 100755 --- a/packages/registry/xz/build-xz.sh +++ b/packages/registry/xz/build-xz.sh @@ -1,42 +1,73 @@ #!/usr/bin/env bash set -euo pipefail -# Build XZ Utils 5.6.4 for wasm32-posix-kernel. +# Build XZ Utils for wasm32-posix-kernel. # # Uses the SDK's wasm32posix-configure wrapper for cross-compilation. # --disable-threads is critical (no pthreads support). -# Output: packages/registry/xz/bin/xz.wasm -# Also installs liblzma.a + headers to sysroot. +# Output: xz.wasm. Resolver/Homebrew invocations install only the declared +# program output into WASM_POSIX_DEP_OUT_DIR; direct legacy invocations still +# populate packages/registry/xz/bin and sysroot. -XZ_VERSION="${XZ_VERSION:-5.6.4}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -SRC_DIR="$SCRIPT_DIR/xz-src" -BIN_DIR="$SCRIPT_DIR/bin" -SYSROOT="$REPO_ROOT/sysroot" +source "$REPO_ROOT/sdk/activate.sh" + +XZ_VERSION="${WASM_POSIX_DEP_VERSION:-${XZ_VERSION:-5.6.2}}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://tukaani.org/xz/xz-${XZ_VERSION}.tar.xz}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-}" +WORK_DIR="${WASM_POSIX_DEP_WORK_DIR:-$SCRIPT_DIR}" +SRC_DIR="$WORK_DIR/xz-src" +BIN_DIR="$WORK_DIR/bin" +INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:-}" +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" + +if [ "$TARGET_ARCH" != "wasm32" ]; then + echo "ERROR: xz is currently packaged for wasm32 only, got $TARGET_ARCH" >&2 + exit 2 +fi + +SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +export WASM_POSIX_SYSROOT="$SYSROOT" # --- Prerequisites --- if ! command -v wasm32posix-cc &>/dev/null; then - echo "ERROR: wasm32posix-cc not found. Run 'npm link' in sdk/ first." >&2 + echo "ERROR: wasm32posix-cc not found. Run through scripts/dev-shell.sh." >&2 exit 1 fi if [ ! -f "$SYSROOT/lib/libc.a" ]; then - echo "ERROR: sysroot not found. Run: bash build.sh && bash scripts/build-musl.sh" >&2 + echo "ERROR: sysroot not found at $SYSROOT. Run scripts/build-musl.sh first." >&2 exit 1 fi -export WASM_POSIX_SYSROOT="$SYSROOT" - # --- Download xz source --- +expected_marker="$(printf '%s\n%s\n%s\n' "$XZ_VERSION" "$SOURCE_URL" "$SOURCE_SHA256")" +SOURCE_MARKER="$SRC_DIR/.kandelo-xz-source" +if [ -d "$SRC_DIR" ] && [ "$(cat "$SOURCE_MARKER" 2>/dev/null || true)" != "$expected_marker" ]; then + echo "==> Existing xz source does not match requested version/source; cleaning..." + rm -rf "$SRC_DIR" +fi + if [ ! -d "$SRC_DIR" ]; then echo "==> Downloading xz $XZ_VERSION..." - TARBALL="xz-${XZ_VERSION}.tar.gz" - URL="https://github.com/tukaani-project/xz/releases/download/v${XZ_VERSION}/${TARBALL}" - curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$URL" -o "/tmp/$TARBALL" + tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-xz-src.XXXXXX")" + trap 'rm -rf "$tmpdir"' EXIT + case "$SOURCE_URL" in + *.tar.gz|*.tgz) TARBALL="$tmpdir/xz-${XZ_VERSION}.tar.gz" ;; + *.tar.xz|*.txz) TARBALL="$tmpdir/xz-${XZ_VERSION}.tar.xz" ;; + *) TARBALL="$tmpdir/xz-${XZ_VERSION}.tar" ;; + esac + curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "$TARBALL" + if [ -n "$SOURCE_SHA256" ]; then + echo "==> Verifying source sha256..." + echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - + fi mkdir -p "$SRC_DIR" - tar xzf "/tmp/$TARBALL" -C "$SRC_DIR" --strip-components=1 - rm "/tmp/$TARBALL" + tar xf "$TARBALL" -C "$SRC_DIR" --strip-components=1 + printf '%s\n' "$expected_marker" > "$SOURCE_MARKER" + trap - EXIT + rm -rf "$tmpdir" echo "==> Source extracted to $SRC_DIR" # Patch: xz excludes __wasm__ from sigprocmask path, but our sysroot has it @@ -98,21 +129,29 @@ else exit 1 fi -# --- Install library to sysroot --- -echo "==> Installing liblzma.a and headers to sysroot..." -if [ -f "$SRC_DIR/src/liblzma/.libs/liblzma.a" ]; then - cp "$SRC_DIR/src/liblzma/.libs/liblzma.a" "$SYSROOT/lib/" - mkdir -p "$SYSROOT/include/lzma" - cp "$SRC_DIR/src/liblzma/api/lzma.h" "$SYSROOT/include/" - cp "$SRC_DIR/src/liblzma/api/lzma/"*.h "$SYSROOT/include/lzma/" - echo "==> Installed liblzma.a and headers" +if [ -n "$INSTALL_DIR" ]; then + source "$REPO_ROOT/scripts/wasm-artifact-guards.sh" + wasm_require_no_legacy_asyncify "$BIN_DIR/xz.wasm" + wasm_require_no_fork_instrumentation "$BIN_DIR/xz.wasm" + rm -rf "$INSTALL_DIR" + mkdir -p "$INSTALL_DIR" + cp "$BIN_DIR/xz.wasm" "$INSTALL_DIR/xz.wasm" + echo "==> Installed xz.wasm to $INSTALL_DIR" +else + # --- Install library to sysroot (legacy direct invocation only) --- + echo "==> Installing liblzma.a and headers to sysroot..." + if [ -f "$SRC_DIR/src/liblzma/.libs/liblzma.a" ]; then + cp "$SRC_DIR/src/liblzma/.libs/liblzma.a" "$SYSROOT/lib/" + mkdir -p "$SYSROOT/include/lzma" + cp "$SRC_DIR/src/liblzma/api/lzma.h" "$SYSROOT/include/" + cp "$SRC_DIR/src/liblzma/api/lzma/"*.h "$SYSROOT/include/lzma/" + echo "==> Installed liblzma.a and headers" + fi + + source "$REPO_ROOT/scripts/install-local-binary.sh" + install_local_binary xz "$SCRIPT_DIR/bin/xz.wasm" fi echo "" echo "==> xz built successfully!" echo "Binary: $BIN_DIR/xz.wasm" - -# Install into local-binaries/ so the resolver picks the freshly-built -# binary over the fetched release. -source "$REPO_ROOT/scripts/install-local-binary.sh" -install_local_binary xz "$SCRIPT_DIR/bin/xz.wasm" diff --git a/scripts/homebrew-bottle-build.sh b/scripts/homebrew-bottle-build.sh index c418f9c82a..3c3a4d2fc7 100755 --- a/scripts/homebrew-bottle-build.sh +++ b/scripts/homebrew-bottle-build.sh @@ -85,9 +85,10 @@ fi KANDELO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" PATCH_FILE="$KANDELO_ROOT/homebrew/patches/0001-add-kandelo-wasm-bottle-tags.patch" mkdir -p "$OUT_DIR/bottles" -WORK_DIR="$(mktemp -d)" +WORK_DIR="$(mktemp -d "$OUT_DIR/homebrew-work.XXXXXX")" BREW_REPO="" BREW_OVERLAY="" +TAP_SOURCE="$TAP_ROOT" cleanup() { if [ -n "$BREW_REPO" ] && [ -n "$BREW_OVERLAY" ] && [ -d "$BREW_OVERLAY" ]; then @@ -120,7 +121,18 @@ export HOMEBREW_KANDELO_ROOT="$KANDELO_ROOT" export HOMEBREW_KANDELO_NODE="$(command -v node)" export HOMEBREW_KANDELO_LLVM_BIN="${LLVM_BIN:-${WASM_POSIX_LLVM_DIR:-}}" -"$BREW_BIN" tap "$TAP_NAME" "$TAP_ROOT" +if [ ! -d "$TAP_SOURCE/.git" ]; then + TAP_SOURCE="$WORK_DIR/tap-source" + mkdir -p "$TAP_SOURCE" + rsync -a --exclude .git "$TAP_ROOT/" "$TAP_SOURCE/" + git -C "$TAP_SOURCE" init -q + git -C "$TAP_SOURCE" config user.name "kandelo-homebrew-local" + git -C "$TAP_SOURCE" config user.email "kandelo-homebrew-local@example.invalid" + git -C "$TAP_SOURCE" add . + git -C "$TAP_SOURCE" commit -q -m "stage Kandelo Homebrew tap" +fi + +"$BREW_BIN" tap "$TAP_NAME" "$TAP_SOURCE" FORMULA_REF="$TAP_NAME/$FORMULA" TAPPED_TAP_ROOT="$("$BREW_BIN" --repository "$TAP_NAME")" TAPPED_FORMULA_PATH="$TAPPED_TAP_ROOT/Formula/$FORMULA.rb" @@ -189,7 +201,7 @@ BOTTLE_JSON="$OUT_DIR/bottles/$(basename "${bottle_jsons[0]}")" BOTTLE_ARCHIVE="$OUT_DIR/bottles/$(basename "${bottle_archives[0]}")" ( - cd "$TAP_ROOT" + cd "$TAPPED_TAP_ROOT" HOMEBREW_KANDELO_BOTTLE_TAG="$BOTTLE_TAG" \ KANDELO_HOMEBREW_BOTTLE_TAG="$BOTTLE_TAG" \ "$BREW_BIN" bottle --merge --write --no-commit "$BOTTLE_JSON" diff --git a/scripts/homebrew-generate-sidecars-from-env.sh b/scripts/homebrew-generate-sidecars-from-env.sh index e77db39103..add93d959f 100755 --- a/scripts/homebrew-generate-sidecars-from-env.sh +++ b/scripts/homebrew-generate-sidecars-from-env.sh @@ -108,89 +108,130 @@ for dep in package_toml.get("depends_on", []): deps.append({"name": dep}) version = str(bottle_formula["pkg_version"]) +package_kind = package_toml.get("kind", "program") +if package_kind not in {"library", "program"}: + raise SystemExit(f"unsupported Homebrew sidecar package kind for {formula}: {package_kind!r}") + +def package_links_and_env(): + if package_kind == "library": + outputs = package_toml.get("outputs", {}) + links = [] + for key in ("headers", "libs", "pkgconfig"): + for rel in sorted(outputs.get(key, [])): + links.append({ + "type": "file", + "source": rel, + "target": rel, + "mode": "0644", + }) + if not links: + raise SystemExit(f"library formula {formula} has no declared package outputs to link") + return links, {} + + return [ + {"type": "symlink", "source": f"bin/{formula}", "target": f"bin/{formula}"} + ], {"PATH_prepend": ["bin"]} + +def package_fork_instrumentation(): + outputs = package_toml.get("outputs", []) + if isinstance(outputs, list): + for output in outputs: + if output.get("name") == formula: + return output.get("fork_instrumentation", "not-required") + return "not-required" + +def default_node_smoke_text(): + if package_kind == "library": + return ( + f"Formula test compiled packages/registry/{formula}/test/{formula}_basic.c " + "against the installed keg and ran the resulting Wasm through " + "node --import tsx/esm examples/run-example.ts" + ) + if formula == "bzip2": + return ( + "Formula test ran bzip2 --help through " + "node --import tsx/esm examples/run-example.ts" + ) + return ( + f"Formula test ran {formula} --version through " + "node --import tsx/esm examples/run-example.ts" + ) + +def skipped_outcome(name, reason): + return { + "name": name, + "status": "skipped", + "passed": [], + "failed": [], + "skipped": [reason], + "skip_reason": reason, + } + +def failed_outcome(name, reason): + return { + "name": name, + "status": "failed", + "passed": [], + "failed": [reason], + "skipped": [], + } + +links, link_env = package_links_and_env() browser_smoke_status = os.environ.get("KANDELO_HOMEBREW_BROWSER_SMOKE_STATUS", "skipped") -if browser_smoke_status not in {"success", "skipped"}: +if browser_smoke_status not in {"success", "skipped", "failed"}: raise SystemExit(f"invalid KANDELO_HOMEBREW_BROWSER_SMOKE_STATUS={browser_smoke_status!r}") browser_compatible = browser_smoke_status == "success" if browser_compatible and arch != "wasm32": raise SystemExit("browser smoke can only mark wasm32 bottles browser-compatible") runtime_support = ["node", "browser"] if browser_compatible else ["node"] -browser_smoke_outcome = { - "name": "browser_smoke", - "status": "skipped", - "passed": [], - "failed": [], - "skipped": ["browser_compatible is false for this bottle"], - "skip_reason": "No successful browser VFS smoke was recorded for this bottle.", -} -vfs_image_outcome = { - "name": "homebrew_vfs_image", - "status": "skipped", - "passed": [], - "failed": [], - "skipped": ["precomposed browser VFS image was not built"], - "skip_reason": "Browser-compatible gallery publication requires kd-8ho.10 browser smoke.", -} -gallery_outcome = { - "name": "browser_gallery", - "status": "skipped", - "passed": [], - "failed": [], - "skipped": ["browser gallery assets were not generated"], - "skip_reason": "Gallery assets require a successful browser VFS smoke.", -} +browser_reason = os.environ.get( + "KANDELO_HOMEBREW_BROWSER_SMOKE_REASON", + f"No successful browser VFS smoke was recorded for {formula} {arch}.", +) +browser_smoke_outcome = skipped_outcome("browser_smoke", browser_reason) +if browser_smoke_status == "failed": + browser_smoke_outcome = failed_outcome("browser_smoke", browser_reason) if browser_compatible: vfs_image = os.environ.get("KANDELO_HOMEBREW_VFS_IMAGE", "") vfs_report = os.environ.get("KANDELO_HOMEBREW_VFS_REPORT", "") - gallery_root = os.environ.get("KANDELO_HOMEBREW_GALLERY_ROOT", "") browser_url = os.environ.get("KANDELO_HOMEBREW_BROWSER_SMOKE_URL", "") browser_command = os.environ.get( "KANDELO_HOMEBREW_BROWSER_SMOKE_COMMAND", - "/home/linuxbrew/.linuxbrew/bin/hello --version", + f"/home/linuxbrew/.linuxbrew/bin/{formula} --version", ) missing = [ name for name, value in [ ("KANDELO_HOMEBREW_VFS_IMAGE", vfs_image), ("KANDELO_HOMEBREW_VFS_REPORT", vfs_report), - ("KANDELO_HOMEBREW_GALLERY_ROOT", gallery_root), ("KANDELO_HOMEBREW_BROWSER_SMOKE_URL", browser_url), ] if not value ] if missing: raise SystemExit("browser smoke success is missing env: " + ", ".join(missing)) - browser_smoke_outcome = { - "name": "browser_smoke", - "status": "success", - "passed": [ - f"Playwright chromium launched {browser_url}", - f"terminal command passed: {browser_command}", - ], - "failed": [], - "skipped": [], - } - vfs_image_outcome = { - "name": "homebrew_vfs_image", - "status": "success", - "passed": [ - f"built {vfs_image}", - f"wrote report {vfs_report}", - ], - "failed": [], - "skipped": [], - } - gallery_outcome = { - "name": "browser_gallery", - "status": "success", - "passed": [ + browser_passed = [ + f"built precomposed VFS image {vfs_image}", + f"wrote VFS report {vfs_report}", + f"Playwright chromium launched {browser_url}", + f"terminal command passed: {browser_command}", + ] + gallery_root = os.environ.get("KANDELO_HOMEBREW_GALLERY_ROOT", "") + if gallery_root: + browser_passed.extend([ f"generated {gallery_root}/gallery.json", f"generated {gallery_root}/index.toml", "scripts/validate-software-gallery.mjs accepted generated gallery assets", - ], + ]) + browser_smoke_outcome = { + "name": "browser_smoke", + "status": "success", + "passed": browser_passed, "failed": [], "skipped": [], } +node_smoke_text = os.environ.get("KANDELO_HOMEBREW_NODE_SMOKE_COMMAND", default_node_smoke_text()) + manifest = { "schema": 1, "tap_repository": os.environ["KANDELO_HOMEBREW_TAP_REPOSITORY"], @@ -219,7 +260,7 @@ manifest = { "prefix": "/home/linuxbrew/.linuxbrew", "runtime_support": runtime_support, "browser_compatible": browser_compatible, - "fork_instrumentation": "not-required", + "fork_instrumentation": package_fork_instrumentation(), "status": "success", "built_by": os.environ["RUN_URL"], "built_at": os.environ["GENERATED_AT"], @@ -227,11 +268,9 @@ manifest = { "url": os.environ["KANDELO_HOMEBREW_BOTTLE_URL"], "cache_key_sha": os.environ["CACHE_KEY_SHA"], "payload_root": f"{formula}/{version}", - "links": [ - {"type": "symlink", "source": f"bin/{formula}", "target": f"bin/{formula}"} - ], + "links": links, "receipts": [f".brew/{formula}.rb", "INSTALL_RECEIPT.json"], - "env": {"PATH_prepend": ["bin"]}, + "env": link_env, "build": { "github_run": os.environ["RUN_URL"], "job": os.environ.get("GITHUB_JOB", "local"), @@ -260,8 +299,8 @@ manifest = { "status": "skipped", "passed": [], "failed": [], - "skipped": ["brew audit was not part of kd-8ho.5 local verification"], - "skip_reason": "kd-8ho.5 validates the first bottle build and sidecars; tap audit can run in the real tap publication gate.", + "skipped": ["brew audit was not part of this local dry-run verification"], + "skip_reason": "Tap audit can run in the trusted tap publication gate.", }, { "name": "bottle_build", @@ -278,15 +317,11 @@ manifest = { { "name": "node_smoke", "status": "success", - "passed": [ - "Formula test ran hello --version through node --import tsx/esm examples/run-example.ts" - ], + "passed": [node_smoke_text], "failed": [], "skipped": [], }, - vfs_image_outcome, browser_smoke_outcome, - gallery_outcome, ], }, } diff --git a/scripts/homebrew-package-node-smoke.ts b/scripts/homebrew-package-node-smoke.ts new file mode 100644 index 0000000000..08cbf0b7b1 --- /dev/null +++ b/scripts/homebrew-package-node-smoke.ts @@ -0,0 +1,644 @@ +/** + * Node-side smoke coverage for Kandelo Homebrew package sidecars. + * + * The runner consumes generated Kandelo/Homebrew sidecars, materializes each + * requested package into a VFS, and runs a package-specific smoke through + * NodeKernelHost. Program packages execute their poured binary. SQLite + * compiles a test-only consumer from the poured headers and static library. + */ +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { ABI_VERSION } from "../host/src/generated/abi"; +import { fetchHomebrewBottleBytes } from "../host/src/homebrew-vfs-fetch"; +import { buildHomebrewVfs } from "../host/src/homebrew-vfs-builder"; +import { + planHomebrewVfs, + type HomebrewBottleArch, + type HomebrewTapMetadata, + type HomebrewVfsPackagePlan, +} from "../host/src/homebrew-vfs-planner"; +import { NodeKernelHost } from "../host/src/node-kernel-host"; +import { MemoryFileSystem } from "../host/src/vfs/memory-fs"; +import { saveImage } from "../images/vfs/scripts/vfs-image-helpers"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, ".."); +const PREFIX = "/home/linuxbrew/.linuxbrew"; +const CELLAR = `${PREFIX}/Cellar`; + +type OutcomeStatus = "pass" | "fail" | "skip"; +type FormulaName = "sqlite" | "bzip2" | "xz"; + +interface CliOptions { + resultDir: string; + tapRoot: string; + formulas: FormulaName[]; + arch: HomebrewBottleArch; + bottleCache: string; + timeoutMs: number; + maxBytes: number; + beadId: string; +} + +interface Outcome { + name: string; + status: OutcomeStatus; + durationMs: number; + details?: string; + error?: string; +} + +interface BuiltVfs { + fs: MemoryFileSystem; + imageBytes: Uint8Array; + reportPath: string; +} + +class SkipCase extends Error { + constructor(message: string) { + super(message); + this.name = "SkipCase"; + } +} + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + mkdirSync(options.resultDir, { recursive: true }); + mkdirSync(join(options.resultDir, "outcome-lists"), { recursive: true }); + mkdirSync(options.bottleCache, { recursive: true }); + + const metadataPath = join(options.tapRoot, "Kandelo", "metadata.json"); + const metadata = readJsonFile(metadataPath); + const tapCommit = gitRevParse(options.tapRoot); + const startedAt = new Date(); + const outcomes: Outcome[] = []; + + writeCurrentRun(options, { + status: "running", + startedAt, + tapCommit, + outcomes, + currentCase: "startup", + }); + + const builtByFormula = new Map(); + for (const formula of options.formulas) { + await runCase(outcomes, options, tapCommit, `homebrew_vfs_build_${formula}`, async () => { + const built = await buildFormulaVfs(metadata, formula, options); + builtByFormula.set(formula, built); + return `report=${built.reportPath}`; + }); + + await runCase(outcomes, options, tapCommit, `node_smoke_${formula}`, async () => { + const built = builtByFormula.get(formula); + if (!built) throw new SkipCase(`requires successful homebrew_vfs_build_${formula}`); + return await runFormulaSmoke(formula, built, options); + }); + } + + writeOutcomeLists(options.resultDir, outcomes); + writeSummary(options, { + startedAt, + completedAt: new Date(), + tapCommit, + outcomes, + }); + writeCurrentRun(options, { + status: outcomes.some((outcome) => outcome.status === "fail") ? "failed" : "complete", + startedAt, + tapCommit, + outcomes, + currentCase: "complete", + }); + + process.exit(outcomes.some((outcome) => outcome.status === "fail") ? 1 : 0); +} + +async function buildFormulaVfs( + metadata: HomebrewTapMetadata, + formula: FormulaName, + options: CliOptions, +): Promise { + const plan = await planHomebrewVfs(metadata, { + packages: [formula], + arch: options.arch, + runtime: "node", + expectedAbi: ABI_VERSION, + loadLinkManifest: (relPath) => readJsonFile(join(options.tapRoot, relPath)), + }); + const fs = createFs(options.maxBytes); + const result = await buildHomebrewVfs(plan, { + fs, + createdBy: "scripts/homebrew-package-node-smoke.ts", + loadBottleBytes: (pkg) => loadBottleBytes(pkg, options), + }); + + const reportPath = join(options.resultDir, `${formula}-${options.arch}-homebrew-vfs-report.json`); + writeFileSync(reportPath, `${JSON.stringify(result.report, null, 2)}\n`); + const imagePath = join(options.resultDir, `${formula}-${options.arch}-homebrew.vfs.zst`); + const imageBytes = await saveImage(fs, imagePath, { + metadata: { + version: 1, + kernelAbi: plan.kandeloAbi, + createdBy: "scripts/homebrew-package-node-smoke.ts", + homebrew: { + tapRepository: plan.tapRepository, + tapCommit: plan.tapCommit, + releaseTag: plan.releaseTag, + packages: plan.packages.map((pkg) => ({ + name: pkg.name, + version: pkg.version, + arch: pkg.arch, + sourceStatus: pkg.sourceStatus, + cacheKeySha: pkg.cacheKeySha, + })), + }, + }, + }); + return { fs, imageBytes, reportPath }; +} + +async function runFormulaSmoke( + formula: FormulaName, + built: BuiltVfs, + options: CliOptions, +): Promise { + switch (formula) { + case "sqlite": + return runSqliteSmoke(built, options); + case "bzip2": + return runProgramVersionSmoke("bzip2", `${PREFIX}/bin/bzip2`, /bzip2/i, built, options, ["--help"]); + case "xz": + return runProgramVersionSmoke("xz", `${PREFIX}/bin/xz`, /xz/i, built, options); + } +} + +async function runProgramVersionSmoke( + argv0: string, + guestPath: string, + expected: RegExp, + built: BuiltVfs, + options: CliOptions, + args: string[] = ["--version"], +): Promise { + const programBytes = readVfsFile(built.fs, guestPath); + const result = await runWasm(programBytes, [argv0, ...args], built.imageBytes, options); + if (result.exitCode !== 0) { + throw new Error(`${argv0} ${args.join(" ")} exited ${result.exitCode}; stderr=${JSON.stringify(result.stderr)}`); + } + const combined = `${result.stdout}\n${result.stderr}`; + if (!expected.test(combined)) { + throw new Error(`unexpected ${argv0} ${args.join(" ")} output: ${JSON.stringify(combined)}`); + } + return combined.trim().split("\n").find((line) => line.trim() !== "") ?? `${argv0} ${args.join(" ")} passed`; +} + +async function runSqliteSmoke(built: BuiltVfs, options: CliOptions): Promise { + const stage = join(options.resultDir, "sqlite-consumer-build", options.arch); + rmSync(stage, { recursive: true, force: true }); + mkdirSync(join(stage, "include"), { recursive: true }); + mkdirSync(join(stage, "lib"), { recursive: true }); + + const version = findPackageVersion(built.fs, "sqlite"); + writeFileSync( + join(stage, "include", "sqlite3.h"), + readVfsFile(built.fs, `${CELLAR}/sqlite/${version}/include/sqlite3.h`), + ); + writeFileSync( + join(stage, "include", "sqlite3ext.h"), + readVfsFile(built.fs, `${CELLAR}/sqlite/${version}/include/sqlite3ext.h`), + ); + writeFileSync( + join(stage, "lib", "libsqlite3.a"), + readVfsFile(built.fs, `${CELLAR}/sqlite/${version}/lib/libsqlite3.a`), + ); + + const testSrc = join(repoRoot, "packages", "registry", "sqlite", "test", "sqlite_basic.c"); + const outWasm = join(stage, "sqlite_basic.wasm"); + const cc = join(repoRoot, "sdk", "bin", `${options.arch}posix-cc`); + execFileSync(cc, [ + `-I${join(stage, "include")}`, + testSrc, + join(stage, "lib", "libsqlite3.a"), + "-lm", + "-o", + outWasm, + ], { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${join(repoRoot, "sdk", "bin")}:${process.env.PATH ?? ""}`, + WASM_POSIX_SYSROOT: join(repoRoot, options.arch === "wasm64" ? "sysroot64" : "sysroot"), + }, + stdio: "pipe", + }); + + const consumerBytes = new Uint8Array(readFileSync(outWasm)); + const result = await runWasm(consumerBytes, ["sqlite_basic"], built.imageBytes, options); + if (result.exitCode !== 0) { + throw new Error(`sqlite_basic exited ${result.exitCode}; stderr=${JSON.stringify(result.stderr)}`); + } + if (!result.stdout.includes("PASS")) { + throw new Error(`sqlite_basic did not report PASS: ${JSON.stringify(result.stdout)}`); + } + return "sqlite_basic linked against poured sqlite keg and reported PASS"; +} + +async function runWasm( + programBytes: Uint8Array, + argv: string[], + rootfsImage: Uint8Array, + options: CliOptions, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + let stdout = ""; + let stderr = ""; + const host = new NodeKernelHost({ + maxWorkers: 4, + rootfsImage, + onStdout: (_pid, data) => { stdout += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { stderr += new TextDecoder().decode(data); }, + }); + await host.init(); + let timeout: ReturnType | undefined; + try { + const exitPromise = host.spawn(toArrayBuffer(programBytes), argv, { + env: [ + "PATH=/home/linuxbrew/.linuxbrew/bin:/usr/bin:/bin", + "HOME=/tmp", + "TMPDIR=/tmp", + ], + cwd: "/", + stdin: new Uint8Array(), + }); + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error(`${argv[0]} timed out after ${options.timeoutMs}ms`)), + options.timeoutMs, + ); + }); + const exitCode = await Promise.race([exitPromise, timeoutPromise]); + return { exitCode, stdout, stderr }; + } finally { + if (timeout) clearTimeout(timeout); + await host.destroy().catch(() => {}); + } +} + +async function runCase( + outcomes: Outcome[], + options: CliOptions, + tapCommit: string, + name: string, + fn: () => Promise, +): Promise { + writeCurrentRun(options, { + status: "running", + tapCommit, + outcomes, + currentCase: name, + }); + const started = Date.now(); + try { + const details = await fn(); + outcomes.push({ name, status: "pass", durationMs: Date.now() - started, details }); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + outcomes.push({ + name, + status: error instanceof SkipCase ? "skip" : "fail", + durationMs: Date.now() - started, + details: error.message, + error: error.stack ?? error.message, + }); + } + writeOutcomeLists(options.resultDir, outcomes); +} + +async function loadBottleBytes( + pkg: HomebrewVfsPackagePlan, + options: CliOptions, +): Promise { + if (pkg.url.startsWith("file://")) { + return new Uint8Array(readFileSync(fileURLToPath(pkg.url))); + } + + const cachePath = join(options.bottleCache, `${pkg.sha256}.tar.gz`); + if (existsSync(cachePath)) return new Uint8Array(readFileSync(cachePath)); + if (!pkg.url.startsWith("https://")) { + throw new Error( + `package ${pkg.name}@${pkg.version} bottle URL must be https:// or file://, got ${pkg.url}`, + ); + } + + const bytes = await fetchHomebrewBottleBytes(pkg.url); + writeFileSync(cachePath, bytes); + return bytes; +} + +function findPackageVersion(fs: MemoryFileSystem, formula: string): string { + const info = JSON.parse(new TextDecoder().decode(readVfsFile(fs, "/etc/kandelo/homebrew-vfs.json"))); + const pkg = info.packages?.find((candidate: { name?: string }) => candidate.name === formula); + if (!pkg) throw new Error(`package ${formula} missing from /etc/kandelo/homebrew-vfs.json`); + const keg = String(pkg.keg ?? ""); + const prefix = `${CELLAR}/${formula}/`; + if (!keg.startsWith(prefix)) throw new Error(`unexpected sqlite keg path: ${keg}`); + return keg.slice(prefix.length); +} + +function readVfsFile(fs: MemoryFileSystem, path: string): Uint8Array { + const st = fs.stat(path); + const fd = fs.open(path, 0, 0); + try { + const out = new Uint8Array(st.size); + let offset = 0; + while (offset < out.byteLength) { + const n = fs.read(fd, out.subarray(offset), null, out.byteLength - offset); + if (n <= 0) break; + offset += n; + } + return out.subarray(0, offset); + } finally { + fs.close(fd); + } +} + +function createFs(maxBytes: number): MemoryFileSystem { + const SharedArrayBufferCtor = SharedArrayBuffer as new ( + byteLength: number, + options?: { maxByteLength?: number }, + ) => SharedArrayBuffer; + return MemoryFileSystem.create( + new SharedArrayBufferCtor(maxBytes, { maxByteLength: maxBytes }), + maxBytes, + ); +} + +function parseArgs(args: string[]): CliOptions { + const defaultResultDir = join( + repoRoot, + "test-runs", + "homebrew-package-node-smoke", + new Date().toISOString().replace(/[-:]/g, "").replace(/\..+$/, "Z"), + ); + const options: CliOptions = { + resultDir: process.env.KANDELO_TEST_RESULT_DIR || defaultResultDir, + tapRoot: process.env.KANDELO_HOMEBREW_TAP_ROOT || "", + formulas: [], + arch: "wasm32", + bottleCache: "", + timeoutMs: 30_000, + maxBytes: 128 * 1024 * 1024, + beadId: process.env.KANDELO_BEAD_ID || "kd-1mr.2", + }; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + switch (arg) { + case "--result-dir": + options.resultDir = requireValue(args, ++i, arg); + break; + case "--tap-root": + options.tapRoot = requireValue(args, ++i, arg); + break; + case "--formula": + options.formulas.push(parseFormula(requireValue(args, ++i, arg))); + break; + case "--arch": + options.arch = parseArch(requireValue(args, ++i, arg)); + break; + case "--bottle-cache": + options.bottleCache = requireValue(args, ++i, arg); + break; + case "--timeout-ms": + options.timeoutMs = parsePositiveInt(requireValue(args, ++i, arg), arg); + break; + case "--max-bytes": + options.maxBytes = parseByteSize(requireValue(args, ++i, arg)); + break; + case "--bead-id": + options.beadId = requireValue(args, ++i, arg); + break; + case "--help": + case "-h": + usage(0); + break; + default: + usage(2, `unexpected argument ${arg}`); + } + } + + if (!options.tapRoot) usage(2, "--tap-root is required"); + if (options.formulas.length === 0) usage(2, "at least one --formula is required"); + options.resultDir = resolve(options.resultDir); + options.tapRoot = resolve(options.tapRoot); + options.bottleCache = options.bottleCache + ? resolve(options.bottleCache) + : join(options.resultDir, "bottle-cache"); + return options; +} + +function parseFormula(value: string): FormulaName { + if (value === "sqlite" || value === "bzip2" || value === "xz") return value; + usage(2, `--formula must be sqlite, bzip2, or xz, got ${value}`); +} + +function parseArch(value: string): HomebrewBottleArch { + if (value === "wasm32" || value === "wasm64") return value; + usage(2, `--arch must be wasm32 or wasm64, got ${value}`); +} + +function parsePositiveInt(value: string, flag: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) usage(2, `${flag} must be a positive integer`); + return parsed; +} + +function parseByteSize(value: string): number { + const match = /^([1-9][0-9]*)([kKmMgG]i?[bB]?|[bB])?$/.exec(value); + if (!match) usage(2, `--max-bytes must be a positive byte size, got ${value}`); + const amount = Number(match[1]); + const suffix = (match[2] ?? "b").toLowerCase(); + const multiplier = suffix.startsWith("g") ? 1024 ** 3 + : suffix.startsWith("m") ? 1024 ** 2 + : suffix.startsWith("k") ? 1024 + : 1; + return amount * multiplier; +} + +function requireValue(args: string[], index: number, flag: string): string { + const value = args[index]; + if (!value || value.startsWith("--")) usage(2, `${flag} requires a value`); + return value; +} + +function usage(code: number, message?: string): never { + if (message) console.error(`homebrew-package-node-smoke: ${message}`); + console.error(`usage: npx tsx scripts/homebrew-package-node-smoke.ts \\ + --tap-root --formula [--formula ...] \\ + [--arch ] [--result-dir ] [--bottle-cache ]`); + process.exit(code); +} + +function writeOutcomeLists(resultDir: string, outcomes: Outcome[]): void { + const listsDir = join(resultDir, "outcome-lists"); + mkdirSync(listsDir, { recursive: true }); + const passed = outcomes.filter((outcome) => outcome.status === "pass"); + const failed = outcomes.filter((outcome) => outcome.status === "fail"); + const skipped = outcomes.filter((outcome) => outcome.status === "skip"); + writeFileSync( + join(listsDir, "passed-tests.tsv"), + ["test\tduration_ms\tdetails", ...passed.map((outcome) => + `${outcome.name}\t${outcome.durationMs}\t${tsv(outcome.details ?? "")}`, + )].join("\n") + "\n", + ); + writeFileSync( + join(listsDir, "failed-tests.tsv"), + ["test\tduration_ms\terror", ...failed.map((outcome) => + `${outcome.name}\t${outcome.durationMs}\t${tsv(outcome.error ?? outcome.details ?? "")}`, + )].join("\n") + "\n", + ); + writeFileSync( + join(listsDir, "skipped-tests.tsv"), + ["test\treason", ...skipped.map((outcome) => + `${outcome.name}\t${tsv(outcome.details ?? "")}`, + )].join("\n") + "\n", + ); + writeFileSync(join(resultDir, "failures.json"), `${JSON.stringify(failed, null, 2)}\n`); +} + +function writeSummary( + options: CliOptions, + data: { + startedAt: Date; + completedAt: Date; + tapCommit: string; + outcomes: Outcome[]; + }, +): void { + const counts = countOutcomes(data.outcomes); + const summary = { + suite: "Homebrew package Node VFS smoke", + bead_id: options.beadId, + started_at: data.startedAt.toISOString(), + completed_at: data.completedAt.toISOString(), + duration_ms: data.completedAt.getTime() - data.startedAt.getTime(), + result_dir: options.resultDir, + tap_root: options.tapRoot, + tap_commit: data.tapCommit, + arch: options.arch, + formulas: options.formulas, + counts, + outcomes: data.outcomes, + artifacts: { + passed: join(options.resultDir, "outcome-lists", "passed-tests.tsv"), + failed: join(options.resultDir, "outcome-lists", "failed-tests.tsv"), + skipped: join(options.resultDir, "outcome-lists", "skipped-tests.tsv"), + failures: join(options.resultDir, "failures.json"), + current_run: join(options.resultDir, "current-run.json"), + }, + }; + writeFileSync(join(options.resultDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`); + writeFileSync(join(options.resultDir, "summary.md"), [ + "# Homebrew package Node VFS smoke", + "", + `Result dir: \`${options.resultDir}\``, + `Tap commit: \`${data.tapCommit}\``, + `Counts: ${counts.pass} pass, ${counts.fail} fail, ${counts.skip} skip`, + "", + "| Test | Status | Details |", + "|---|---:|---|", + ...data.outcomes.map((outcome) => + `| \`${outcome.name}\` | ${outcome.status} | ${outcome.details ? outcome.details.replace(/\|/g, "\\|") : ""} |`, + ), + "", + ].join("\n")); +} + +function writeCurrentRun( + options: CliOptions, + data: { + status: "running" | "complete" | "failed"; + startedAt?: Date; + tapCommit: string; + outcomes: Outcome[]; + currentCase: string; + }, +): void { + const counts = countOutcomes(data.outcomes); + const currentRun = { + suite: "homebrew-package-node-smoke", + bead_id: options.beadId, + worktree: repoRoot, + result_dir: options.resultDir, + status: data.status, + started_at: data.startedAt?.toISOString(), + updated_at: new Date().toISOString(), + current_case: data.currentCase, + progress: { + completed: data.outcomes.length, + total: options.formulas.length * 2, + pass: counts.pass, + fail: counts.fail, + skip: counts.skip, + }, + tap_root: options.tapRoot, + tap_commit: data.tapCommit, + command: { + cwd: repoRoot, + argv: process.argv, + }, + outcome_lists: { + passed: join(options.resultDir, "outcome-lists", "passed-tests.tsv"), + failed: join(options.resultDir, "outcome-lists", "failed-tests.tsv"), + skipped: join(options.resultDir, "outcome-lists", "skipped-tests.tsv"), + }, + stale_no_runner_threshold_seconds: 600, + expected_next: data.status === "running" + ? { deterministic: true, action: "continue current smoke case" } + : { deterministic: false, action: "suite terminal" }, + }; + const out = join(options.resultDir, "current-run.json"); + const tmp = `${out}.tmp`; + writeFileSync(tmp, `${JSON.stringify(currentRun, null, 2)}\n`); + renameSync(tmp, out); +} + +function countOutcomes(outcomes: Outcome[]): { pass: number; fail: number; skip: number } { + return { + pass: outcomes.filter((outcome) => outcome.status === "pass").length, + fail: outcomes.filter((outcome) => outcome.status === "fail").length, + skip: outcomes.filter((outcome) => outcome.status === "skip").length, + }; +} + +function readJsonFile(path: string): T { + return JSON.parse(readFileSync(path, "utf8")) as T; +} + +function gitRevParse(path: string): string { + return execFileSync("git", ["-C", path, "rev-parse", "HEAD"], { + encoding: "utf8", + }).trim(); +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +function tsv(value: string): string { + return value.replace(/\t/g, " ").replace(/\r?\n/g, "\\n"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From 4f4b2918a7313ff9dfdf7c1bdc1589c97d066089 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Tue, 30 Jun 2026 01:39:23 -0400 Subject: [PATCH 2/3] docs: design generic Homebrew browser smoke --- ...0-homebrew-generic-browser-smoke-design.md | 520 ++++++++++++++++++ 1 file changed, 520 insertions(+) create mode 100644 docs/plans/2026-06-30-homebrew-generic-browser-smoke-design.md diff --git a/docs/plans/2026-06-30-homebrew-generic-browser-smoke-design.md b/docs/plans/2026-06-30-homebrew-generic-browser-smoke-design.md new file mode 100644 index 0000000000..77d4d1a657 --- /dev/null +++ b/docs/plans/2026-06-30-homebrew-generic-browser-smoke-design.md @@ -0,0 +1,520 @@ +# Generic Homebrew Browser Smoke Design + +Date: 2026-06-30 + +Tracked work: + +- `kd-1mr` - Port all current Kandelo packages to Homebrew. +- `kd-1mr.2` - Port sqlite, bzip2, and xz Homebrew pilot. +- `kd-1mr.2.1` - Add generic browser smoke for non-hello Homebrew packages. +- Parent implementation evidence: `kd-1mr.2` implementation commit + `440ac7e5e8876b54345682f23dde271889682c40` added the sqlite, bzip2, + and xz pilot Formulae, generic Node smoke, non-hello link manifests, and + sidecar browser-smoke status fields. + +This is a design artifact. It does not implement the browser harness, change +sidecar schemas, publish Homebrew bottles, or mark any non-hello package as +browser-compatible. + +## Problem Statement + +The Homebrew pilot can now build and smoke sqlite, bzip2, and xz through the +Node Homebrew VFS path, but browser compatibility is still effectively +hardcoded to the original `hello` smoke. That leaves generated sidecars with +truthful `browser_smoke` skips for non-hello packages, but no reusable way to +turn package-specific browser evidence into: + +- a browser-compatible sidecar claim for wasm32 packages that actually boot + and run in the browser host; +- a failed or skipped browser-smoke outcome when the browser host, VFS image, + package command, or validation consumer does not work; +- durable passed, failed, and skipped browser outcome-list artifacts with + reasons. + +The new path must prove browser behavior through the normal browser product +surface: a precomposed Homebrew-derived VFS image, the Kandelo browser app, +the browser kernel host, a real terminal or runtime process, and package +commands that run from the poured Homebrew prefix. + +## Non-Goals + +- Do not add package-specific browser demo branches for sqlite, bzip2, or xz. +- Do not mark browser support from Node smoke, bottle build success, or VFS + image construction alone. +- Do not require guest `brew install`; the supported path is still + precomposed VFS images built from sidecars and verified bottle bytes. +- Do not weaken ABI, cache-key, bottle sha, byte-count, link-manifest, or + receipt validation to get a browser image to boot. +- Do not make full SQLite upstream project tests part of this browser smoke. + The browser smoke only proves the package-specific consumer case. +- Do not make wasm64 browser compatibility claims. The existing sidecar + wrapper only permits browser success for wasm32, and the gallery path is + wasm32-only. + +## Users And Operator Workflows + +### Package Porter + +The porter runs one command against a generated or trusted tap root and gets +per-formula browser status: + +```bash +npx tsx scripts/homebrew-package-browser-smoke.ts \ + --tap-root /path/to/kandelo-homebrew \ + --formula sqlite \ + --formula bzip2 \ + --formula xz \ + --arch wasm32 \ + --result-dir test-runs/homebrew-package-browser-smoke +``` + +The command builds candidate Homebrew VFS images, launches the browser app, +runs package-specific checks, and writes summary plus outcome-list artifacts. + +### Trusted Publisher + +The trusted workflow uses the browser-smoke summary to decide whether the +final sidecars may record: + +- `runtime_support = ["node", "browser"]` and + `browser_compatible = true`, when the wasm32 browser smoke passes; or +- `runtime_support = ["node"]`, `browser_compatible = false`, and a failed or + skipped `browser_smoke` outcome with artifact paths and reasons. + +Browser gallery assets are generated only after final metadata records wasm32 +success and `browser_compatible = true`. + +### Maintainer Reviewer + +The reviewer checks that the browser outcome is evidence-backed, not inferred. +They should be able to inspect the command, browser URL, terminal output, +VFS build report, screenshot or trace, and passed/failed/skipped TSV files for +each package. + +### Debugger + +The debugger needs failures to identify the layer: sidecar planning, bottle +fetch, VFS build, SQLite consumer compilation, Vite startup, browser app boot, +cross-origin isolation, terminal readiness, package command exit, output +matching, or sidecar finalization. + +## Current State + +The parent Homebrew pilot commit contains these relevant surfaces: + +- `scripts/homebrew-package-node-smoke.ts` materializes Homebrew sidecars into + VFS images and runs package-specific Node smokes. It writes `summary.json`, + `summary.md`, `failures.json`, `current-run.json`, and + `outcome-lists/{passed,failed,skipped}-tests.tsv`. +- `images/vfs/scripts/build-homebrew-vfs-image.ts` builds precomposed VFS + images from Homebrew sidecars and verified bottle bytes. +- `apps/browser-demos/test/kandelo-homebrew.spec.ts` has the trusted `hello` + browser smoke. It boots the browser app with `?vfs=`, waits for + the terminal prompt, types `/home/linuxbrew/.linuxbrew/bin/hello --version`, + and checks terminal output. +- `scripts/homebrew-generate-sidecars-from-env.sh` can record browser smoke + as `success`, `failed`, or `skipped` through environment variables. Success + requires VFS image, VFS report, browser URL, and browser command evidence. +- `homebrew/kandelo-homebrew/Kandelo/provenance.schema.json` already supports + a `browser_smoke` outcome list with `passed`, `failed`, `skipped`, and + `skip_reason` strings. No schema change is required to record package-level + browser case names and artifact paths as strings. + +The child bead worktree created for this design was based on convoy commit +`f4339836e8c9c4b1fc2de7f4d931856c51a74432`, which does not contain the parent +Homebrew pilot files. Implementation for `kd-1mr.2.1` should either stack on +`440ac7e5e8876b54345682f23dde271889682c40` or wait until that parent work is +merged before editing the harness. + +## Architecture + +Add a reusable TypeScript browser smoke runner: + +```text +scripts/homebrew-package-browser-smoke.ts +``` + +The runner should mirror the Node smoke runner's contract: + +```text +tap root + formula list + arch + | + v +read Kandelo/metadata.json + | + v +plan and build candidate Homebrew VFS images + - verify ABI, cache key when supplied, bottle sha, bottle bytes + - load link manifests + - do not require browser_compatible=true before the smoke runs + | + v +optionally inject validation-only smoke artifacts + - sqlite_basic.wasm for sqlite + | + v +save one browser candidate image per formula + | + v +serve image through Vite/browser app + | + v +run package-specific terminal commands + | + v +write browser summary and outcome-list artifacts + | + v +final sidecar generation consumes summary +``` + +The important detail is the planning mode. Before a package has browser +evidence, its sidecar must not yet say `browser_compatible = true`. The smoke +runner therefore must not call `planHomebrewVfs()` with `runtime: "browser"` +for candidate images. It should omit the runtime filter, or use the existing +Node-compatible success metadata, then treat the browser run as the evidence +that may update final sidecars. The stricter `runtime: "browser"` check is +appropriate after final metadata is generated, especially for gallery asset +creation. + +## Package Smoke Cases + +Keep package behavior in a small shared case registry, for example: + +```text +scripts/homebrew-package-smoke-cases.ts +``` + +The Node and browser runners do not need identical mechanics, but they should +share the same package intent: command, expected output, minimum success +threshold, and unsupported reasons. + +### Bzip2 + +Required browser case: + +```bash +/home/linuxbrew/.linuxbrew/bin/bzip2 --help +``` + +Expected output should match `/bzip2/i` in terminal text. `--help` is a good +first check because the parent Node smoke used it to avoid `bzip2` writing +compressed bytes to a terminal. + +Preferred stronger case: + +```bash +cd /tmp && +printf 'kandelo bzip2 browser smoke\n' > bzip2.in && +/home/linuxbrew/.linuxbrew/bin/bzip2 -k bzip2.in && +/home/linuxbrew/.linuxbrew/bin/bzip2 -dc bzip2.in.bz2 +``` + +Expected output is the original text. This avoids writing compressed bytes to +the PTY. If this round trip is flaky because of shell or file semantics, the +harness should mark that specific case skipped with a reason, not convert the +package to browser-compatible on a hidden workaround. + +### Xz + +Required browser case: + +```bash +/home/linuxbrew/.linuxbrew/bin/xz --version +``` + +Expected output should match `/xz/i`. + +Preferred stronger case: + +```bash +cd /tmp && +printf 'kandelo xz browser smoke\n' > xz.in && +/home/linuxbrew/.linuxbrew/bin/xz -k xz.in && +/home/linuxbrew/.linuxbrew/bin/xz -dc xz.in.xz +``` + +Expected output is the original text. As with bzip2, round-trip inability is a +case result, not a reason to fake success. + +### SQLite + +SQLite is a library package, so the browser smoke must run a test-only +consumer. Reuse the parent Node smoke's idea: + +1. Extract `include/sqlite3.h`, `include/sqlite3ext.h`, and + `lib/libsqlite3.a` from the poured keg in the candidate VFS. +2. Compile `packages/registry/sqlite/test/sqlite_basic.c` with the + worktree-local SDK into `sqlite_basic.wasm`. +3. Inject the validation-only Wasm into the candidate browser VFS at a path + such as `/usr/local/kandelo-smoke/bin/sqlite_basic`. +4. Boot the image in the browser and run: + +```bash +/usr/local/kandelo-smoke/bin/sqlite_basic +``` + +Browser compatibility for sqlite requires the consumer to exit 0 and print +the existing `PASS` marker. A library bottle that builds, pours, or links is +not sufficient by itself. + +If consumer compilation is unavailable in a particular environment, record +`browser_smoke` as skipped with the compile blocker and artifact path. Do not +mark sqlite as browser-compatible. + +## Harness Behavior + +The browser runner should: + +- accept `--tap-root`, repeated `--formula`, `--arch`, `--result-dir`, + `--bottle-cache`, `--timeout-ms`, `--max-bytes`, and `--bead-id`; +- reject or skip `--arch wasm64` for browser compatibility with a clear + reason; +- create one result subdirectory per formula and arch; +- build a candidate VFS image with Homebrew VFS metadata preserved in image + metadata; +- use a deterministic static fixture path under + `apps/browser-demos/public/__kandelo-homebrew-smoke//` or a local + static server so `?vfs=` can fetch the image; +- start Vite with `KANDELO_BROWSER_TEST_NO_HMR=1`; +- launch Chromium with SharedArrayBuffer support, matching the existing + browser smoke baseline; +- navigate to `/?vfs=`; +- wait for the terminal prompt rather than sleeping blindly; +- type a package command wrapped with a unique pass/fail sentinel; +- collect terminal text, console warnings/errors, page errors, request + failures, screenshots, and Playwright trace or video when available; +- write `current-run.json` during execution so interrupted runs are + diagnosable; +- always write passed, failed, and skipped outcome lists, even when one list is + empty. + +The existing app-level `LiveKernelHost.runShellCommand()` and +`/etc/kandelo/demo.json` `autoCommand` path are useful references, but the +first implementation should keep command execution in the harness. That avoids +creating product-visible auto-run metadata solely for tests. If terminal +driving proves too flaky, a later refinement can add smoke-only demo metadata +to the generated VFS image and still boot through the normal app path. + +## Sidecar Finalization + +Avoid a circular metadata dependency by splitting candidate sidecars from +final sidecars: + +1. Generate candidate sidecars after bottle build with browser smoke skipped + or absent. These sidecars are enough to materialize a candidate VFS image. +2. Run Node and browser smokes against the candidate sidecars. +3. Regenerate final sidecars with Node and browser summary inputs. +4. Publish final sidecars, provenance, and release assets. + +Extend `scripts/homebrew-generate-sidecars-from-env.sh` to accept an optional +browser summary path: + +```text +KANDELO_HOMEBREW_BROWSER_SMOKE_SUMMARY=/path/to/summary.json +``` + +When present, the wrapper should populate the provenance `browser_smoke` +outcome from the summary: + +- `status = success` only when all required package browser cases pass; +- `status = failed` when any required case fails; +- `status = skipped` when the package is unsupported for browser smoke or a + prerequisite is missing; +- `passed`, `failed`, and `skipped` arrays contain concrete case names, + reasons, and artifact paths as strings. + +The existing environment variables remain valid for manual or transitional +use: + +```text +KANDELO_HOMEBREW_BROWSER_SMOKE_STATUS +KANDELO_HOMEBREW_BROWSER_SMOKE_REASON +KANDELO_HOMEBREW_VFS_IMAGE +KANDELO_HOMEBREW_VFS_REPORT +KANDELO_HOMEBREW_BROWSER_SMOKE_URL +KANDELO_HOMEBREW_BROWSER_SMOKE_COMMAND +KANDELO_HOMEBREW_GALLERY_ROOT +``` + +Final metadata rules: + +- `browser_compatible = true` only for wasm32 packages with successful + browser smoke. +- `runtime_support` includes `browser` only when `browser_compatible = true`. +- Browser failure does not have to make the bottle status `failed` if the + package is still valid for Node. It should remain a successful Node-capable + bottle with a failed browser-smoke validation outcome. +- Gallery creation runs only after final metadata passes + `homebrew-validate` and records browser compatibility. + +## Outcome Artifacts + +For each browser smoke run, write: + +- `summary.json` - machine-readable suite result, counts, tap commit, + image/report paths, formulas, arch, browser URL, and command list. +- `summary.md` - reviewer-readable table of cases and statuses. +- `current-run.json` - live progress, current case, expected next action, and + stale-run threshold. +- `failures.json` - complete failed case objects. +- `outcome-lists/passed-tests.tsv` - case, duration, details, artifact path. +- `outcome-lists/failed-tests.tsv` - case, duration, error, artifact path. +- `outcome-lists/skipped-tests.tsv` - case, reason, artifact path. +- `--homebrew.vfs.zst` - candidate image or a pointer to its + served copy. +- `--homebrew-vfs-report.json` - VFS builder report. +- screenshots and traces when the browser page fails, times out, or produces + unexpected output. + +Skipped lists must include reasons, for example: + +- `wasm64 browser compatibility is unsupported by the current Homebrew browser + sidecar path`; +- `sqlite consumer compilation failed before browser launch`; +- `candidate sidecar planning failed because package has no wasm32 bottle`; +- `browser terminal did not become ready before timeout`. + +## Implementation Sequence + +1. Stack the worktree on the parent Homebrew pilot commit + `440ac7e5e8876b54345682f23dde271889682c40` or wait until it is merged. +2. Refactor shared outcome-list helpers from + `scripts/homebrew-package-node-smoke.ts` into a small local helper module. +3. Add shared package smoke case definitions for sqlite, bzip2, and xz. +4. Add `scripts/homebrew-package-browser-smoke.ts` with candidate VFS + building, optional sqlite consumer injection, Vite startup, Playwright + browser execution, terminal command driving, and artifact writing. +5. Add targeted unit coverage for command planning, unsupported-case + classification, and outcome-list generation. +6. Extend `scripts/homebrew-generate-sidecars-from-env.sh` to consume browser + summary JSON and populate the existing `browser_smoke` provenance outcome. +7. Update the reusable Homebrew publish workflow to run the browser smoke only + for wasm32 entries after candidate sidecars are available and before final + sidecars are published. +8. Keep `apps/browser-demos/test/kandelo-homebrew.spec.ts` for gallery gating + and hello coverage. Add only minimal coverage there if the generic runner + exposes a browser-app regression not covered by the direct script. +9. Generate browser gallery assets only from final metadata that passed the + browser smoke. +10. Update `docs/homebrew-publishing.md` with the generic browser smoke flow, + candidate/final sidecar sequence, outcome artifacts, and unsupported wasm64 + boundary. + +## Test And Documentation Plan + +For the implementation PR, run and record: + +- `npx tsx scripts/homebrew-package-node-smoke.ts --tap-root --formula sqlite --formula bzip2 --formula xz --arch wasm32 --result-dir ` +- `npx tsx scripts/homebrew-package-browser-smoke.ts --tap-root --formula sqlite --formula bzip2 --formula xz --arch wasm32 --result-dir ` +- `cargo run --release -p xtask -- homebrew-validate --tap-root ` +- `cd host && npx vitest run test/homebrew-vfs-planner.test.ts test/homebrew-vfs-builder.test.ts test/homebrew-vfs-fetch.test.ts` +- `cd apps/browser-demos && npx playwright test test/kandelo-homebrew.spec.ts --project=chromium` +- `scripts/validate-software-gallery.mjs ` when gallery assets are generated. + +If implementation changes shared host runtime, VFS semantics, ABI-adjacent +code, package bytes, or browser app boot behavior, broaden verification using +the suites in `CLAUDE.md` and `docs/agent-guidance/validation.md`. At minimum, +publish exactly which full-gate commands were run and which were not run. + +Documentation updates: + +- `docs/homebrew-publishing.md` for the generic browser smoke workflow and + sidecar finalization semantics. +- Package-specific notes only if the implementation changes package build + outputs or smoke expectations. +- No `docs/package-management.md` update unless package archive, resolver, or + revision semantics change. + +## Alternatives Considered + +### Add One Playwright Spec Per Package + +Rejected. Per-package specs would reproduce the current `hello` hardcoding and +make every new Formula require browser test plumbing. A generic runner keeps +package specifics in data and produces uniform artifacts. + +### Run Browser Smokes Through The Low-Level Test Runner Page + +Rejected as the default. `apps/browser-demos/pages/test-runner` is useful for +isolated Wasm binaries, but Homebrew browser support is a product claim about +precomposed VFS images, the Kandelo app boot path, browser host setup, and +terminal-visible execution. + +### Require `browser_compatible=true` Before Building The Smoke Image + +Rejected because it is circular. The smoke must build a candidate image before +browser compatibility is known. The strict browser runtime filter belongs +after final sidecar generation. + +### Mark Browser Compatibility From Node Smoke + +Rejected. Node and browser are peer hosts, and browser-specific failures in +fetching, SharedArrayBuffer setup, VFS restore, terminal PTY behavior, or +worker lifecycle would be hidden. + +### Put `autoCommand` In Every Smoke Image + +Deferred. Image-declared `autoCommand` is a real product feature and should +not be used only to simplify the first test harness. It remains a fallback if +terminal driving is demonstrably flaky. + +## Risks And Mitigations + +Metadata circularity: + +- Risk: the harness needs browser-compatible metadata to build the image that + proves browser compatibility. +- Mitigation: use candidate sidecars without a browser runtime filter, then + regenerate final sidecars from smoke summaries. + +Terminal flakiness: + +- Risk: prompt detection or text wrapping makes browser checks unreliable. +- Mitigation: use unique sentinels, bounded waits, screenshots/traces on + failure, and one command at a time. Avoid relying on exact full terminal + layout. + +Library smoke ambiguity: + +- Risk: sqlite could be marked compatible after only headers and libraries are + linked into the VFS. +- Mitigation: require a compiled `sqlite_basic.wasm` consumer to run in the + browser before setting browser compatibility. + +Package command overreach: + +- Risk: bzip2/xz round trips could fail because shell utilities are missing + rather than because the package is broken. +- Mitigation: make version/help the required first smoke, use Bash builtins for + file creation, and record round-trip skips separately if needed. + +Workflow time and artifact size: + +- Risk: one browser boot per formula increases trusted publication time. +- Mitigation: run only wasm32 browser candidates, keep timeouts explicit, cache + bottle bytes, and preserve per-formula images only as run artifacts unless a + package becomes gallery-eligible. + +Stale branch base: + +- Risk: implementation starts from a branch that lacks the parent Homebrew + pilot files. +- Mitigation: stack on the parent implementation commit or merged main before + editing. Record the base in bead metadata. + +## Open Questions + +- Should browser compatibility require the bzip2/xz file round-trip cases, or + is version/help sufficient for the first generic harness? +- Should the browser runner use Playwright Test for trace integration, or a + direct Playwright script like `browser-sqlite-official-runner.ts` for simpler + process control? +- Should sqlite consumer injection be local to the smoke runner, or should + `build-homebrew-vfs-image.ts` grow a generic `--inject-file` test-only + option? +- Should the trusted workflow run browser smoke before or after Node smoke + finalization, or should both consume the same candidate sidecar root and + regenerate final sidecars once? +- Which browser projects beyond Chromium should become required after the + generic harness is stable? From 1332b60c92a7e2fc9c499a6ab5286a24c6fc99ef Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Tue, 30 Jun 2026 03:39:25 -0400 Subject: [PATCH 3/3] homebrew: add generic browser smoke runner --- .../reusable-homebrew-bottle-publish.yml | 126 +-- .../pages/homebrew-smoke/index.html | 35 + .../pages/homebrew-smoke/main.ts | 126 +++ apps/browser-demos/vite.config.ts | 1 + docs/homebrew-publishing.md | 48 +- host/src/browser-kernel-host.ts | 15 +- host/src/default-rootfs-url.ts | 3 + host/test/browser-kernel.test.ts | 31 +- scripts/dev-shell.sh | 1 + .../homebrew-generate-sidecars-from-env.sh | 83 +- scripts/homebrew-package-browser-smoke.ts | 897 ++++++++++++++++++ scripts/homebrew-package-node-smoke.ts | 61 +- scripts/homebrew-package-smoke-cases.ts | 91 ++ scripts/homebrew-smoke-outcomes.ts | 86 ++ .../homebrew-smoke-helpers.test.ts | 82 ++ 15 files changed, 1546 insertions(+), 140 deletions(-) create mode 100644 apps/browser-demos/pages/homebrew-smoke/index.html create mode 100644 apps/browser-demos/pages/homebrew-smoke/main.ts create mode 100644 host/src/default-rootfs-url.ts create mode 100644 scripts/homebrew-package-browser-smoke.ts create mode 100644 scripts/homebrew-package-smoke-cases.ts create mode 100644 scripts/homebrew-smoke-outcomes.ts create mode 100644 tests/package-system/homebrew-smoke-helpers.test.ts diff --git a/.github/workflows/reusable-homebrew-bottle-publish.yml b/.github/workflows/reusable-homebrew-bottle-publish.yml index 82b19c9133..1dd60641fe 100644 --- a/.github/workflows/reusable-homebrew-bottle-publish.yml +++ b/.github/workflows/reusable-homebrew-bottle-publish.yml @@ -340,75 +340,90 @@ jobs: bash scripts/dev-shell.sh bash "$sidecar_script" browser_gallery_root="" - if [ "$KANDELO_HOMEBREW_FORMULA" = "hello" ] && [ "$KANDELO_HOMEBREW_ARCH" = "wasm32" ]; then - browser_vfs_root="$RUNNER_TEMP/homebrew-browser-vfs" + if [ "$KANDELO_HOMEBREW_ARCH" = "wasm32" ]; then + browser_result_dir="$RUNNER_TEMP/homebrew-browser-smoke" browser_gallery_root="$RUNNER_TEMP/homebrew-browser-gallery" - browser_public_dir="$GITHUB_WORKSPACE/kandelo/apps/browser-demos/public/__kandelo-smoke" browser_port="${KANDELO_PLAYWRIGHT_PORT:-5401}" - browser_vfs="$browser_vfs_root/homebrew-hello.vfs.zst" - browser_report="$browser_vfs_root/homebrew-hello.vfs-report.json" - browser_url="http://127.0.0.1:${browser_port}/__kandelo-smoke/homebrew-hello.vfs.zst" - mkdir -p "$browser_vfs_root" "$browser_gallery_root" "$browser_public_dir" - - bash scripts/dev-shell.sh npx tsx images/vfs/scripts/build-homebrew-vfs-image.ts \ - --metadata "$sidecar_root/Kandelo/metadata.json" \ - --tap-root "$sidecar_root" \ - --package hello \ - --arch wasm32 \ - --runtime node \ - --out "$browser_vfs" \ - --report "$browser_report" \ - --bottle-cache "$RUNNER_TEMP/homebrew-bottle-cache" \ - --write-profile - cp "$browser_vfs" "$browser_public_dir/homebrew-hello.vfs.zst" + mkdir -p "$browser_result_dir" "$browser_gallery_root" ( cd apps/browser-demos npx playwright install chromium --with-deps - KANDELO_PLAYWRIGHT_PORT="$browser_port" \ - KANDELO_BROWSER_HELLO_VFS_URL="$browser_url" \ - npx playwright test test/kandelo-homebrew.spec.ts \ - --project=chromium \ - --grep "Homebrew hello VFS image boots" ) - export KANDELO_HOMEBREW_BROWSER_SMOKE_STATUS="success" - export KANDELO_HOMEBREW_VFS_IMAGE="$browser_vfs" - export KANDELO_HOMEBREW_VFS_REPORT="$browser_report" - export KANDELO_HOMEBREW_GALLERY_ROOT="$browser_gallery_root" - export KANDELO_HOMEBREW_BROWSER_SMOKE_URL="$browser_url" - export KANDELO_HOMEBREW_BROWSER_SMOKE_COMMAND="/home/linuxbrew/.linuxbrew/bin/hello --version" + set +e + KANDELO_PLAYWRIGHT_PORT="$browser_port" \ + bash scripts/dev-shell.sh npx tsx scripts/homebrew-package-browser-smoke.ts \ + --tap-root "$sidecar_root" \ + --formula "$KANDELO_HOMEBREW_FORMULA" \ + --arch wasm32 \ + --result-dir "$browser_result_dir" \ + --bottle-cache "$RUNNER_TEMP/homebrew-bottle-cache" \ + --port "$browser_port" \ + --run-id "gha-${KANDELO_HOMEBREW_FORMULA}-${KANDELO_HOMEBREW_ARCH}-${GITHUB_RUN_ID}" + browser_smoke_exit=$? + set -e + + if [ ! -f "$browser_result_dir/summary.json" ]; then + echo "::error::browser smoke did not produce $browser_result_dir/summary.json" + exit 1 + fi + + export KANDELO_HOMEBREW_BROWSER_SMOKE_SUMMARY="$browser_result_dir/summary.json" rm -rf "$sidecar_root" mkdir -p "$sidecar_root" bash scripts/dev-shell.sh bash "$sidecar_script" - bash scripts/homebrew-create-browser-gallery.sh \ - --metadata "$sidecar_root/Kandelo/metadata.json" \ - --image "$browser_vfs" \ - --report "$browser_report" \ - --out "$browser_gallery_root" \ - --formula hello \ - --title "GNU hello Homebrew VFS" \ - --description "GNU hello poured from the published Automattic/kandelo-homebrew bottle into a browser-smoked Kandelo VFS image." - node scripts/validate-software-gallery.mjs \ - --gallery "$browser_gallery_root/gallery.json" \ - --index "$browser_gallery_root/index.toml" - - if [ "$KANDELO_HOMEBREW_DRY_RUN" != "true" ]; then - if ! gh release view "$KANDELO_HOMEBREW_RELEASE_TAG" --repo "$KANDELO_HOMEBREW_TAP_REPOSITORY" >/dev/null 2>&1; then - gh release create "$KANDELO_HOMEBREW_RELEASE_TAG" \ - --repo "$KANDELO_HOMEBREW_TAP_REPOSITORY" \ - --target "$(git -C "$GITHUB_WORKSPACE/tap" rev-parse HEAD)" \ - --title "$KANDELO_HOMEBREW_RELEASE_TAG" \ - --notes "Kandelo Homebrew bottle sidecars and browser gallery assets." + if [ "$browser_smoke_exit" -ne 0 ]; then + echo "::warning::browser smoke exited $browser_smoke_exit; final sidecars record the browser outcome from $browser_result_dir/summary.json" + fi + + if jq -e \ + --arg formula "$KANDELO_HOMEBREW_FORMULA" \ + --arg arch "$KANDELO_HOMEBREW_ARCH" \ + '.packages[] | select(.formula == $formula and .arch == $arch and .status == "success")' \ + "$browser_result_dir/summary.json" >/dev/null; then + browser_vfs="$(jq -r \ + --arg formula "$KANDELO_HOMEBREW_FORMULA" \ + --arg arch "$KANDELO_HOMEBREW_ARCH" \ + '.packages[] | select(.formula == $formula and .arch == $arch) | .vfs_image' \ + "$browser_result_dir/summary.json")" + browser_report="$(jq -r \ + --arg formula "$KANDELO_HOMEBREW_FORMULA" \ + --arg arch "$KANDELO_HOMEBREW_ARCH" \ + '.packages[] | select(.formula == $formula and .arch == $arch) | .vfs_report' \ + "$browser_result_dir/summary.json")" + + if [ "$KANDELO_HOMEBREW_FORMULA" = "hello" ]; then + bash scripts/homebrew-create-browser-gallery.sh \ + --metadata "$sidecar_root/Kandelo/metadata.json" \ + --image "$browser_vfs" \ + --report "$browser_report" \ + --out "$browser_gallery_root" \ + --formula hello \ + --title "GNU hello Homebrew VFS" \ + --description "GNU hello poured from the published Automattic/kandelo-homebrew bottle into a browser-smoked Kandelo VFS image." + node scripts/validate-software-gallery.mjs \ + --gallery "$browser_gallery_root/gallery.json" \ + --index "$browser_gallery_root/index.toml" + + if [ "$KANDELO_HOMEBREW_DRY_RUN" != "true" ]; then + if ! gh release view "$KANDELO_HOMEBREW_RELEASE_TAG" --repo "$KANDELO_HOMEBREW_TAP_REPOSITORY" >/dev/null 2>&1; then + gh release create "$KANDELO_HOMEBREW_RELEASE_TAG" \ + --repo "$KANDELO_HOMEBREW_TAP_REPOSITORY" \ + --target "$(git -C "$GITHUB_WORKSPACE/tap" rev-parse HEAD)" \ + --title "$KANDELO_HOMEBREW_RELEASE_TAG" \ + --notes "Kandelo Homebrew bottle sidecars and browser gallery assets." + fi + gh release upload "$KANDELO_HOMEBREW_RELEASE_TAG" \ + "$browser_gallery_root/gallery.json" \ + "$browser_gallery_root/index.toml" \ + "$browser_gallery_root"/*.tar.zst \ + --repo "$KANDELO_HOMEBREW_TAP_REPOSITORY" \ + --clobber + fi fi - gh release upload "$KANDELO_HOMEBREW_RELEASE_TAG" \ - "$browser_gallery_root/gallery.json" \ - "$browser_gallery_root/index.toml" \ - "$browser_gallery_root"/*.tar.zst \ - --repo "$KANDELO_HOMEBREW_TAP_REPOSITORY" \ - --clobber fi fi @@ -460,6 +475,7 @@ jobs: path: | ${{ runner.temp }}/homebrew-bottle/** ${{ runner.temp }}/homebrew-browser-gallery/** + ${{ runner.temp }}/homebrew-browser-smoke/** ${{ runner.temp }}/homebrew-browser-vfs/** ${{ runner.temp }}/homebrew-sidecars/** ${{ runner.temp }}/homebrew-*.txt diff --git a/apps/browser-demos/pages/homebrew-smoke/index.html b/apps/browser-demos/pages/homebrew-smoke/index.html new file mode 100644 index 0000000000..b560e65ff2 --- /dev/null +++ b/apps/browser-demos/pages/homebrew-smoke/index.html @@ -0,0 +1,35 @@ + + + + + + Kandelo Homebrew Browser Smoke + + + +
Loading
+

+    
+  
+
diff --git a/apps/browser-demos/pages/homebrew-smoke/main.ts b/apps/browser-demos/pages/homebrew-smoke/main.ts
new file mode 100644
index 0000000000..4b63efbddd
--- /dev/null
+++ b/apps/browser-demos/pages/homebrew-smoke/main.ts
@@ -0,0 +1,126 @@
+import { BrowserKernel } from "@host/browser-kernel-host";
+import { ABI_VERSION } from "@host/generated/abi";
+import { MemoryFileSystem } from "@host/vfs/memory-fs";
+import kernelWasmUrl from "@kernel-wasm?url";
+
+interface HomebrewSmokeRequest {
+  vfsUrl: string;
+  argv: string[];
+  timeoutMs?: number;
+  cwd?: string;
+  env?: string[];
+}
+
+interface HomebrewSmokeResult {
+  exitCode: number;
+  stdout: string;
+  stderr: string;
+  combined: string;
+  durationMs: number;
+}
+
+declare global {
+  interface Window {
+    __homebrewSmokeReady: boolean;
+    __runHomebrewSmoke: (request: HomebrewSmokeRequest) => Promise;
+  }
+}
+
+const statusEl = document.getElementById("status")!;
+const logEl = document.getElementById("log")!;
+const decoder = new TextDecoder();
+
+let kernelBytes: ArrayBuffer | null = null;
+
+function appendLog(text: string): void {
+  logEl.textContent += text;
+}
+
+async function fetchBytes(url: string, label: string): Promise {
+  const response = await fetch(url);
+  if (!response.ok) {
+    throw new Error(`${label} fetch failed: ${response.status} ${response.statusText}`);
+  }
+  return response.arrayBuffer();
+}
+
+function timeoutAfter(ms: number): Promise {
+  return new Promise((_, reject) => {
+    window.setTimeout(() => reject(new Error("TIMEOUT")), ms);
+  });
+}
+
+async function runHomebrewSmoke(request: HomebrewSmokeRequest): Promise {
+  if (!kernelBytes) throw new Error("kernel wasm is not loaded");
+  if (!request.vfsUrl) throw new Error("vfsUrl is required");
+  if (!Array.isArray(request.argv) || request.argv.length === 0) {
+    throw new Error("argv must contain at least argv[0]");
+  }
+
+  const start = performance.now();
+  let stdout = "";
+  let stderr = "";
+  const vfsBytes = new Uint8Array(await fetchBytes(request.vfsUrl, "Homebrew VFS"));
+  MemoryFileSystem.assertImageKernelAbi(vfsBytes, ABI_VERSION, "Homebrew smoke VFS");
+
+  const kernel = new BrowserKernel({
+    kernelOwnedFs: true,
+    onStdout: (data) => {
+      const text = decoder.decode(data);
+      stdout += text;
+      appendLog(text);
+    },
+    onStderr: (data) => {
+      const text = decoder.decode(data);
+      stderr += text;
+      appendLog(text);
+    },
+  });
+
+  try {
+    const { exit } = await kernel.boot({
+      kernelWasm: kernelBytes,
+      vfsImage: vfsBytes,
+      argv: request.argv,
+      cwd: request.cwd ?? "/",
+      env: request.env ?? [
+        "HOME=/tmp",
+        "TMPDIR=/tmp",
+        "TERM=xterm-256color",
+        "LANG=en_US.UTF-8",
+        "PATH=/home/linuxbrew/.linuxbrew/bin:/usr/local/bin:/usr/bin:/bin",
+      ],
+      uid: 0,
+      gid: 0,
+      stdin: new Uint8Array(0),
+    });
+    const exitCode = await Promise.race([
+      exit,
+      timeoutAfter(request.timeoutMs ?? 180_000),
+    ]);
+    return {
+      exitCode,
+      stdout,
+      stderr,
+      combined: `${stdout}${stderr}`,
+      durationMs: Math.round(performance.now() - start),
+    };
+  } finally {
+    await kernel.destroy().catch(() => {});
+  }
+}
+
+async function init(): Promise {
+  kernelBytes = await fetchBytes(kernelWasmUrl, "kernel.wasm");
+  window.__runHomebrewSmoke = runHomebrewSmoke;
+  window.__homebrewSmokeReady = true;
+  statusEl.textContent = "Ready";
+}
+
+window.__homebrewSmokeReady = false;
+init().catch((err) => {
+  const message = err instanceof Error ? err.message : String(err);
+  statusEl.textContent = `Error: ${message}`;
+  appendLog(`${message}\n`);
+  console.error("Homebrew smoke init failed:", err);
+});
diff --git a/apps/browser-demos/vite.config.ts b/apps/browser-demos/vite.config.ts
index f517aaba2f..705519dc63 100644
--- a/apps/browser-demos/vite.config.ts
+++ b/apps/browser-demos/vite.config.ts
@@ -403,6 +403,7 @@ const defaultDemoInputs = {
 
 const demoInputs = {
   ...defaultDemoInputs,
+  "homebrew-smoke": path.resolve(__dirname, "pages/homebrew-smoke/index.html"),
   "sqlite-test": path.resolve(__dirname, "pages/sqlite-test/index.html"),
   // The perl, python, ruby, erlang, texlive, and redis package entries
   // are not bundled into this static build while their slow builds
diff --git a/docs/homebrew-publishing.md b/docs/homebrew-publishing.md
index c1e180d9e6..ef205903af 100644
--- a/docs/homebrew-publishing.md
+++ b/docs/homebrew-publishing.md
@@ -227,6 +227,16 @@ the prefix. Package-specific Node and browser outcome text may be supplied via
 only when `KANDELO_HOMEBREW_BROWSER_SMOKE_STATUS=success` and the browser VFS
 smoke artifact environment is complete.
 
+Trusted publication can also supply
+`KANDELO_HOMEBREW_BROWSER_SMOKE_SUMMARY=/path/to/summary.json` from
+`scripts/homebrew-package-browser-smoke.ts`. When present, that summary is the
+authoritative browser outcome for the current formula and arch: successful
+wasm32 package entries become `runtime_support = ["node", "browser"]` and
+`browser_compatible = true`; failed or skipped entries stay Node-only and
+record the concrete browser cases, reasons, and artifact paths in the
+`browser_smoke` provenance outcome. The older manual browser-smoke environment
+variables remain available for transitional and one-off runs.
+
 ## VFS Planning And Building
 
 Homebrew-derived VFS images are built from sidecars and verified bottle bytes,
@@ -298,18 +308,46 @@ evidence remains local evidence until the trusted workflow publishes GHCR
 bottle bytes and tap sidecars.
 
 Browser compatibility requires a separate browser smoke. For the current
-`hello` path, the trusted publisher builds a precomposed wasm32 VFS image,
-serves it through the browser demo, runs Chromium Playwright against
-`apps/browser-demos/test/kandelo-homebrew.spec.ts`, and executes:
+generic path, the trusted publisher first generates candidate sidecars without
+requiring `browser_compatible=true`, then runs:
 
 ```bash
-/home/linuxbrew/.linuxbrew/bin/hello --version
+npx tsx scripts/homebrew-package-browser-smoke.ts \
+  --tap-root /path/to/kandelo-homebrew \
+  --formula sqlite \
+  --formula bzip2 \
+  --formula xz \
+  --arch wasm32 \
+  --result-dir test-runs/homebrew-package-browser-smoke
 ```
 
-Only after that smoke passes may sidecars record
+The runner materializes one Homebrew-derived VFS image per formula, serves it
+through the browser demo app, launches Chromium with the same cross-origin
+isolation requirements as the browser tests, and uses the dedicated
+`pages/homebrew-smoke/` entry to boot the candidate image through
+`BrowserKernel`. Program packages execute their poured binary directly as the
+first browser-kernel process, using explicit argv such as
+`["/home/linuxbrew/.linuxbrew/bin/bzip2", "--help"]`; sqlite compiles
+`packages/registry/sqlite/test/sqlite_basic.c` against the poured keg, injects
+that validation-only Wasm into the candidate image, and runs it the same way.
+The generic smoke entry only imports the kernel and the explicit VFS path under
+test, so unrelated browser-gallery assets and the default rootfs do not decide
+whether a Homebrew package is browser-compatible.
+
+The browser runner writes `summary.json`, `summary.md`, `current-run.json`,
+`failures.json`, and `outcome-lists/{passed,failed,skipped}-tests.tsv`.
+Skipped outcomes include reasons, such as the wasm64 browser boundary or a
+missing sqlite consumer compiler. Final sidecars are regenerated with
+`KANDELO_HOMEBREW_BROWSER_SMOKE_SUMMARY` after the smoke finishes. Only after
+that smoke passes may sidecars record
 `runtime_support = ["node", "browser"]` and `browser_compatible = true`.
 Packages without a successful browser smoke remain Node-only.
 
+The older `apps/browser-demos/test/kandelo-homebrew.spec.ts` coverage remains
+as app/gallery regression coverage for the published hello image. New
+Homebrew package browser claims should use the generic package runner instead
+of adding per-formula Playwright specs.
+
 ## Browser Gallery Assets
 
 Generate browser gallery assets only from browser-smoked wasm32 metadata:
diff --git a/host/src/browser-kernel-host.ts b/host/src/browser-kernel-host.ts
index 58fe444ddc..7990797a1a 100644
--- a/host/src/browser-kernel-host.ts
+++ b/host/src/browser-kernel-host.ts
@@ -23,7 +23,6 @@ import type { HttpRequest, HttpResponse } from "./networking/in-kernel-http";
 
 export type { HttpRequest, HttpResponse };
 import kernelWasmUrl from "@kernel-wasm?url";
-import rootfsVfsUrl from "@rootfs-vfs?url";
 import workerEntryUrl from "./worker-entry-browser.ts?worker&url";
 import kernelWorkerEntryUrl from "./browser-kernel-worker-entry.ts?worker&url";
 import { DEFAULT_MAX_PAGES } from "./constants";
@@ -254,13 +253,13 @@ export class BrowserKernel {
       kernelWasmBytes
         ? Promise.resolve(kernelWasmBytes)
         : fetch(kernelWasmUrl).then((r) => r.arrayBuffer()),
-      fetch(rootfsVfsUrl).then((r) => r.arrayBuffer()),
+      loadDefaultRootfsImage(),
     ]);
 
     await this.bootWorker({
       kernelWasmBytes: wasmBytes,
       fsSab: this.fsSab!,
-      rootfsImage: new Uint8Array(rootfsVfsBuf),
+      rootfsImage: rootfsVfsBuf,
     });
 
     await registerLazyVfsMetadata(this.memfs!, async (message) => {
@@ -294,9 +293,7 @@ export class BrowserKernel {
         ? Promise.resolve(options.kernelWasm)
         : fetch(kernelWasmUrl).then((r) => r.arrayBuffer()),
       options.vfsImage === "default"
-        ? fetch(rootfsVfsUrl)
-            .then((r) => r.arrayBuffer())
-            .then((b) => new Uint8Array(b))
+        ? loadDefaultRootfsImage()
         : Promise.resolve(options.vfsImage),
     ]);
 
@@ -1127,3 +1124,9 @@ export class BrowserKernel {
     return this.fbMemoryByPid.get(pid);
   }
 }
+
+async function loadDefaultRootfsImage(): Promise {
+  const { default: rootfsVfsUrl } = await import("./default-rootfs-url");
+  const bytes = await fetch(rootfsVfsUrl).then((r) => r.arrayBuffer());
+  return new Uint8Array(bytes);
+}
diff --git a/host/src/default-rootfs-url.ts b/host/src/default-rootfs-url.ts
new file mode 100644
index 0000000000..5a73ad3895
--- /dev/null
+++ b/host/src/default-rootfs-url.ts
@@ -0,0 +1,3 @@
+import rootfsVfsUrl from "@rootfs-vfs?url";
+
+export default rootfsVfsUrl;
diff --git a/host/test/browser-kernel.test.ts b/host/test/browser-kernel.test.ts
index 7107bb115a..3c47ae5565 100644
--- a/host/test/browser-kernel.test.ts
+++ b/host/test/browser-kernel.test.ts
@@ -89,6 +89,24 @@ async function makeRootfsImageBuffer(): Promise {
   ) as ArrayBuffer;
 }
 
+async function waitForWorker(index = 0): Promise {
+  for (let i = 0; i < 50; i++) {
+    const worker = MockWorker.instances[index];
+    if (worker) return worker;
+    await new Promise((r) => setTimeout(r, 0));
+  }
+  throw new Error(`worker ${index} was not created`);
+}
+
+async function waitForMessage(worker: MockWorker, type: string): Promise {
+  for (let i = 0; i < 50; i++) {
+    const message = worker.lastMessage(type);
+    if (message) return message;
+    await new Promise((r) => setTimeout(r, 0));
+  }
+  throw new Error(`worker message ${type} was not sent`);
+}
+
 describe("BrowserKernel", () => {
   beforeEach(() => {
     MockWorker.instances = [];
@@ -170,14 +188,12 @@ describe("BrowserKernel", () => {
     let resolved = false;
     void initPromise.then(() => { resolved = true; });
 
-    await new Promise((r) => setTimeout(r, 0));
-    const w = MockWorker.instances[0]!;
-    expect(w.lastMessage("init")).toBeDefined();
+    const w = await waitForWorker();
+    await waitForMessage(w, "init");
     w.simulateMessage({ type: "ready" });
     await new Promise((r) => setTimeout(r, 0));
 
-    const lazy = w.lastMessage("register_lazy_files");
-    expect(lazy).toBeDefined();
+    const lazy = await waitForMessage(w, "register_lazy_files");
     expect(typeof lazy.requestId).toBe("number");
     expect(lazy.entries).toMatchObject([
       { path: "/bin/lazy", url: "/assets/lazy.wasm", size: 123 },
@@ -202,12 +218,11 @@ describe("BrowserKernel", () => {
     const kernel = new BrowserKernel({ memfs });
     const initPromise = kernel.init(new ArrayBuffer(8));
 
-    await new Promise((r) => setTimeout(r, 0));
-    const w = MockWorker.instances[0]!;
+    const w = await waitForWorker();
     w.simulateMessage({ type: "ready" });
     await new Promise((r) => setTimeout(r, 0));
 
-    const lazy = w.lastMessage("register_lazy_files");
+    const lazy = await waitForMessage(w, "register_lazy_files");
     w.simulateMessage({
       type: "response",
       requestId: lazy.requestId,
diff --git a/scripts/dev-shell.sh b/scripts/dev-shell.sh
index 4ecf73cee7..57db26f350 100755
--- a/scripts/dev-shell.sh
+++ b/scripts/dev-shell.sh
@@ -118,6 +118,7 @@ nix_develop=(
     --keep KANDELO_HOMEBREW_BOTTLE_URL \
     --keep KANDELO_HOMEBREW_BOTTLE_SHA256 \
     --keep KANDELO_HOMEBREW_BOTTLE_BYTES \
+    --keep KANDELO_HOMEBREW_BROWSER_SMOKE_SUMMARY \
     --keep KANDELO_HOMEBREW_BROWSER_SMOKE_STATUS \
     --keep KANDELO_HOMEBREW_VFS_IMAGE \
     --keep KANDELO_HOMEBREW_VFS_REPORT \
diff --git a/scripts/homebrew-generate-sidecars-from-env.sh b/scripts/homebrew-generate-sidecars-from-env.sh
index add93d959f..cf9b958b83 100755
--- a/scripts/homebrew-generate-sidecars-from-env.sh
+++ b/scripts/homebrew-generate-sidecars-from-env.sh
@@ -176,23 +176,84 @@ def failed_outcome(name, reason):
         "skipped": [],
     }
 
+def browser_outcome_from_summary(summary_path, formula, arch):
+    with pathlib.Path(summary_path).open("r", encoding="utf-8") as f:
+        summary = json.load(f)
+    packages = summary.get("packages")
+    if not isinstance(packages, list):
+        raise SystemExit(f"browser smoke summary lacks packages list: {summary_path}")
+    matches = [
+        package for package in packages
+        if package.get("formula") == formula and package.get("arch") == arch
+    ]
+    if len(matches) != 1:
+        raise SystemExit(
+            f"browser smoke summary expected one {formula} {arch} entry, got {len(matches)}"
+        )
+    package = matches[0]
+    status = package.get("status")
+    if status not in {"success", "failed", "skipped"}:
+        raise SystemExit(f"invalid browser smoke summary status for {formula} {arch}: {status!r}")
+
+    def strings(key):
+        value = package.get(key, [])
+        if not isinstance(value, list):
+            raise SystemExit(f"browser smoke summary {formula} {arch} {key} must be a list")
+        out = []
+        for entry in value:
+            text = str(entry)
+            if text:
+                out.append(text)
+        return out
+
+    passed = strings("passed")
+    failed = strings("failed")
+    skipped = strings("skipped")
+    if status == "success" and not passed:
+        raise SystemExit(f"browser smoke summary success for {formula} {arch} has no passed evidence")
+    if status == "failed" and not failed:
+        failed = [f"browser smoke failed for {formula} {arch}; see {summary_path}"]
+    if status == "skipped" and not skipped:
+        skipped = [package.get("skip_reason") or f"browser smoke skipped for {formula} {arch}; see {summary_path}"]
+
+    outcome = {
+        "name": "browser_smoke",
+        "status": status,
+        "passed": passed,
+        "failed": failed,
+        "skipped": skipped,
+    }
+    if status == "skipped":
+        outcome["skip_reason"] = package.get("skip_reason") or skipped[0]
+    return status, outcome
+
 links, link_env = package_links_and_env()
-browser_smoke_status = os.environ.get("KANDELO_HOMEBREW_BROWSER_SMOKE_STATUS", "skipped")
-if browser_smoke_status not in {"success", "skipped", "failed"}:
-    raise SystemExit(f"invalid KANDELO_HOMEBREW_BROWSER_SMOKE_STATUS={browser_smoke_status!r}")
+browser_summary = os.environ.get("KANDELO_HOMEBREW_BROWSER_SMOKE_SUMMARY", "")
+browser_smoke_outcome = None
+if browser_summary:
+    browser_smoke_status, browser_smoke_outcome = browser_outcome_from_summary(
+        browser_summary,
+        formula,
+        arch,
+    )
+else:
+    browser_smoke_status = os.environ.get("KANDELO_HOMEBREW_BROWSER_SMOKE_STATUS", "skipped")
+    if browser_smoke_status not in {"success", "skipped", "failed"}:
+        raise SystemExit(f"invalid KANDELO_HOMEBREW_BROWSER_SMOKE_STATUS={browser_smoke_status!r}")
 browser_compatible = browser_smoke_status == "success"
 if browser_compatible and arch != "wasm32":
     raise SystemExit("browser smoke can only mark wasm32 bottles browser-compatible")
 runtime_support = ["node", "browser"] if browser_compatible else ["node"]
 
-browser_reason = os.environ.get(
-    "KANDELO_HOMEBREW_BROWSER_SMOKE_REASON",
-    f"No successful browser VFS smoke was recorded for {formula} {arch}.",
-)
-browser_smoke_outcome = skipped_outcome("browser_smoke", browser_reason)
-if browser_smoke_status == "failed":
-    browser_smoke_outcome = failed_outcome("browser_smoke", browser_reason)
-if browser_compatible:
+if browser_smoke_outcome is None:
+    browser_reason = os.environ.get(
+        "KANDELO_HOMEBREW_BROWSER_SMOKE_REASON",
+        f"No successful browser VFS smoke was recorded for {formula} {arch}.",
+    )
+    browser_smoke_outcome = skipped_outcome("browser_smoke", browser_reason)
+    if browser_smoke_status == "failed":
+        browser_smoke_outcome = failed_outcome("browser_smoke", browser_reason)
+if browser_compatible and not browser_summary:
     vfs_image = os.environ.get("KANDELO_HOMEBREW_VFS_IMAGE", "")
     vfs_report = os.environ.get("KANDELO_HOMEBREW_VFS_REPORT", "")
     browser_url = os.environ.get("KANDELO_HOMEBREW_BROWSER_SMOKE_URL", "")
diff --git a/scripts/homebrew-package-browser-smoke.ts b/scripts/homebrew-package-browser-smoke.ts
new file mode 100644
index 0000000000..c155074ab2
--- /dev/null
+++ b/scripts/homebrew-package-browser-smoke.ts
@@ -0,0 +1,897 @@
+/**
+ * Browser smoke coverage for Kandelo Homebrew package sidecars.
+ *
+ * The runner consumes generated Kandelo/Homebrew sidecars, materializes each
+ * requested wasm32 package into a candidate VFS without requiring existing
+ * browser-compatible metadata, boots that image through BrowserKernel in
+ * Chromium, and runs package-specific checks.
+ */
+import { execFileSync, spawn, type ChildProcess } from "node:child_process";
+import {
+  appendFileSync,
+  copyFileSync,
+  existsSync,
+  mkdirSync,
+  readFileSync,
+  renameSync,
+  rmSync,
+  writeFileSync,
+} from "node:fs";
+import { basename, dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import type { Browser } from "playwright";
+import { ABI_VERSION } from "../host/src/generated/abi";
+import type {
+  HomebrewBottleArch,
+  HomebrewTapMetadata,
+  HomebrewVfsPackagePlan,
+} from "../host/src/homebrew-vfs-planner";
+import type { MemoryFileSystem } from "../host/src/vfs/memory-fs";
+import {
+  HOMEBREW_CELLAR,
+  browserSmokeCasesForFormula,
+  browserUnsupportedReason,
+  parseHomebrewSmokeFormula,
+  SQLITE_BROWSER_CONSUMER_PATH,
+  type BrowserSmokeCase,
+  type HomebrewSmokeFormula,
+} from "./homebrew-package-smoke-cases";
+import {
+  countOutcomes,
+  SkipCase,
+  type SmokeOutcome,
+  writeOutcomeLists,
+} from "./homebrew-smoke-outcomes";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(__dirname, "..");
+const browserDemoDir = join(repoRoot, "apps", "browser-demos");
+const publicSmokeRoot = join(browserDemoDir, "public", "__kandelo-homebrew-smoke");
+const publicSmokePath = "/__kandelo-homebrew-smoke";
+
+interface CliOptions {
+  resultDir: string;
+  tapRoot: string;
+  formulas: HomebrewSmokeFormula[];
+  arch: HomebrewBottleArch;
+  bottleCache: string;
+  timeoutMs: number;
+  maxBytes: number;
+  beadId: string;
+  port: number;
+  runId: string;
+  browserChannel: string;
+}
+
+interface BuiltBrowserVfs {
+  formula: HomebrewSmokeFormula;
+  fs: MemoryFileSystem;
+  imagePath: string;
+  reportPath: string;
+  publicPath: string;
+  publicUrl: string;
+}
+
+interface BrowserDiagnostics {
+  console: string[];
+  pageErrors: string[];
+  requestFailures: string[];
+}
+
+interface BrowserSmokeResult {
+  exitCode: number;
+  stdout: string;
+  stderr: string;
+  combined: string;
+  durationMs: number;
+}
+
+type PackageStatus = "success" | "failed" | "skipped";
+
+async function main(): Promise {
+  const options = parseArgs(process.argv.slice(2));
+  mkdirSync(options.resultDir, { recursive: true });
+  mkdirSync(join(options.resultDir, "outcome-lists"), { recursive: true });
+  mkdirSync(options.bottleCache, { recursive: true });
+
+  const unsupportedReason = browserUnsupportedReason(options.arch);
+  const metadataPath = join(options.tapRoot, "Kandelo", "metadata.json");
+  const metadata = unsupportedReason ? undefined : readJsonFile(metadataPath);
+  const tapCommit = tryGitRevParse(options.tapRoot) ?? metadata?.tap_commit ?? "unknown";
+  const startedAt = new Date();
+  const outcomes: SmokeOutcome[] = [];
+  const builtByFormula = new Map();
+  const publicRunDir = join(publicSmokeRoot, options.runId);
+
+  writeCurrentRun(options, {
+    status: "running",
+    startedAt,
+    tapCommit,
+    outcomes,
+    currentCase: "startup",
+  });
+
+  try {
+    if (unsupportedReason) {
+      for (const formula of options.formulas) {
+        await runCase(outcomes, options, tapCommit, `browser_smoke_${formula}_unsupported_arch`, async () => {
+          throw new SkipCase(unsupportedReason);
+        }, formulaDir(options, formula));
+      }
+    } else {
+      mkdirSync(publicRunDir, { recursive: true });
+      for (const formula of options.formulas) {
+        await runCase(outcomes, options, tapCommit, `homebrew_browser_vfs_build_${formula}`, async () => {
+          if (!metadata) throw new Error(`missing Homebrew metadata: ${metadataPath}`);
+          const built = await buildFormulaVfs(metadata, formula, options);
+          builtByFormula.set(formula, built);
+          return `image=${built.imagePath}; report=${built.reportPath}; browser_url=${built.publicUrl}`;
+        }, formulaDir(options, formula));
+      }
+
+      let vite: ChildProcess | undefined;
+      let browser: Browser | undefined;
+      await runCase(outcomes, options, tapCommit, "browser_server_start", async () => {
+        vite = await startViteServer(options);
+        const { chromium } = await import("playwright");
+        browser = await chromium.launch({
+          channel: options.browserChannel,
+          headless: true,
+        });
+        return `vite=http://127.0.0.1:${options.port}; channel=${options.browserChannel}`;
+      }, join(options.resultDir, "vite.log"));
+
+      try {
+        for (const formula of options.formulas) {
+          for (const smokeCase of browserSmokeCasesForFormula(formula)) {
+            await runCase(
+              outcomes,
+              options,
+              tapCommit,
+              `browser_smoke_${formula}_${smokeCase.name}`,
+              async () => {
+                const built = builtByFormula.get(formula);
+                if (!built) throw new SkipCase(`requires successful homebrew_browser_vfs_build_${formula}`);
+                if (!browser) throw new Error("browser did not start");
+                return runBrowserSmokeCase(browser, built, smokeCase, options);
+              },
+              join(formulaDir(options, formula), `${smokeCase.name}-terminal.txt`),
+            );
+          }
+        }
+      } finally {
+        await browser?.close().catch(() => {});
+        await stopProcess(vite);
+      }
+    }
+  } finally {
+    rmSync(publicRunDir, { recursive: true, force: true });
+  }
+
+  writeOutcomeLists(options.resultDir, outcomes, { includeArtifactPath: true });
+  writeSummary(options, {
+    startedAt,
+    completedAt: new Date(),
+    tapCommit,
+    outcomes,
+    builtByFormula,
+  });
+  writeCurrentRun(options, {
+    status: outcomes.some((outcome) => outcome.status === "fail") ? "failed" : "complete",
+    startedAt,
+    tapCommit,
+    outcomes,
+    currentCase: "complete",
+  });
+
+  process.exit(outcomes.some((outcome) => outcome.status === "fail") ? 1 : 0);
+}
+
+async function buildFormulaVfs(
+  metadata: HomebrewTapMetadata,
+  formula: HomebrewSmokeFormula,
+  options: CliOptions,
+): Promise {
+  const [
+    { buildHomebrewVfs },
+    { planHomebrewVfs },
+    { MemoryFileSystem },
+    { saveImage },
+  ] = await Promise.all([
+    import("../host/src/homebrew-vfs-builder"),
+    import("../host/src/homebrew-vfs-planner"),
+    import("../host/src/vfs/memory-fs"),
+    import("../images/vfs/scripts/vfs-image-helpers"),
+  ]);
+  const plan = await planHomebrewVfs(metadata, {
+    packages: [formula],
+    arch: options.arch,
+    expectedAbi: ABI_VERSION,
+    loadLinkManifest: (relPath) => readJsonFile(join(options.tapRoot, relPath)),
+  });
+  const fs = createFs(MemoryFileSystem, options.maxBytes);
+  const result = await buildHomebrewVfs(plan, {
+    fs,
+    createdBy: "scripts/homebrew-package-browser-smoke.ts",
+    loadBottleBytes: (pkg) => loadBottleBytes(pkg, options),
+  });
+
+  if (formula === "sqlite") {
+    await compileAndInjectSqliteConsumer(fs, options);
+  }
+
+  const dir = formulaDir(options, formula);
+  mkdirSync(dir, { recursive: true });
+  const reportPath = join(dir, `${formula}-${options.arch}-homebrew-vfs-report.json`);
+  const imagePath = join(dir, `${formula}-${options.arch}-homebrew.vfs.zst`);
+  writeFileSync(reportPath, `${JSON.stringify({ ...result.report, image: imagePath }, null, 2)}\n`);
+  await saveImage(fs, imagePath, {
+    metadata: {
+      version: 1,
+      kernelAbi: plan.kandeloAbi,
+      createdBy: "scripts/homebrew-package-browser-smoke.ts",
+      homebrew: {
+        tapRepository: plan.tapRepository,
+        tapCommit: plan.tapCommit,
+        releaseTag: plan.releaseTag,
+        packages: plan.packages.map((pkg) => ({
+          name: pkg.name,
+          version: pkg.version,
+          arch: pkg.arch,
+          sourceStatus: pkg.sourceStatus,
+          cacheKeySha: pkg.cacheKeySha,
+        })),
+      },
+    },
+  });
+
+  const publicDir = join(publicSmokeRoot, options.runId);
+  mkdirSync(publicDir, { recursive: true });
+  const publicPath = join(publicDir, basename(imagePath));
+  copyFileSync(imagePath, publicPath);
+  const publicUrl = `http://127.0.0.1:${options.port}${publicSmokePath}/${encodeURIComponent(options.runId)}/${encodeURIComponent(basename(imagePath))}`;
+
+  return { formula, fs, imagePath, reportPath, publicPath, publicUrl };
+}
+
+async function compileAndInjectSqliteConsumer(fs: MemoryFileSystem, options: CliOptions): Promise {
+  const { ensureDirRecursive, writeVfsBinary } = await import("../images/vfs/scripts/vfs-image-helpers");
+  const stage = join(formulaDir(options, "sqlite"), "sqlite-consumer-build", options.arch);
+  rmSync(stage, { recursive: true, force: true });
+  mkdirSync(join(stage, "include"), { recursive: true });
+  mkdirSync(join(stage, "lib"), { recursive: true });
+
+  const version = findPackageVersion(fs, "sqlite");
+  writeFileSync(
+    join(stage, "include", "sqlite3.h"),
+    readVfsFile(fs, `${HOMEBREW_CELLAR}/sqlite/${version}/include/sqlite3.h`),
+  );
+  writeFileSync(
+    join(stage, "include", "sqlite3ext.h"),
+    readVfsFile(fs, `${HOMEBREW_CELLAR}/sqlite/${version}/include/sqlite3ext.h`),
+  );
+  writeFileSync(
+    join(stage, "lib", "libsqlite3.a"),
+    readVfsFile(fs, `${HOMEBREW_CELLAR}/sqlite/${version}/lib/libsqlite3.a`),
+  );
+
+  const cc = join(repoRoot, "sdk", "bin", `${options.arch}posix-cc`);
+  if (!existsSync(cc)) {
+    throw new SkipCase(`sqlite consumer compiler is unavailable: ${cc}`);
+  }
+  const testSrc = join(repoRoot, "packages", "registry", "sqlite", "test", "sqlite_basic.c");
+  const outWasm = join(stage, "sqlite_basic.wasm");
+  try {
+    execFileSync(cc, [
+      `-I${join(stage, "include")}`,
+      testSrc,
+      join(stage, "lib", "libsqlite3.a"),
+      "-lm",
+      "-o",
+      outWasm,
+    ], {
+      cwd: repoRoot,
+      env: {
+        ...process.env,
+        PATH: `${join(repoRoot, "sdk", "bin")}:${process.env.PATH ?? ""}`,
+        WASM_POSIX_SYSROOT: join(repoRoot, "sysroot"),
+      },
+      stdio: "pipe",
+    });
+  } catch (err) {
+    const message = err instanceof Error ? err.message : String(err);
+    const stderr = readProcessErrorStderr(err);
+    throw new Error(`sqlite consumer compilation failed: ${stderr || message}`);
+  }
+
+  ensureDirRecursive(fs, dirname(SQLITE_BROWSER_CONSUMER_PATH));
+  writeVfsBinary(fs, SQLITE_BROWSER_CONSUMER_PATH, new Uint8Array(readFileSync(outWasm)), 0o755);
+}
+
+async function runBrowserSmokeCase(
+  browser: Browser,
+  built: BuiltBrowserVfs,
+  smokeCase: BrowserSmokeCase,
+  options: CliOptions,
+): Promise {
+  const dir = formulaDir(options, smokeCase.formula);
+  const terminalPath = join(dir, `${smokeCase.name}-terminal.txt`);
+  const eventsPath = join(dir, `${smokeCase.name}-browser-events.json`);
+  const screenshotPath = join(dir, `${smokeCase.name}-failure.png`);
+  const tracePath = join(dir, `${smokeCase.name}-trace.zip`);
+  const diagnostics: BrowserDiagnostics = {
+    console: [],
+    pageErrors: [],
+    requestFailures: [],
+  };
+  const context = await browser.newContext();
+  await context.tracing.start({ screenshots: true, snapshots: true });
+  const page = await context.newPage();
+  page.on("console", (msg) => {
+    if (msg.type() === "warning" || msg.type() === "error") {
+      diagnostics.console.push(`[${msg.type()}] ${msg.text()}`);
+    }
+  });
+  page.on("pageerror", (err) => diagnostics.pageErrors.push(err.stack ?? err.message));
+  page.on("requestfailed", (request) => {
+    diagnostics.requestFailures.push(`${request.method()} ${request.url()} ${request.failure()?.errorText ?? ""}`.trim());
+  });
+
+  let failed = false;
+  try {
+    await page.goto(`http://127.0.0.1:${options.port}/pages/homebrew-smoke/`, {
+      waitUntil: "domcontentloaded",
+      timeout: options.timeoutMs,
+    });
+    await page.waitForFunction(() => window.__homebrewSmokeReady === true, undefined, {
+      timeout: options.timeoutMs,
+    });
+    const result = await page.evaluate(
+      async ({ vfsUrl, argv, timeoutMs }) => window.__runHomebrewSmoke({ vfsUrl, argv, timeoutMs }),
+      {
+        vfsUrl: built.publicUrl,
+        argv: smokeCase.argv,
+        timeoutMs: options.timeoutMs,
+      },
+    ) as BrowserSmokeResult;
+    const output = [
+      `command: ${smokeCase.command}`,
+      `argv: ${JSON.stringify(smokeCase.argv)}`,
+      `exitCode: ${result.exitCode}`,
+      `durationMs: ${result.durationMs}`,
+      "",
+      "stdout:",
+      result.stdout,
+      "",
+      "stderr:",
+      result.stderr,
+    ].join("\n");
+    writeFileSync(terminalPath, output);
+    writeFileSync(eventsPath, `${JSON.stringify(diagnostics, null, 2)}\n`);
+
+    if (result.exitCode !== 0) {
+      throw new Error(`${smokeCase.name} exited ${result.exitCode}; output=${JSON.stringify(output)}`);
+    }
+    smokeCase.expected.lastIndex = 0;
+    if (!smokeCase.expected.test(result.combined)) {
+      throw new Error(`${smokeCase.name} output did not match ${smokeCase.expected}: ${JSON.stringify(output)}`);
+    }
+    return `command=${smokeCase.command}; url=${built.publicUrl}; output=${terminalPath}`;
+  } catch (err) {
+    failed = true;
+    await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => {});
+    const message = err instanceof Error ? err.message : String(err);
+    writeFileSync(eventsPath, `${JSON.stringify({ ...diagnostics, error: message }, null, 2)}\n`);
+    throw err;
+  } finally {
+    await context.tracing.stop(failed ? { path: tracePath } : undefined).catch(() => {});
+    await context.close().catch(() => {});
+  }
+}
+
+async function runCase(
+  outcomes: SmokeOutcome[],
+  options: CliOptions,
+  tapCommit: string,
+  name: string,
+  fn: () => Promise,
+  artifactPath?: string,
+): Promise {
+  writeCurrentRun(options, {
+    status: "running",
+    tapCommit,
+    outcomes,
+    currentCase: name,
+  });
+  const started = Date.now();
+  try {
+    const details = await fn();
+    outcomes.push({ name, status: "pass", durationMs: Date.now() - started, details, artifactPath });
+  } catch (err) {
+    const error = err instanceof Error ? err : new Error(String(err));
+    const skipped = error instanceof SkipCase;
+    outcomes.push({
+      name,
+      status: skipped ? "skip" : "fail",
+      durationMs: Date.now() - started,
+      details: error.message,
+      ...(skipped ? {} : { error: error.stack ?? error.message }),
+      artifactPath,
+    });
+  }
+  writeCurrentRun(options, {
+    status: "running",
+    tapCommit,
+    outcomes,
+    currentCase: name,
+  });
+}
+
+async function startViteServer(options: CliOptions): Promise {
+  const logPath = join(options.resultDir, "vite.log");
+  writeFileSync(logPath, "");
+  return new Promise((resolvePromise, reject) => {
+    const proc = spawn("npx", [
+      "vite",
+      "--config",
+      join(browserDemoDir, "vite.config.ts"),
+      "--host",
+      "127.0.0.1",
+      "--port",
+      String(options.port),
+      "--strictPort",
+    ], {
+      cwd: browserDemoDir,
+      stdio: ["ignore", "pipe", "pipe"],
+      env: {
+        ...process.env,
+        KANDELO_BROWSER_TEST_NO_HMR: "1",
+        KANDELO_BROWSER_DEMO_INPUTS: "homebrew-smoke",
+        KANDELO_PLAYWRIGHT_PORT: String(options.port),
+      },
+    });
+
+    let started = false;
+    const timeout = setTimeout(() => {
+      if (!started) {
+        proc.kill();
+        reject(new Error(`Vite server did not start within 30000ms; see ${logPath}`));
+      }
+    }, 30_000);
+    const onData = (data: Buffer) => {
+      const text = data.toString();
+      appendFileSync(logPath, text);
+      if (!started && /Local:\s+http:\/\/127\.0\.0\.1:/.test(text)) {
+        started = true;
+        clearTimeout(timeout);
+        setTimeout(() => resolvePromise(proc), 500);
+      }
+    };
+    proc.stdout?.on("data", onData);
+    proc.stderr?.on("data", onData);
+    proc.on("exit", (code) => {
+      if (!started) {
+        clearTimeout(timeout);
+        reject(new Error(`Vite exited with code ${code}; see ${logPath}`));
+      }
+    });
+    proc.on("error", (err) => {
+      if (!started) {
+        clearTimeout(timeout);
+        reject(err);
+      }
+    });
+  });
+}
+
+async function stopProcess(proc: ChildProcess | undefined): Promise {
+  if (!proc || proc.exitCode !== null || proc.signalCode !== null) return;
+  await new Promise((resolvePromise) => {
+    const timer = setTimeout(() => {
+      proc.kill("SIGKILL");
+      resolvePromise();
+    }, 5_000);
+    proc.once("exit", () => {
+      clearTimeout(timer);
+      resolvePromise();
+    });
+    proc.kill();
+  });
+}
+
+async function loadBottleBytes(
+  pkg: HomebrewVfsPackagePlan,
+  options: CliOptions,
+): Promise {
+  if (pkg.url.startsWith("file://")) {
+    return new Uint8Array(readFileSync(fileURLToPath(pkg.url)));
+  }
+
+  const cachePath = join(options.bottleCache, `${pkg.sha256}.tar.gz`);
+  if (existsSync(cachePath)) return new Uint8Array(readFileSync(cachePath));
+  if (!pkg.url.startsWith("https://")) {
+    throw new Error(
+      `package ${pkg.name}@${pkg.version} bottle URL must be https:// or file://, got ${pkg.url}`,
+    );
+  }
+
+  const { fetchHomebrewBottleBytes } = await import("../host/src/homebrew-vfs-fetch");
+  const bytes = await fetchHomebrewBottleBytes(pkg.url);
+  writeFileSync(cachePath, bytes);
+  return bytes;
+}
+
+function findPackageVersion(fs: MemoryFileSystem, formula: string): string {
+  const info = JSON.parse(new TextDecoder().decode(readVfsFile(fs, "/etc/kandelo/homebrew-vfs.json")));
+  const pkg = info.packages?.find((candidate: { name?: string }) => candidate.name === formula);
+  if (!pkg) throw new Error(`package ${formula} missing from /etc/kandelo/homebrew-vfs.json`);
+  const keg = String(pkg.keg ?? "");
+  const prefix = `${HOMEBREW_CELLAR}/${formula}/`;
+  if (!keg.startsWith(prefix)) throw new Error(`unexpected ${formula} keg path: ${keg}`);
+  return keg.slice(prefix.length);
+}
+
+function readVfsFile(fs: MemoryFileSystem, path: string): Uint8Array {
+  const st = fs.stat(path);
+  const fd = fs.open(path, 0, 0);
+  try {
+    const out = new Uint8Array(st.size);
+    let offset = 0;
+    while (offset < out.byteLength) {
+      const n = fs.read(fd, out.subarray(offset), null, out.byteLength - offset);
+      if (n <= 0) break;
+      offset += n;
+    }
+    return out.subarray(0, offset);
+  } finally {
+    fs.close(fd);
+  }
+}
+
+function createFs(
+  MemoryFileSystemCtor: {
+    create(sab: SharedArrayBuffer, maxBytes?: number): MemoryFileSystem;
+  },
+  maxBytes: number,
+): MemoryFileSystem {
+  const SharedArrayBufferCtor = SharedArrayBuffer as new (
+    byteLength: number,
+    options?: { maxByteLength?: number },
+  ) => SharedArrayBuffer;
+  return MemoryFileSystemCtor.create(
+    new SharedArrayBufferCtor(maxBytes, { maxByteLength: maxBytes }),
+    maxBytes,
+  );
+}
+
+function parseArgs(args: string[]): CliOptions {
+  const defaultResultDir = join(
+    repoRoot,
+    "test-runs",
+    "homebrew-package-browser-smoke",
+    new Date().toISOString().replace(/[-:]/g, "").replace(/\..+$/, "Z"),
+  );
+  const options: CliOptions = {
+    resultDir: process.env.KANDELO_TEST_RESULT_DIR || defaultResultDir,
+    tapRoot: process.env.KANDELO_HOMEBREW_TAP_ROOT || "",
+    formulas: [],
+    arch: "wasm32",
+    bottleCache: "",
+    timeoutMs: 180_000,
+    maxBytes: 128 * 1024 * 1024,
+    beadId: process.env.KANDELO_BEAD_ID || "kd-1mr.2.1",
+    port: Number(process.env.KANDELO_PLAYWRIGHT_PORT ?? 5401),
+    runId: "",
+    browserChannel: process.env.KANDELO_PLAYWRIGHT_CHANNEL || "chromium",
+  };
+
+  for (let i = 0; i < args.length; i += 1) {
+    const arg = args[i];
+    switch (arg) {
+      case "--result-dir":
+        options.resultDir = requireValue(args, ++i, arg);
+        break;
+      case "--tap-root":
+        options.tapRoot = requireValue(args, ++i, arg);
+        break;
+      case "--formula":
+        options.formulas.push(parseFormula(requireValue(args, ++i, arg)));
+        break;
+      case "--arch":
+        options.arch = parseArch(requireValue(args, ++i, arg));
+        break;
+      case "--bottle-cache":
+        options.bottleCache = requireValue(args, ++i, arg);
+        break;
+      case "--timeout-ms":
+        options.timeoutMs = parsePositiveInt(requireValue(args, ++i, arg), arg);
+        break;
+      case "--max-bytes":
+        options.maxBytes = parseByteSize(requireValue(args, ++i, arg));
+        break;
+      case "--bead-id":
+        options.beadId = requireValue(args, ++i, arg);
+        break;
+      case "--port":
+        options.port = parsePositiveInt(requireValue(args, ++i, arg), arg);
+        break;
+      case "--run-id":
+        options.runId = sanitizeToken(requireValue(args, ++i, arg));
+        break;
+      case "--browser-channel":
+        options.browserChannel = requireValue(args, ++i, arg);
+        break;
+      case "--help":
+      case "-h":
+        usage(0);
+        break;
+      default:
+        usage(2, `unexpected argument ${arg}`);
+    }
+  }
+
+  if (!options.tapRoot) usage(2, "--tap-root is required");
+  if (options.formulas.length === 0) usage(2, "at least one --formula is required");
+  const seen = new Set();
+  for (const formula of options.formulas) {
+    if (seen.has(formula)) usage(2, `duplicate --formula ${formula}`);
+    seen.add(formula);
+  }
+  options.resultDir = resolve(options.resultDir);
+  options.tapRoot = resolve(options.tapRoot);
+  options.bottleCache = options.bottleCache
+    ? resolve(options.bottleCache)
+    : join(options.resultDir, "bottle-cache");
+  options.runId ||= `${sanitizeToken(options.beadId)}-${new Date().toISOString().replace(/[^0-9TZ]/g, "")}-${process.pid}`;
+  return options;
+}
+
+function parseFormula(value: string): HomebrewSmokeFormula {
+  try {
+    return parseHomebrewSmokeFormula(value);
+  } catch (err) {
+    usage(2, err instanceof Error ? err.message : String(err));
+  }
+}
+
+function parseArch(value: string): HomebrewBottleArch {
+  if (value === "wasm32" || value === "wasm64") return value;
+  usage(2, `--arch must be wasm32 or wasm64, got ${value}`);
+}
+
+function parsePositiveInt(value: string, flag: string): number {
+  const parsed = Number(value);
+  if (!Number.isInteger(parsed) || parsed <= 0) usage(2, `${flag} must be a positive integer`);
+  return parsed;
+}
+
+function parseByteSize(value: string): number {
+  const match = /^([1-9][0-9]*)([kKmMgG]i?[bB]?|[bB])?$/.exec(value);
+  if (!match) usage(2, `--max-bytes must be a positive byte size, got ${value}`);
+  const amount = Number(match[1]);
+  const suffix = (match[2] ?? "b").toLowerCase();
+  const multiplier = suffix.startsWith("g") ? 1024 ** 3
+    : suffix.startsWith("m") ? 1024 ** 2
+    : suffix.startsWith("k") ? 1024
+    : 1;
+  return amount * multiplier;
+}
+
+function requireValue(args: string[], index: number, flag: string): string {
+  const value = args[index];
+  if (!value || value.startsWith("--")) usage(2, `${flag} requires a value`);
+  return value;
+}
+
+function usage(code: number, message?: string): never {
+  if (message) console.error(`homebrew-package-browser-smoke: ${message}`);
+  console.error(`usage: npx tsx scripts/homebrew-package-browser-smoke.ts \\
+  --tap-root  --formula  [--formula ...] \\
+  [--arch ] [--result-dir ] [--bottle-cache ] \\
+  [--timeout-ms ] [--max-bytes ] [--port ]`);
+  process.exit(code);
+}
+
+function writeSummary(
+  options: CliOptions,
+  data: {
+    startedAt: Date;
+    completedAt: Date;
+    tapCommit: string;
+    outcomes: SmokeOutcome[];
+    builtByFormula: Map;
+  },
+): void {
+  const counts = countOutcomes(data.outcomes);
+  const packages = options.formulas.map((formula) =>
+    summarizePackage(formula, options, data.outcomes, data.builtByFormula.get(formula))
+  );
+  const summary = {
+    suite: "Homebrew package browser VFS smoke",
+    bead_id: options.beadId,
+    started_at: data.startedAt.toISOString(),
+    completed_at: data.completedAt.toISOString(),
+    duration_ms: data.completedAt.getTime() - data.startedAt.getTime(),
+    result_dir: options.resultDir,
+    tap_root: options.tapRoot,
+    tap_commit: data.tapCommit,
+    arch: options.arch,
+    formulas: options.formulas,
+    counts,
+    outcomes: data.outcomes,
+    packages,
+    artifacts: {
+      passed: join(options.resultDir, "outcome-lists", "passed-tests.tsv"),
+      failed: join(options.resultDir, "outcome-lists", "failed-tests.tsv"),
+      skipped: join(options.resultDir, "outcome-lists", "skipped-tests.tsv"),
+      failures: join(options.resultDir, "failures.json"),
+      current_run: join(options.resultDir, "current-run.json"),
+    },
+  };
+  writeFileSync(join(options.resultDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`);
+  writeFileSync(join(options.resultDir, "summary.md"), [
+    "# Homebrew package browser VFS smoke",
+    "",
+    `Result dir: \`${options.resultDir}\``,
+    `Tap commit: \`${data.tapCommit}\``,
+    `Counts: ${counts.pass} pass, ${counts.fail} fail, ${counts.skip} skip`,
+    "",
+    "| Formula | Status | Browser URL | Details |",
+    "|---|---:|---|---|",
+    ...packages.map((pkg) =>
+      `| \`${pkg.formula}\` | ${pkg.status} | ${pkg.browser_url ? `\`${pkg.browser_url}\`` : ""} | ${[...pkg.failed, ...pkg.skipped].join("; ").replace(/\|/g, "\\|")} |`,
+    ),
+    "",
+    "| Test | Status | Details |",
+    "|---|---:|---|",
+    ...data.outcomes.map((outcome) =>
+      `| \`${outcome.name}\` | ${outcome.status} | ${(outcome.details ?? "").replace(/\|/g, "\\|")} |`,
+    ),
+    "",
+  ].join("\n"));
+}
+
+function summarizePackage(
+  formula: HomebrewSmokeFormula,
+  options: CliOptions,
+  outcomes: SmokeOutcome[],
+  built: BuiltBrowserVfs | undefined,
+): {
+  formula: HomebrewSmokeFormula;
+  arch: HomebrewBottleArch;
+  status: PackageStatus;
+  required_cases: string[];
+  browser_url?: string;
+  vfs_image?: string;
+  vfs_report?: string;
+  commands: string[];
+  argv: string[][];
+  passed: string[];
+  failed: string[];
+  skipped: string[];
+  skip_reason?: string;
+} {
+  const smokeCases = browserSmokeCasesForFormula(formula);
+  const formulaOutcomes = outcomes.filter((outcome) =>
+    outcome.name === `homebrew_browser_vfs_build_${formula}` ||
+    outcome.name.startsWith(`browser_smoke_${formula}_`)
+  );
+  const globalOutcomes = outcomes.filter((outcome) => outcome.name === "browser_server_start");
+  const relevant = [...globalOutcomes, ...formulaOutcomes];
+  const failed = relevant.filter((outcome) => outcome.status === "fail").map(formatOutcome);
+  const skipped = formulaOutcomes.filter((outcome) => outcome.status === "skip").map(formatOutcome);
+  const passed = relevant.filter((outcome) => outcome.status === "pass").map(formatOutcome);
+  const status: PackageStatus = failed.length > 0 ? "failed" : skipped.length > 0 ? "skipped" : "success";
+  const firstSkip = formulaOutcomes.find((outcome) => outcome.status === "skip");
+
+  return {
+    formula,
+    arch: options.arch,
+    status,
+    required_cases: smokeCases.filter((smokeCase) => smokeCase.required).map((smokeCase) => smokeCase.name),
+    browser_url: built?.publicUrl,
+    vfs_image: built?.imagePath,
+    vfs_report: built?.reportPath,
+    commands: smokeCases.map((smokeCase) => smokeCase.command),
+    argv: smokeCases.map((smokeCase) => smokeCase.argv),
+    passed,
+    failed,
+    skipped,
+    ...(status === "skipped" && firstSkip?.details ? { skip_reason: firstSkip.details } : {}),
+  };
+}
+
+function formatOutcome(outcome: SmokeOutcome): string {
+  const text = outcome.status === "fail"
+    ? outcome.error ?? outcome.details ?? ""
+    : outcome.details ?? "";
+  return `${outcome.name}: ${text}${outcome.artifactPath ? ` [${outcome.artifactPath}]` : ""}`;
+}
+
+function writeCurrentRun(
+  options: CliOptions,
+  data: {
+    status: "running" | "complete" | "failed";
+    startedAt?: Date;
+    tapCommit: string;
+    outcomes: SmokeOutcome[];
+    currentCase: string;
+  },
+): void {
+  const counts = countOutcomes(data.outcomes);
+  const currentRun = {
+    suite: "homebrew-package-browser-smoke",
+    bead_id: options.beadId,
+    worktree: repoRoot,
+    result_dir: options.resultDir,
+    status: data.status,
+    started_at: data.startedAt?.toISOString(),
+    updated_at: new Date().toISOString(),
+    current_case: data.currentCase,
+    progress: {
+      completed: data.outcomes.length,
+      total: options.arch === "wasm64"
+        ? options.formulas.length
+        : options.formulas.length * 2 + 1,
+      pass: counts.pass,
+      fail: counts.fail,
+      skip: counts.skip,
+    },
+    tap_root: options.tapRoot,
+    tap_commit: data.tapCommit,
+    command: {
+      cwd: repoRoot,
+      argv: process.argv,
+    },
+    outcome_lists: {
+      passed: join(options.resultDir, "outcome-lists", "passed-tests.tsv"),
+      failed: join(options.resultDir, "outcome-lists", "failed-tests.tsv"),
+      skipped: join(options.resultDir, "outcome-lists", "skipped-tests.tsv"),
+    },
+    stale_no_runner_threshold_seconds: 900,
+    expected_next: data.status === "running"
+      ? { deterministic: true, action: "continue current browser smoke case" }
+      : { deterministic: false, action: "suite terminal" },
+  };
+  const out = join(options.resultDir, "current-run.json");
+  const tmp = `${out}.tmp`;
+  writeFileSync(tmp, `${JSON.stringify(currentRun, null, 2)}\n`);
+  renameSync(tmp, out);
+}
+
+function formulaDir(options: CliOptions, formula: HomebrewSmokeFormula): string {
+  return join(options.resultDir, `${formula}-${options.arch}`);
+}
+
+function readJsonFile(path: string): T {
+  return JSON.parse(readFileSync(path, "utf8")) as T;
+}
+
+function tryGitRevParse(path: string): string | undefined {
+  try {
+    return execFileSync("git", ["-C", path, "rev-parse", "HEAD"], {
+      encoding: "utf8",
+      stdio: ["ignore", "pipe", "ignore"],
+    }).trim();
+  } catch {
+    return undefined;
+  }
+}
+
+function readProcessErrorStderr(err: unknown): string {
+  if (err && typeof err === "object" && "stderr" in err) {
+    const stderr = (err as { stderr?: unknown }).stderr;
+    if (stderr instanceof Buffer) return stderr.toString();
+    if (typeof stderr === "string") return stderr;
+  }
+  return "";
+}
+
+function sanitizeToken(value: string): string {
+  return value.replace(/[^A-Za-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || "run";
+}
+
+main().catch((err) => {
+  console.error(err);
+  process.exit(1);
+});
diff --git a/scripts/homebrew-package-node-smoke.ts b/scripts/homebrew-package-node-smoke.ts
index 08cbf0b7b1..938305148d 100644
--- a/scripts/homebrew-package-node-smoke.ts
+++ b/scripts/homebrew-package-node-smoke.ts
@@ -29,13 +29,18 @@ import {
 import { NodeKernelHost } from "../host/src/node-kernel-host";
 import { MemoryFileSystem } from "../host/src/vfs/memory-fs";
 import { saveImage } from "../images/vfs/scripts/vfs-image-helpers";
+import {
+  countOutcomes,
+  SkipCase,
+  type SmokeOutcome as Outcome,
+  writeOutcomeLists,
+} from "./homebrew-smoke-outcomes";
 
 const __dirname = dirname(fileURLToPath(import.meta.url));
 const repoRoot = resolve(__dirname, "..");
 const PREFIX = "/home/linuxbrew/.linuxbrew";
 const CELLAR = `${PREFIX}/Cellar`;
 
-type OutcomeStatus = "pass" | "fail" | "skip";
 type FormulaName = "sqlite" | "bzip2" | "xz";
 
 interface CliOptions {
@@ -49,27 +54,12 @@ interface CliOptions {
   beadId: string;
 }
 
-interface Outcome {
-  name: string;
-  status: OutcomeStatus;
-  durationMs: number;
-  details?: string;
-  error?: string;
-}
-
 interface BuiltVfs {
   fs: MemoryFileSystem;
   imageBytes: Uint8Array;
   reportPath: string;
 }
 
-class SkipCase extends Error {
-  constructor(message: string) {
-    super(message);
-    this.name = "SkipCase";
-  }
-}
-
 async function main(): Promise {
   const options = parseArgs(process.argv.slice(2));
   mkdirSync(options.resultDir, { recursive: true });
@@ -488,33 +478,6 @@ function usage(code: number, message?: string): never {
   process.exit(code);
 }
 
-function writeOutcomeLists(resultDir: string, outcomes: Outcome[]): void {
-  const listsDir = join(resultDir, "outcome-lists");
-  mkdirSync(listsDir, { recursive: true });
-  const passed = outcomes.filter((outcome) => outcome.status === "pass");
-  const failed = outcomes.filter((outcome) => outcome.status === "fail");
-  const skipped = outcomes.filter((outcome) => outcome.status === "skip");
-  writeFileSync(
-    join(listsDir, "passed-tests.tsv"),
-    ["test\tduration_ms\tdetails", ...passed.map((outcome) =>
-      `${outcome.name}\t${outcome.durationMs}\t${tsv(outcome.details ?? "")}`,
-    )].join("\n") + "\n",
-  );
-  writeFileSync(
-    join(listsDir, "failed-tests.tsv"),
-    ["test\tduration_ms\terror", ...failed.map((outcome) =>
-      `${outcome.name}\t${outcome.durationMs}\t${tsv(outcome.error ?? outcome.details ?? "")}`,
-    )].join("\n") + "\n",
-  );
-  writeFileSync(
-    join(listsDir, "skipped-tests.tsv"),
-    ["test\treason", ...skipped.map((outcome) =>
-      `${outcome.name}\t${tsv(outcome.details ?? "")}`,
-    )].join("\n") + "\n",
-  );
-  writeFileSync(join(resultDir, "failures.json"), `${JSON.stringify(failed, null, 2)}\n`);
-}
-
 function writeSummary(
   options: CliOptions,
   data: {
@@ -612,14 +575,6 @@ function writeCurrentRun(
   renameSync(tmp, out);
 }
 
-function countOutcomes(outcomes: Outcome[]): { pass: number; fail: number; skip: number } {
-  return {
-    pass: outcomes.filter((outcome) => outcome.status === "pass").length,
-    fail: outcomes.filter((outcome) => outcome.status === "fail").length,
-    skip: outcomes.filter((outcome) => outcome.status === "skip").length,
-  };
-}
-
 function readJsonFile(path: string): T {
   return JSON.parse(readFileSync(path, "utf8")) as T;
 }
@@ -634,10 +589,6 @@ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
   return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
 }
 
-function tsv(value: string): string {
-  return value.replace(/\t/g, " ").replace(/\r?\n/g, "\\n");
-}
-
 main().catch((err) => {
   console.error(err);
   process.exit(1);
diff --git a/scripts/homebrew-package-smoke-cases.ts b/scripts/homebrew-package-smoke-cases.ts
new file mode 100644
index 0000000000..742e4fe9f6
--- /dev/null
+++ b/scripts/homebrew-package-smoke-cases.ts
@@ -0,0 +1,91 @@
+import type { HomebrewBottleArch } from "../host/src/homebrew-vfs-planner";
+
+export const HOMEBREW_PREFIX = "/home/linuxbrew/.linuxbrew";
+export const HOMEBREW_CELLAR = `${HOMEBREW_PREFIX}/Cellar`;
+export const SQLITE_BROWSER_CONSUMER_PATH = "/usr/local/kandelo-smoke/bin/sqlite_basic";
+
+export type HomebrewSmokeFormula = "hello" | "sqlite" | "bzip2" | "xz";
+
+export interface BrowserSmokeCase {
+  name: string;
+  formula: HomebrewSmokeFormula;
+  required: boolean;
+  command: string;
+  argv: string[];
+  expected: RegExp;
+  description: string;
+}
+
+export function parseHomebrewSmokeFormula(value: string): HomebrewSmokeFormula {
+  if (value === "hello" || value === "sqlite" || value === "bzip2" || value === "xz") {
+    return value;
+  }
+  throw new Error(`formula must be hello, sqlite, bzip2, or xz, got ${value}`);
+}
+
+export function browserUnsupportedReason(arch: HomebrewBottleArch): string | undefined {
+  return arch === "wasm64"
+    ? "wasm64 browser compatibility is unsupported by the current Homebrew browser sidecar path"
+    : undefined;
+}
+
+export function browserSmokeCasesForFormula(formula: HomebrewSmokeFormula): BrowserSmokeCase[] {
+  switch (formula) {
+    case "hello":
+      return [programOutputCase({
+        formula,
+        name: "hello_version",
+        argv: [`${HOMEBREW_PREFIX}/bin/hello`, "--version"],
+        expected: /hello/i,
+        description: "Run hello --version from the poured Homebrew prefix.",
+      })];
+    case "bzip2":
+      return [programOutputCase({
+        formula,
+        name: "bzip2_help",
+        argv: [`${HOMEBREW_PREFIX}/bin/bzip2`, "--help"],
+        expected: /bzip2/i,
+        description: "Run bzip2 --help from the poured Homebrew prefix.",
+      })];
+    case "xz":
+      return [programOutputCase({
+        formula,
+        name: "xz_version",
+        argv: [`${HOMEBREW_PREFIX}/bin/xz`, "--version"],
+        expected: /xz/i,
+        description: "Run xz --version from the poured Homebrew prefix.",
+      })];
+    case "sqlite":
+      return [{
+        name: "sqlite_basic_consumer",
+        formula,
+        required: true,
+        command: SQLITE_BROWSER_CONSUMER_PATH,
+        argv: [SQLITE_BROWSER_CONSUMER_PATH],
+        expected: /PASS/,
+        description: "Run sqlite_basic linked against the poured sqlite keg.",
+      }];
+  }
+}
+
+export function browserCaseNamesForFormula(formula: HomebrewSmokeFormula): string[] {
+  return browserSmokeCasesForFormula(formula).map((smokeCase) => smokeCase.name);
+}
+
+function programOutputCase(options: {
+  formula: HomebrewSmokeFormula;
+  name: string;
+  argv: string[];
+  expected: RegExp;
+  description: string;
+}): BrowserSmokeCase {
+  return {
+    name: options.name,
+    formula: options.formula,
+    required: true,
+    command: options.argv.join(" "),
+    argv: options.argv,
+    expected: options.expected,
+    description: options.description,
+  };
+}
diff --git a/scripts/homebrew-smoke-outcomes.ts b/scripts/homebrew-smoke-outcomes.ts
new file mode 100644
index 0000000000..63a6c59747
--- /dev/null
+++ b/scripts/homebrew-smoke-outcomes.ts
@@ -0,0 +1,86 @@
+import { mkdirSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+
+export type SmokeOutcomeStatus = "pass" | "fail" | "skip";
+
+export interface SmokeOutcome {
+  name: string;
+  status: SmokeOutcomeStatus;
+  durationMs: number;
+  details?: string;
+  error?: string;
+  artifactPath?: string;
+}
+
+export class SkipCase extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = "SkipCase";
+  }
+}
+
+export function countOutcomes(outcomes: SmokeOutcome[]): {
+  pass: number;
+  fail: number;
+  skip: number;
+} {
+  return {
+    pass: outcomes.filter((outcome) => outcome.status === "pass").length,
+    fail: outcomes.filter((outcome) => outcome.status === "fail").length,
+    skip: outcomes.filter((outcome) => outcome.status === "skip").length,
+  };
+}
+
+export function writeOutcomeLists(
+  resultDir: string,
+  outcomes: SmokeOutcome[],
+  options: { includeArtifactPath?: boolean } = {},
+): void {
+  const listsDir = join(resultDir, "outcome-lists");
+  mkdirSync(listsDir, { recursive: true });
+  const passed = outcomes.filter((outcome) => outcome.status === "pass");
+  const failed = outcomes.filter((outcome) => outcome.status === "fail");
+  const skipped = outcomes.filter((outcome) => outcome.status === "skip");
+  const withArtifact = options.includeArtifactPath === true;
+
+  writeFileSync(
+    join(listsDir, "passed-tests.tsv"),
+    [
+      withArtifact ? "test\tduration_ms\tdetails\tartifact_path" : "test\tduration_ms\tdetails",
+      ...passed.map((outcome) => [
+        outcome.name,
+        String(outcome.durationMs),
+        tsv(outcome.details ?? ""),
+        ...(withArtifact ? [tsv(outcome.artifactPath ?? "")] : []),
+      ].join("\t")),
+    ].join("\n") + "\n",
+  );
+  writeFileSync(
+    join(listsDir, "failed-tests.tsv"),
+    [
+      withArtifact ? "test\tduration_ms\terror\tartifact_path" : "test\tduration_ms\terror",
+      ...failed.map((outcome) => [
+        outcome.name,
+        String(outcome.durationMs),
+        tsv(outcome.error ?? outcome.details ?? ""),
+        ...(withArtifact ? [tsv(outcome.artifactPath ?? "")] : []),
+      ].join("\t")),
+    ].join("\n") + "\n",
+  );
+  writeFileSync(
+    join(listsDir, "skipped-tests.tsv"),
+    [
+      withArtifact ? "test\treason\tartifact_path" : "test\treason",
+      ...skipped.map((outcome) => [
+        outcome.name,
+        tsv(outcome.details ?? ""),
+        ...(withArtifact ? [tsv(outcome.artifactPath ?? "")] : []),
+      ].join("\t")),
+    ].join("\n") + "\n",
+  );
+  writeFileSync(join(resultDir, "failures.json"), `${JSON.stringify(failed, null, 2)}\n`);
+}
+
+function tsv(value: string): string {
+  return value.replace(/\t/g, " ").replace(/\r?\n/g, "\\n");
+}
diff --git a/tests/package-system/homebrew-smoke-helpers.test.ts b/tests/package-system/homebrew-smoke-helpers.test.ts
new file mode 100644
index 0000000000..f309997e38
--- /dev/null
+++ b/tests/package-system/homebrew-smoke-helpers.test.ts
@@ -0,0 +1,82 @@
+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import {
+  browserCaseNamesForFormula,
+  browserSmokeCasesForFormula,
+  browserUnsupportedReason,
+  parseHomebrewSmokeFormula,
+  SQLITE_BROWSER_CONSUMER_PATH,
+} from "../../scripts/homebrew-package-smoke-cases";
+import {
+  countOutcomes,
+  writeOutcomeLists,
+  type SmokeOutcome,
+} from "../../scripts/homebrew-smoke-outcomes";
+
+describe("Homebrew package smoke helpers", () => {
+  it("plans concrete browser cases for pilot packages", () => {
+    expect(browserCaseNamesForFormula("bzip2")).toEqual(["bzip2_help"]);
+    expect(browserCaseNamesForFormula("xz")).toEqual(["xz_version"]);
+    expect(browserCaseNamesForFormula("sqlite")).toEqual(["sqlite_basic_consumer"]);
+
+    const bzip2 = browserSmokeCasesForFormula("bzip2")[0];
+    expect(bzip2.command).toContain("/home/linuxbrew/.linuxbrew/bin/bzip2 --help");
+    expect(bzip2.argv).toEqual(["/home/linuxbrew/.linuxbrew/bin/bzip2", "--help"]);
+    expect(bzip2.expected.test("bzip2, a block-sorting file compressor")).toBe(true);
+
+    const sqlite = browserSmokeCasesForFormula("sqlite")[0];
+    expect(sqlite.command).toBe(SQLITE_BROWSER_CONSUMER_PATH);
+    expect(sqlite.argv).toEqual([SQLITE_BROWSER_CONSUMER_PATH]);
+    expect(sqlite.expected.test("PASS")).toBe(true);
+  });
+
+  it("classifies supported formulas and the current wasm64 browser boundary", () => {
+    expect(parseHomebrewSmokeFormula("hello")).toBe("hello");
+    expect(parseHomebrewSmokeFormula("sqlite")).toBe("sqlite");
+    expect(() => parseHomebrewSmokeFormula("zlib")).toThrow(/formula must be/);
+    expect(browserUnsupportedReason("wasm32")).toBeUndefined();
+    expect(browserUnsupportedReason("wasm64")).toMatch(/wasm64 browser compatibility is unsupported/);
+  });
+
+  it("writes passed, failed, and skipped outcome lists with browser artifact paths", () => {
+    const dir = mkdtempSync(join(tmpdir(), "kandelo-homebrew-smoke-"));
+    try {
+      const outcomes: SmokeOutcome[] = [
+        {
+          name: "browser_smoke_bzip2_help",
+          status: "pass",
+          durationMs: 12,
+          details: "terminal command passed",
+          artifactPath: "/tmp/terminal.txt",
+        },
+        {
+          name: "browser_smoke_xz_version",
+          status: "fail",
+          durationMs: 34,
+          error: "terminal command exited 1",
+          artifactPath: "/tmp/trace.zip",
+        },
+        {
+          name: "browser_smoke_sqlite_consumer",
+          status: "skip",
+          durationMs: 0,
+          details: "sqlite consumer compiler is unavailable",
+          artifactPath: "/tmp/sqlite",
+        },
+      ];
+      writeOutcomeLists(dir, outcomes, { includeArtifactPath: true });
+
+      expect(countOutcomes(outcomes)).toEqual({ pass: 1, fail: 1, skip: 1 });
+      expect(readFileSync(join(dir, "outcome-lists", "passed-tests.tsv"), "utf8"))
+        .toContain("test\tduration_ms\tdetails\tartifact_path");
+      expect(readFileSync(join(dir, "outcome-lists", "failed-tests.tsv"), "utf8"))
+        .toContain("browser_smoke_xz_version\t34\tterminal command exited 1\t/tmp/trace.zip");
+      expect(readFileSync(join(dir, "outcome-lists", "skipped-tests.tsv"), "utf8"))
+        .toContain("browser_smoke_sqlite_consumer\tsqlite consumer compiler is unavailable\t/tmp/sqlite");
+    } finally {
+      rmSync(dir, { recursive: true, force: true });
+    }
+  });
+});