Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions apps/browser-demos/pages/homebrew-smoke/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kandelo Homebrew Browser Smoke</title>
<style>
:root {
color-scheme: dark;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
background: #111;
color: #eee;
}

body {
margin: 0;
padding: 16px;
}

#status {
margin-bottom: 12px;
}

#log {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
</style>
</head>
<body>
<div id="status">Loading</div>
<pre id="log"></pre>
<script type="module" src="./main.ts"></script>
</body>
</html>
128 changes: 128 additions & 0 deletions apps/browser-demos/pages/homebrew-smoke/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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[];
stdin?: string;
}

interface HomebrewSmokeResult {
exitCode: number;
stdout: string;
stderr: string;
combined: string;
durationMs: number;
}

declare global {
interface Window {
__homebrewSmokeReady: boolean;
__runHomebrewSmoke: (request: HomebrewSmokeRequest) => Promise<HomebrewSmokeResult>;
}
}

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<ArrayBuffer> {
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<never> {
return new Promise((_, reject) => {
window.setTimeout(() => reject(new Error("TIMEOUT")), ms);
});
}

async function runHomebrewSmoke(request: HomebrewSmokeRequest): Promise<HomebrewSmokeResult> {
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 defaultEnv = [
"HOME=/tmp",
"TMPDIR=/tmp",
"TERM=xterm-256color",
"LANG=en_US.UTF-8",
"PATH=/home/linuxbrew/.linuxbrew/bin:/usr/local/bin:/usr/bin:/bin",
];
const { exit } = await kernel.boot({
kernelWasm: kernelBytes,
vfsImage: vfsBytes,
argv: request.argv,
cwd: request.cwd ?? "/",
env: [...defaultEnv, ...(request.env ?? [])],
uid: 0,
gid: 0,
stdin: request.stdin === undefined ? new Uint8Array(0) : new TextEncoder().encode(request.stdin),
});
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<void> {
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);
});
1 change: 1 addition & 0 deletions apps/browser-demos/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 47 additions & 5 deletions docs/homebrew-publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ For formulae that build Kandelo Wasm artifacts:
for Homebrew bottle selection. Update Kandelo `build.toml` `revision` only
when the underlying Kandelo package output bytes legitimately change.

Current dependency-root formulae mirror the registry manifests' architecture
support: `openssl`, `libcxx`, and `libxml2` build wasm32 and wasm64 bottles;
`libpng`, `libcurl`, and the hybrid `ncurses` package are wasm32-only until
their registry manifests opt into wasm64.

Formula Ruby should read these `HOMEBREW_KANDELO_*` variables for values that
must survive Homebrew environment handling:

Expand Down Expand Up @@ -265,15 +270,52 @@ 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.

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:
For the sqlite/bzip2/xz pilot and later non-hello package checks, use the
generic package smoke runner against a generated tap root:

