Adding a new package recipe¶
The practical walkthrough: you want punix install foo to work for some foo that isn't yet in packages/official/. Two paths.
Path A: translate from a Homebrew formula¶
The fast path when the package already has a brew formula. The translator handles ~80 % of common shapes (cargo, go, npm, autotools, cmake, meson, swift, pip, git-tag sources) — you read its output and ship.
1. Translate¶
uv run python tools/migrate-brew.py \
--brew-core sandbox/homebrew-core \
--out /tmp/foo-out \
--names-from <(echo foo)
Or for a one-off, the script is importable:
from pathlib import Path
from punix.migrate.brew import translate_formula
formula = Path("sandbox/homebrew-core/Formula/f/foo.rb").read_text()
result = translate_formula(formula, name_override="foo")
print(result.pcl) if not result.refused else print(result.refusal_reason)
Common refusal reasons and what they mean: see Corpus blockers → Translator pattern not handled yet.
2. Inspect the translation¶
module foo {
version = "1.2.3"
recipe = "std.cargo"
source = {
type = "url"
url = "https://example.com/foo-1.2.3.tar.gz"
hash = "..."
hashType = "sha256"
}
recipeArgs = {
manifest_path = "crates/foo-cli"
}
deps = [rustc-bootstrap.pname, openssl.pname]
meta = { ... }
}
Walk through:
recipepicked by the translator from the install-block shape (std.cargoforsystem "cargo", "install",std.goforgo build, …). Full list atsrc/punix/realise/recipes_lib/std/.source—type = "url"for tarballs,type = "git"withhash = "vX.Y.Z"for git-tag sources.recipeArgs— recipe-class-specific knobs the translator extracted from the formula. The shape is documented at the top of eachstd.<name>.py.deps— every brewdepends_onthat maps to a Punix recipe in_DEPS_BREW_TO_PUNIX(a table at the top ofsrc/punix/migrate/brew.py).# TODO[brew-import]comments inmeta— anything the translator couldn't auto-translate. Each is a concrete instruction for what to do.
3. Try to build¶
cp /tmp/foo-out/foo.pcl packages/official/foo.pcl
d=$(mktemp -d -t punix-build-XXXXXX)
cp packages/official/*.pcl packages/seeds/*.pcl "$d/"
uv run punix build "$d" --only foo
A green build means: source fetched, hash verified, deps wired, build succeeded, output landed in ~/.punix/store/<hash>-foo-1.2.3/.
A red build typically falls into one of three buckets:
- Missing dep:
${DEP:X}unbound orPackage requirements were not met. Add the missing recipe todeps. If the brew formula relied on the macOS system version, you'll need to declare it explicitly. - Wrong CWD:
manifest path not found/go.mod not found. The build extracted to a subdir the recipe class doesn't auto-detect; passsrc_dir = "."if the repo is flat, orsrc_dir = "<subdir>"otherwise. - Per-recipe shape issue: configure flag mismatch, custom Makefile target, etc. Read the build log at
~/.punix/punix-build-logs/foo.logand hand-tune the recipe.
4. Smoke-test¶
If the binary runs, the recipe is ready to promote.
5. Promote¶
If you've copied to packages/official/foo.pcl and the build is green, you're already done. Two follow-ups for a clean PR:
- Add
"foo": "foo"to_DEPS_BREW_TO_PUNIXinsrc/punix/migrate/brew.pyso the coverage tool and the translator both know about it. - Run
uv run python tools/status-report.pyto refresh the corpus docs.
Path B: hand-write¶
The fallback when the brew formula's install shape doesn't translate (shell-only installs, vendored multi-source bundles, xcodebuild, etc.) or when the package isn't in brew at all.
1. Pick a recipe class¶
| Build shape | Recipe class |
|---|---|
./configure && make && make install |
std.autotools |
cmake -B build && cmake --build build && cmake --install build |
std.cmake |
meson setup && meson compile && meson install |
std.meson |
make && make install (no autoconf) |
std.make |
cargo install |
std.cargo |
go build |
std.go |
npm install |
std.npm |
gem build && gem install |
std.ruby_gem |
pip install (multi-package via pip's resolver) |
std.python_venv |
| Pure-python single-package install | std.python_wheel |
swift build -c release |
std.swift |
| Copy files only | std.copy_tree |
| Custom — write the bash yourself | std.shell |
Each class's accepted recipeArgs is documented at the top of its .py file under src/punix/realise/recipes_lib/std/.
2. Write the .pcl¶
module foo {
version = "1.2.3"
recipe = "std.autotools"
source = {
type = "url"
url = "https://example.com/foo-1.2.3.tar.gz"
hash = "<sha256 of the tarball>"
hashType = "sha256"
}
recipeArgs = {
configure_flags = ["--with-bar=${DEP:bar}"]
}
deps = [autoconf.pname, automake.pname, bar.pname]
meta = {
build_system = "autotools"
description = "One-sentence project description"
homepage = "https://example.com/foo"
license = "MIT"
origin = "hand-written"
}
}
Notes:
- Get the hash by attempting the build. Use a placeholder (64
0s), build, copy the "expected … got …" diff intohash, rebuild. ${DEP:X}references expand to the dep's store path at build time. They only work for deps you've declared indeps.origin = "hand-written"distinguishes from"brew-import"for the upgrade reporter and the status tooling.
3. Build, smoke-test, promote¶
Same as Path A steps 3–5.
Hashes: how to compute one safely¶
For a brand-new source URL whose hash you don't know:
Or let the fetcher tell you:
source = {
type = "url"
url = "https://example.com/foo-1.2.3.tar.gz"
hash = "0000000000000000000000000000000000000000000000000000000000000000"
hashType = "sha256"
}
The build will fail with Source hash mismatch: expected 000…, got <actual>. Copy the got value into the recipe and rebuild. The store is content-addressed, so the wrong-hash attempt left nothing behind.
When the build fails: where to look¶
| Symptom | First place to look |
|---|---|
Source hash mismatch |
The actual hash is in the error message — paste it in. |
DEP: unbound variable |
A ${DEP:X} reference whose X isn't in deps. Either add it or remove the reference. |
Package requirements were not met (autotools) |
Missing dep at configure time. Add it; if pkg-config is the resolution path, make sure pkgconf is in deps. |
manifest path … not found (cargo) |
Source extracted flat or to an unexpected dir. Add src_dir = "." or src_dir = "<subdir>". |
go.mod not found |
Same as cargo's manifest issue; src_dir = "." for git-source checkouts. |
dyld: Library not loaded: @rpath/… at runtime |
Sequoia DYLD-stripping. std.cmake already bakes external-dep rpaths via install_name_tool in _postinstall.sh; for std.shell / std.autotools, pass -Wl,-rpath,${DEP:X}/lib in LDFLAGS. |
cp: <path>: No such file or directory in post_install |
The build never produced that path. Check the actual build output: _PUNIX_BUILD_LOG=1 uv run punix build … keeps the build tree. |
Build logs from each --only attempt land at ~/.punix/punix-build-logs/<name>.log. Sandbox temp dirs are cleaned up on success; on failure they stick around at /var/folders/.../punix-build-<random>/ (macOS) or /tmp/punix-build-<random>/ (Linux).
Related¶
- Corpus status — what's already shipped, by topic.
- Corpus blockers — the failure-class list.
- Extending Punix — adding a recipe class (the
std.<name>itself, not a package using one). - Testing — how to validate your recipe with
make dogfood.