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 d14760066e..ef205903af 100644
--- a/docs/homebrew-publishing.md
+++ b/docs/homebrew-publishing.md
@@ -215,6 +215,28 @@ 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.
+
+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,
@@ -265,19 +287,67 @@ 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
-`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/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/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?
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/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/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/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-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..cf9b958b83 100755
--- a/scripts/homebrew-generate-sidecars-from-env.sh
+++ b/scripts/homebrew-generate-sidecars-from-env.sh
@@ -108,89 +108,191 @@ for dep in package_toml.get("depends_on", []):
deps.append({"name": dep})
version = str(bottle_formula["pkg_version"])
-browser_smoke_status = os.environ.get("KANDELO_HOMEBREW_BROWSER_SMOKE_STATUS", "skipped")
-if browser_smoke_status not in {"success", "skipped"}:
- raise SystemExit(f"invalid KANDELO_HOMEBREW_BROWSER_SMOKE_STATUS={browser_smoke_status!r}")
+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": [],
+ }
+
+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_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_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.",
-}
-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", "")
- 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 +321,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 +329,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 +360,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 +378,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-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
new file mode 100644
index 0000000000..938305148d
--- /dev/null
+++ b/scripts/homebrew-package-node-smoke.ts
@@ -0,0 +1,595 @@
+/**
+ * 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";
+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 FormulaName = "sqlite" | "bzip2" | "xz";
+
+interface CliOptions {
+ resultDir: string;
+ tapRoot: string;
+ formulas: FormulaName[];
+ arch: HomebrewBottleArch;
+ bottleCache: string;
+ timeoutMs: number;
+ maxBytes: number;
+ beadId: string;
+}
+
+interface BuiltVfs {
+ fs: MemoryFileSystem;
+ imageBytes: Uint8Array;
+ reportPath: string;
+}
+
+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 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 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;
+}
+
+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 });
+ }
+ });
+});