```bash
/home/linuxbrew/.linuxbrew/bin/hello --version
npx tsx scripts/homebrew-package-node-smoke.ts \
--tap-root /path/to/kandelo-homebrew \
--formula sqlite \
--formula bzip2 \
--formula xz \
--formula openssl \
--formula libcxx \
--formula libxml2 \
--formula libpng \
--formula libcurl \
--formula ncurses \
--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 small consumers against poured library headers and static
libraries before running the validation Wasm on Node. Use a separate wasm64 run
for formulae whose registry manifests declare `arches = ["wasm32", "wasm64"]`.
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 package sidecars,
use the generic browser runner against a generated tap root:

```bash
npx tsx scripts/homebrew-package-browser-smoke.ts \
--tap-root /path/to/kandelo-homebrew \
--formula bc \
--formula coreutils \
--arch wasm32 \
--result-dir test-runs/homebrew-package-browser-smoke
```

The runner builds a precomposed wasm32 VFS image for each package, serves it
through the browser demo's `homebrew-smoke` page, launches Chromium, executes
the package-specific smoke command through `BrowserKernel`, and writes passed,
failed, and skipped outcome lists. Set
`KANDELO_HOMEBREW_BROWSER_SMOKE_SUMMARY` to its `summary.json` when
regenerating sidecars so provenance records the exact browser evidence.

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.
Expand Down
3 changes: 3 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@
# dies later with "makeinfo: command not found" on every
# `*.info` rule.
pkgs.texinfo
# GNU bc's host-side libmath.h generator calls upstream's
# fix-libmath_h helper, which invokes `ed`.
pkgs.ed
# Mozilla CA bundle — Nix's curl is built against
# cacert and looks up its bundle via SSL_CERT_FILE /
# NIX_SSL_CERT_FILE / GIT_SSL_CAINFO. Pure-shell
Expand Down
26 changes: 26 additions & 0 deletions homebrew/kandelo-homebrew/Formula/bc.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
require_relative "../Kandelo/formula_support/kandelo_package"

class Bc < Formula
include KandeloPackageFormula
SOURCE_URL = "https://ftpmirror.gnu.org/gnu/bc/bc-1.07.1.tar.gz"
SOURCE_SHA256 = "62adfca89b0a1c0164c2cdca59ca210c1d44c3ffc46daf9931cf4942664cb02a"

desc "Arbitrary precision calculator language for Kandelo"
homepage "https://www.gnu.org/software/bc/"
url SOURCE_URL
sha256 SOURCE_SHA256
license "GPL-3.0-or-later"

skip_clean "bin"

def install
out_dir = kandelo_build_package("bc", "build-bc.sh", SOURCE_URL, SOURCE_SHA256,
script_env: { "BC_VERSION" => version.to_s })
kandelo_install_bin(out_dir, "bc.wasm", "bc")
end

test do
output = kandelo_run_wasm(bin/"bc", [], input: "2+3\nquit\n")
assert_match "5", output
end
end
34 changes: 34 additions & 0 deletions homebrew/kandelo-homebrew/Formula/coreutils.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
require_relative "../Kandelo/formula_support/kandelo_package"

class Coreutils < Formula
include KandeloPackageFormula
SOURCE_URL = "https://ftpmirror.gnu.org/gnu/coreutils/coreutils-9.6.tar.xz"
SOURCE_SHA256 = "7a0124327b398fd9eb1a6abde583389821422c744ffa10734b24f557610d3283"

ALIASES = %w(
cat ls cp mv rm mkdir rmdir ln chmod chown head tail wc sort uniq tr cut
paste tee true false yes env printenv printf expr test [ basename dirname
readlink realpath stat touch date sleep id whoami uname hostname pwd dd od
md5sum sha256sum base64 seq factor nproc du df
).freeze

desc "GNU core utilities multicall binary for Kandelo"
homepage "https://www.gnu.org/software/coreutils/"
url SOURCE_URL
sha256 SOURCE_SHA256
license "GPL-3.0-or-later"

skip_clean "bin"

def install
out_dir = kandelo_build_package("coreutils", "build-coreutils.sh", SOURCE_URL, SOURCE_SHA256,
script_env: { "COREUTILS_VERSION" => version.to_s })
kandelo_install_bin(out_dir, "coreutils.wasm", "coreutils")
kandelo_install_bin_aliases("coreutils", ALIASES)
end

test do
output = kandelo_run_wasm(bin/"coreutils", ["--coreutils-prog=printf", "ok\n"])
assert_match "ok", output
end
end
26 changes: 26 additions & 0 deletions homebrew/kandelo-homebrew/Formula/diffutils.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
require_relative "../Kandelo/formula_support/kandelo_package"

class Diffutils < Formula
include KandeloPackageFormula
SOURCE_URL = "https://ftpmirror.gnu.org/gnu/diffutils/diffutils-3.10.tar.xz"
SOURCE_SHA256 = "90e5e93cc724e4ebe12ede80df1634063c7a855692685919bfe60b556c9bd09e"

desc "GNU diff, cmp, diff3, and sdiff for Kandelo"
homepage "https://www.gnu.org/software/diffutils/"
url SOURCE_URL
sha256 SOURCE_SHA256
license "GPL-3.0-or-later"

skip_clean "bin"

def install
out_dir = kandelo_build_package("diffutils", "build-diffutils.sh", SOURCE_URL, SOURCE_SHA256,
script_env: { "DIFFUTILS_VERSION" => version.to_s })
%w[diff cmp diff3 sdiff].each { |tool| kandelo_install_bin(out_dir, "#{tool}.wasm", tool) }
end

test do
output = kandelo_run_wasm(bin/"diff", ["--version"])
assert_match "diff", output.downcase
end
end
33 changes: 33 additions & 0 deletions homebrew/kandelo-homebrew/Formula/dinit.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
require_relative "../Kandelo/formula_support/kandelo_package"

class Dinit < Formula
include KandeloPackageFormula
SOURCE_URL = "https://github.com/davmac314/dinit/archive/refs/tags/v0.19.4.tar.gz"
SOURCE_SHA256 = "3c0f624eb958f8e884631be4ef687da1e475ebaa6241e7ee330b864e6cd9e30b"

desc "Service supervisor and init system for Kandelo"
homepage "https://github.com/davmac314/dinit"
url SOURCE_URL
sha256 SOURCE_SHA256
license "Apache-2.0"

depends_on "automattic/kandelo-homebrew/libcxx"

skip_clean "bin"

def install
out_dir = kandelo_build_package("dinit", "build-dinit.sh", SOURCE_URL, SOURCE_SHA256,
script_env: {
"DINIT_VERSION" => "v#{version}",
"WASM_POSIX_DEP_LIBCXX_DIR" => Formula["automattic/kandelo-homebrew/libcxx"].opt_prefix,
})
%w[dinit dinitctl dinitcheck].each do |tool|
kandelo_install_bin(out_dir, "#{tool}.wasm", tool)
end
end

test do
output = kandelo_run_wasm(bin/"dinit", ["--version"])
assert_match "dinit", output.downcase
end
end
Loading
Loading