architecture
Architecture & deep dive
Minimal usage is in ../README.md; quick start in ./quickstart.en.md.
ๆฌๆๆกฃ็ฑ README ๆๅ่ๆฅ๏ผไฟ็ๅ จ้จ่ฎพ่ฎก็ป่๏ผไป ไฝ็ฝฎ่ฟ็งป๏ผๆชๅ ๅใ
Architecture
bin/raku-pm.raku Thin entry shell (just forwards args to RakuPM::CLI)
โ
โผ
RakuPM::CLI CLI body: command dispatch + every command implementation
โ (the 900+ lines that used to live in bin)
โ โป It is a module for one reason only โ precomp: bin
โ scripts get NO precompilation and are fully recompiled
โ on every invocation (~0.75s measured); with the body in
โ a module only the thin shell is compiled, cutting
โ startup by 0.6~0.8s (0.79.0)
โ
โผ
RakuPM::Client orchestrator (ร la Zef::Client โ coordinates only)
โ
โโโ RakuPM::Repositories repository *list*: persisted config + aliases + env
โ โ (repositories.json decides which indexes are mounted)
โ โผ
โโโ RakuPM::Resolver dependency resolver (recursion + conflicts + topo sort)
โ โ filters by "does this version really provide the module?", then ranks
โ โ
โ โโโ RakuPM::Repository (role) repository abstraction (pluggable)
โ โ โ โ mixes in Repository::Matching:
โ โ โ the single implementation of
โ โ โ "keyword โ distribution" matching
โ โ โ (shared by search/store/installed/install)
โ โ โโโ Repository::Local local directory repo
โ โ โโโ Repository::Ecosystem remote JSON ecosystem (zef/rea/cpanโฆ)
โ โ
โ โโโ RakuPM::Version semver + constraint matching
โ โ
โ โโโ RakuPM::Spec install-spec parsing (Foo:ver<1.2.3> / [email protected])
โ zero-dependency: shared by install and the query commands
โ
โโโ RakuPM::Store package store (multi-version + content digest)
โ
โโโ RakuPM::Installer installer (store โ CUR::Installation's site/)
โ + transaction/generations + bin wrappers
โ โโโ RakuPM::InstallOptions carrier for the install-chain options
โ โ (collapses 7+ boolean flags into one object)
โ โโโ RakuPM::Ledger the ONLY reader/writer of installed.json
โ โโโ RakuPM::Fs recursive delete / copy / walk / size
โ โ (single implementation point)
โ โโโ RakuPM::Installer::Generations format & lifecycle of generations/
โ โ (id allocation / before / meta / manifest / current / prune)
โ โโโ RakuPM::Installer::ShellTemplates the three platform shell templates
โ (bash / cmd .bat / PowerShell .ps1 โ pure string functions)
โ
โโโ RakuPM::Prefix the ONLY source of the *install prefix*
โ default prefix / prefix resolution /
โ "is this the default prefix?" / promote default.
โ Zero-dependency โ the CLI must know the prefix
โ BEFORE requiring Client (cold-start path).
โ
โโโ RakuPM::Platform the ONLY source of platform differences (H9)
โ OS detection / path-list separator / executable
โ suffixes / MSYS paths / process killing / library
โ search paths / md5 command & output format.
โ Every function reads $*DISTRO at call time, so tests
โ can shadow it (`my $*DISTRO = Distro.new(:name('linux'))`)
โ and really exercise all three platform branches anywhere.
โ
โโโ RakuPM::NativeLib system library (:from<native>) probing
โ logical name โ per-OS file names + search paths + install command
โ (all platform knowledge delegated to RakuPM::Platform)
โ
โโโ RakuPM::Cleaner cache cleanup (`clean` / `gc`): decides which
โ versions are still in use; dry-run by default
โ
โโโ RakuPM::Lock lockfile (exact versions + digests + native-libs,
guarantees reproducibility)
(this one is NOT in the Client tree โ the CLI calls it directly, and it has
nothing to do with the install target)
RakuPM::Author producer side (authoring): works on a distribution directory
โโโ refresh rebuild META6.json's provides from disk
RakuPM::Clientitself is split by responsibility into five roles (SelfManager/Tester/Git/Query/Flusher) mixed in viadoes, so the orchestrator doesn't grow into a thousand-line god class. External behavior is unchanged.
Startup time: measured breakdown, and what we evaluated and did not do (0.79.1)
How fast startup is, why, and which optimisations were evaluated and rejected โ recorded
here so the argument does not have to be re-litigated. Measured on one machine
(raku -Ilib -e โฆ, best of 7 runs):
| Step | Cost | Avoidable? |
|---|---|---|
raku interpreter start (raku -e 1 baseline) | 0.148s | No |
| Loading the first precomp module | +0.20s (fixed cost) | No โ CLI must load |
| Each additional module after that | +0.011s each | Only if it isn't needed |
Full RakuPM::CLI (with its 10 deps) | 0.379s (+0.231s) | โ |
The whole heavy RakuPM::Client graph | ~80ms more than CLI alone | see below |
Key finding: loading a precomp module costs "one fixed charge + a tiny per-module
increment", independent of module size. RakuPM::Installer (1205 lines) and
RakuPM::Net (a tiny zero-dependency module) take almost the same time to load alone
(0.395s vs 0.342s) โ the "big module = slow" intuition does not hold here; what is actually
expensive is the module-loading machinery itself. This is also the real reason the 0.79.0
refactor worked: it did not save "lines of code", it moved bin from
impossible to cache to cacheable.
Where slow commands really spend their time: index loading (0.84.2 measurement + fix)
The section above explains the ~0.4s every command pays; it does not explain why search
takes 12 seconds. Measured in 0.84.2 (same machine, best of 3โ5):
| Command | Before | After |
|---|---|---|
version / list / installed (no index load) | 0.97 / 1.10 / 1.58s | unchanged |
info X --offline | 11.12s | 6.64s |
search X --offline | 12.03s | 7.71s |
Breakdown (rea alone): refresh 5.86s โ 2.4s parsing (wasted) + 2.4s parsing (useful)
0.44s index building + misc โ while the actual matching takes only 0.36s (a keyword with zero hits costs the same as one with many, so the algorithm was never the bottleneck). The bottleneck is that every command slurps and parses the 40 MB of indexes (rea 23.6 + zef 12.6 + cpan 2.6 MB).
The fix: Ecosystem.!read-fetched-at used to slurp + from-json the entire index
just to read one _rakupm_t timestamp โ and !load-rows then parsed the same file all
over again. It now scans only the first and last 4 KB (the timestamp always sits within
the first or last few bytes: rea/cpan at offset 5, zef 24 bytes from the end โ both key
orders are in use, hence both ends), decoded as latin-1 (truncated multi-byte sequences
cannot make it throw).
What still costs (recorded honestly, not done for now): of the remaining ~6.6s, about
4.2s is "parsing 40 MB of JSON" itself โ a necessary cost, since %!by-nv / %!provides
are built from full rows. The only way further down is to change the cache shape: have
refresh also write a "query-sized slim index" (keeping just name / version / auth /
description, i.e. what search and info need โ an estimated 40 MB โ a few MB). That costs a
new cache format, invalidation logic and migration of old caches, and buys about 3.5s โ
evaluated and not done: complexity traded for time, which conflicts with the
"bicycle, not motorbike" principle.
Another hotspot: verify's content check (0.84.3 โ 0.86.0, three iterations)
Of verify's 19.3s, 17.6s goes into per-file MD5 (33 installed entries / 296 files /
2.1 MB). The way this got fixed is itself a "measure first" case study โ each step overturned
the previous step's premise:
0.84.3: repaired a silently-dead fast path. There used to be a layer "files โฅ64KB go to the system md5 command" (
certutil -hashfile/md5sum/md5 -q). Measurement showed it had never worked on Chinese Windows: certutil prints GBK (MD5 \xb5\xc4 โฆ ๅๅธ:),$proc.out.slurp(:close)decodes as UTF-8, throws "Malformed UTF-8", and the surroundingtryswallowed it โ empty output โ fall back to pure Raku. So every big file paid for a doomed process launch (0.25s) plus the slow pure-Raku hash (331KB measured at 2.07s) โ slower than pure Raku alone. Reading bytes (:bin) and decoding as latin-1 fixed it: 331KB file 2.5s โ 0.25s,verify19.3s โ 13.5s.0.85.0: hash less (manifest sidecar). With parallelism ruled out (below), the move was to hash only when content actually changed: each store entry's
.files.json(a sidecar like.digest/build.jsonโ not counted in the fingerprint, never copied into an install prefix) records every file's (size, mtime, md5); unchanged files reuse the old digest.verify: ~13s first time, 1.8s afterwards. Trade-off stated plainly: a modification that also restores size and mtime is not detected; for unconditional full hashing setRAKUPM_VERIFY_NOCACHE=1.0.86.0: change the implementation (native binding) โ the real answer. Both earlier steps still argued inside the premise "pure-Raku MD5 runs at 0.16 MB/s". The root fix is to hand CPU-bound work to native code: NativeCall-bind the system libcrypto
MD5(), measured at 0.246s for the same files (44ร faster than pure Raku, with RFC vectors bit-identical to the pure-Raku path).verifywith forced full hashing: 11.7s โ 2.86s โ by then the remaining cost is mostly ~1s of CLI/Client startup plus file IO, no longer the hashing. Library names are probed across platforms (libcrypto/libcrypto.so.3/.so.1.1/libcrypto.dylibโฆ), validated with an RFC vector on first use, and fall back to pure Raku. The "system command" layer was then deleted โ with a native binding it is pure overhead, while the platform special cases it carried (three output formats, per-OS command names) and the GBK-class trap were real cost. One path fewer = one class of platform-only bugs fewer.
Parallelism: measured useless, recorded so nobody retries it. With 296 files read into
memory and only the MD5 computed, 8 threads buy just 1.18x (10.97s โ 9.27s, results
bit-identical to serial) โ pure-Raku hashing does not truly parallelise on MoarVM.
(Contrast: parsing the index JSONs in parallel does help (4.32s โ 3.31s) because that path
involves IO.) Multiple processes are no better: each raku subprocess costs ~1s to start, and
8 of them eat the entire gain. Conclusion: for CPU-bound work there are only two real ways
out โ a native implementation, or computing less (manifests / caches).
Evaluated and not done: splitting / lazifying the heavy RakuPM::Client graph
The idea: keep read-only commands (list / installed / which / env / lock /
outdated / doctor) from loading Installer / Builder / Tester. Verdict: not done,
for three reasons:
The ceiling is only ~80ms (the 0.231s โ 0.292s gap), and it only matters for pure query commands; for
install(seconds to minutes) it is noise at the 0.1% level.Cost and payoff are asymmetric:
Client's attributes carry compile-time type constraints (has RakuPM::Installer $.installer;), itnews Installer / Builder / Store / Lock inTWEAK, and it is composed of 5 roles. Actually keeping read commands from loading those would require dropping the type constraints and switching to lazy accessors โ i.e. giving up the compile-time guarantees of the most safety-critical class in the project (whole-tree transactional install).It is a different kind of change from 0.79.0: that one had an architectural payoff (bin can never get precomp); this one has none โ
Clientis already lazilyrequired (CLI:513) and already precomp'd, so splitting it only breaks something already cheap into smaller pieces.
One suspicious item ruled out too: CLI's top-level use JSON::Fast (used exactly once,
by raku-pm version to read META6.json) looks like it could be dropped, but RakuPM::Spec
already does use JSON::Fast โ the transitive import is unavoidable, and the measured
marginal cost is just 0.020s.
What remains โ raku start plus the first module load โ is a fixed, unavoidable cost.
HTTP layer: single backend (curl)
All network traffic goes through the RakuPM::HTTP facade; a backend implements
the RakuPM::HTTP::Backend role:
| Backend | Under the hood | Notes |
|---|---|---|
RakuPM::HTTP::Tinyish | HTTP::Tinyish (system curl) | the only backend; best TLS/proxy support (incl. HTTPS-over-proxy CONNECT); timeout becomes curl's --max-time |
As of 0.82.0 there is a single backend. It used to ship a pure-Raku HTTP::Tiny
fallback plus an auto-fallback order and a pluggable registry
(RAKUPM_HTTP_BACKEND / register-backend); all of that is gone โ the "pluggability"
had little value for a single-machine teaching tool and a large maintenance surface, and
post-form / post-json (upload / login) already shell out to curl directly, so
collapsing onto one curl path is simply simpler. When a test needs a stand-in, inject one
via the :backend constructor argument (see t/http-debug.t).
Two traps already hit in production; avoid them when writing / changing a backend:
Pass request headers to
.get(), not to.new(). HTTP::Tinyish spells the constructor optiondefault-headers;.new(headers => โฆ)is silently dropped. This once meantIf-None-Matchwas never actually sent, so the ETag/304 conditional refresh did nothing and every TTL expiry re-downloaded 10โ18 MB.The timeout option is
timeoutfor Tinyish (notmax-time). Getting it wrong means no timeout at all: a stalled connection hangs forever (identical on Windows and WSL โ it is not a platform issue).
The install pipeline
resolve โ fetch โ build โ store โ test โ install โ lockresolve: look up which distributions can provide the target module, pick the highest version matching the constraint, expand dependencies into a tree, then topologically sort into a dependency-first, bottom-up order.
fetch: local repos point straight at a directory; ecosystem repos build a URL from the index's
path, HTTP-download the tarball and unpack it intocache/dist/<dist>/<version>/.build: if the dist ships a
Build.pm(convention: aclass Buildwhosebuildmethod returns truthy) or its META6 declares a"builder"field (e.g.Distribution::Builder::MakeFromJSON), a raku subprocess runs the build from the dist root; a false result fails the install.build-depends(the builder class is usually a separate ecosystem package) are resolved and installed before the build;--no-buildskips the stage entirely. Native packages typically compile their C sources intoresources/libraries/*.sohere.store: the source directory is admitted (MD5 content digest); one download can be deployed repeatedly. Build-generated resources are copied into the store here, which is what makes
%?RESOURCESwork after install.test: run
t/*.tandt/*.rakutestagainst the source directory (-I lib -I inst#<target>/site, dependencies already installed). Any failing case aborts the install;--no-testskips it.install: hand the distribution to
CompUnit::Repository::Installation, which writes it intotarget/site/. It defaults to:precompile, so Rakudo manages precompilation โ the early approach of shelling out toraku -M<mod>to precompile into a flatlib/is gone (slower and it carried no version metadata).lock: write exact versions + digests.
generation (atomic install): steps 3โ7 above are wrapped in a single transaction โ if any package fails, the target is restored to exactly what it was before the run; only if everything succeeds is a generation recorded, which you can return to with
raku-pm rollback. See Atomic install and generation rollback below.
A git install runs the same pipeline. Only steps 1 and 2 differ for the target
package: resolve treats the META6.json depends as a set of roots (see
below), and fetch skips downloading in favour of the clone directory.
Atomic install and generation rollback
An install either takes effect completely or not at all โ never a
half-installed target:
raku-pm install A # A depends on B โ order [B, A]
B installed into the CUR โ
A's tests fail โ
โ rollback: B is removed too; the target is back to its previous stateraku-pm generations # list all generations
raku-pm rollback # go back one generation
raku-pm rollback 000003 # go back to a specific generationWhy generations are cheap: CompUnit::Repository::Installation supports
multiple coexisting versions, so installing a new version never erases an older
one. "Rolling back" therefore means removing the versions this run installed from
the CUR and restoring installed.json to its pre-transaction snapshot โ the older
versions were never gone. A generation is thus a few KB of JSON, not a copy of a
hundreds-of-MB site/.
Layout:
target/
โโโ site/ the live CUR::Installation
โโโ installed.json current ledger (a generation's manifest is its snapshot)
โโโ generations/
โโโ current current generation id
โโโ 000001/
โ โโโ before.json ledger snapshot before the transaction (rollback truth)
โ โโโ manifest.json ledger after commit (what this generation looks like)
โ โโโ meta.json timestamp, what was installed
โโโ 000002/โฆThe 10 most recent generations are kept; the current one is never pruned.
Limits (stated honestly):
build/testrun against the source directory and never touch the target, so they are already atomic. The transaction protects the "activate into the target CUR + write the ledger + place bins" part.Rolling back to a generation whose sources have since been removed from the store by
raku-pm cleanwarns and skips (it cannot be reinstalled). Don't gc aggressively if you might need to roll back.When a same-version
--forcereinstall is rolled back, the copy in the CUR is removed; if the store still has that version it is reinstalled automatically, so you never end up with "the ledger says it's installed but it can't be loaded".
Reverse dependencies and reclamation
Dependencies are declared as module names ("depends": { "Foo::Bar" => "1.0" })
while the ledger keys on distribution names; provides bridges the two, giving a
dependency graph. A reverse dependency is just its reversed edge.
raku-pm rdepends Foo # who depends on Foo (a module name works too)
raku-pm uninstall Foo # refuses while something still depends on it
raku-pm uninstall Foo --recursive # remove its dependents too
raku-pm uninstall Foo --force # force (leaves broken dependencies; your call)
raku-pm autoremove # reclaim packages nothing depends on any more
raku-pm autoremove --dry # list only, change nothingAfter uninstall, packages that nothing depends on any more are reported, e.g. after
removing Top:
ไปฅไธ 1 ไธชๅ
ไธๅ่ขซไปปไฝๅทฒ่ฃ
ๅ
ไพ่ต๏ผๅฝๅๆฏไฝไธบไพ่ต่ฃ
่ฟๆฅ็๏ผ๏ผ
ยท Mid 1.0
raku-pm autoremove ๅฏไปฅๆๅฎไปฌไธ่ตทๆธ
ๆWhy it never deletes blindly: every ledger entry carries a reason โ
| reason | meaning | reclaimed by autoremove? |
|---|---|---|
explicit | the user asked for it (last entry of @order, i.e. the target) | never |
dependency | pulled in as a dependency | yes, once nothing depends on it |
Entries from older ledgers without reason are treated as explicit โ better to
reclaim nothing than to silently delete something the user installed. This is the
same idea as apt's auto/manual marker: without it, "auto reclamation" is just
"deleting things".
autoremove loops until it converges: removing Mid can turn Leaf (only depended on
by Mid) into a new orphan, which is then handled too.
Verify: content integrity + install consistency
raku-pm verify # both sections
raku-pm verify --content # content integrity only (old behaviour)
raku-pm verify --fix # also repair what can be repaired1. Content integrity โ has the source in the store been modified? Covers every
version in versions[], not just the active one.
2. Install consistency โ do the ledger / CUR / store / resources agree? This
section was added later because section 1 alone gives false confidence: in the GDBM
incident the CUR was already half-broken (site/dist/<id> gone) while the store copy
was perfectly intact, so section 1 still reported "all fine".
| Issue | Meaning | Auto-fixable |
|---|---|---|
| In ledger, not in CUR | broken/half-installed CUR entry | no, reinstall |
| In ledger, not in store | rollback/reinstall can't restore it | no, reinstall |
| In CUR, not in ledger | breaks installed, reclamation, rollback | yes, re-record |
| Declared resource file gone | build output was deleted | no, reinstall |
| Resource still absent after the build | the build didn't succeed (detectable since 0.44.0) | no, reinstall |
builder declared but no build trace | installed by an older raku-pm; unverifiable | no, reinstall |
Installed with --no-build | build deliberately skipped, yet a builder is declared | no, reinstall |
| Build failed but was stored anyway | should not happen | no, reinstall |
Build traces: the build.json sidecar (since 0.44.0)
Every store entry now carries a build.json recording what the build phase actually
did: mode (built / skipped-no-build / not-needed / not-recorded), success
or failure, which raku was used (the compiler release, handy for diagnosing
precomp/ABI mismatch), and the declared resources that were never produced.
This closes the honest limit stated below. Declaring only resources that exist on
disk is a necessary trade-off in Store (declaring a phantom resource makes
CUR::Installation fail), so "the build never succeeded โ the resource was never
produced" used to be silently erased from the store's META6.json; the
metadata looked perfectly clean and nothing could be checked. build.json keeps
the erased evidence verbatim, which is what finally makes verify able to report it.
It is deliberately excluded from the content digest (skipped alongside
.digest in !fingerprint): it is a record of how the entry was produced, not
distribution content, and it carries a timestamp โ including it would make every
recomputation drift and raise false "content was modified" alarms.
What remains undetectable: a resource that no META6 ever declared (upstream packaging never listed it). In that case there is no record of what should exist; we don't pretend otherwise.
Repositories: a configurable array
The Raku world has more than one ecosystem index, and their coverage differs a lot (measured 2026-09):
| Alias | Index URL | Records | Unique dists | Note |
|---|---|---|---|---|
zef | https://360.zef.pm | ~8k | ~8k | current versions |
rea | โฆ/Raku/REA/main/META.json | ~15k | ~6k | ecosystem archive, incl. old versions |
cpan | โฆ/ugexe/Perl6-ecosystems/master/cpan1.json | ~2k | ~2k | Perl6 modules on CPAN |
p6c | โฆ/ugexe/Perl6-ecosystems/master/p6c1.json | โ | โ | legacy archive |
922 distributions exist only in REA and not in the zef index, so a single index cannot install everything โ repositories have to be an array.
Config precedence (high โ low):
$RAKUPM_TARGET/repositories.json(maintained byrepos add/remove, persisted)RAKUPM_ECOSYSTEMenv var โ comma/semicolon separated, each entry an URL or aliasdefault: the
zefindex + the local$RAKUPM_REPOdirectory
One easy trap: RAKUPM_REPO is an invocation-level setting (it's "which
directory to look for local packages this time"), so it overrides the local path
stored in repositories.json. RAKUPM_ECOSYSTEM by contrast is a fallback for
persistent config and only applies when no config file exists. Otherwise
RAKUPM_REPO=./vendor raku-pm install Foo would silently reuse the path stored
last time, which looks like "I put the package there and it still can't find it".
raku -Ilib bin/raku-pm.raku repos # list configured repos
raku -Ilib bin/raku-pm.raku repos add rea # add the ecosystem archive (alias)
raku -Ilib bin/raku-pm.raku repos add https://example.com/eco.json # or a full URL
raku -Ilib bin/raku-pm.raku repos remove rea
# or point at several indexes without touching disk
RAKUPM_ECOSYSTEM='https://360.zef.pm,rea' raku -Ilib bin/raku-pm.raku install ADTWhen one index cannot be fetched (since 0.49.5: degrade + warn, never abort the
whole command): the resolver asks every repository, so a single unreachable index
used to break the entire command โ even when the answer was sitting in a reachable
repository (a local directory repo, or the zef index). Both cases now continue:
| Situation | Behaviour |
|---|---|
| Fetch failed, an old cache exists | reuse the cache, warn "may be stale, run raku-pm repos update" |
| Fetch failed, never cached | treat it as an empty index, warn "results may be incomplete" |
Either way one โ line names the index and the URL. So if a "module not found"
appears right below a few index โ lines, that means "it may exist, I just
cannot see it right now" โ fix the network/proxy and retry, or raku-pm repos remove
the index you don't need. It never degrades silently: an extra warning line beats
letting you believe the package really does not exist. (A common case in mainland
China: cpan / rea on raw.githubusercontent.com are unreachable while the primary
index 360.zef.pm works fine โ you can still install anything that index has.)
Local directory repos (no index file needed)
One directory holding one subdirectory per distribution, each with META6.json + lib/:
~/my-dists/
โโโ HTTP-Client/{META6.json, lib/...}
โโโ Web-App/{META6.json, lib/...}# a directory is detected as a local repo (name defaults to the basename,
# override with --name=; the absolute path is stored)
raku -Ilib bin/raku-pm.raku repos add ~/my-dists
# fill it: fetch sources (download only, no install), or drop/symlink dirs yourself
raku -Ilib bin/raku-pm.raku fetch HTTP::Client --to=~/my-dists
raku -Ilib bin/raku-pm.raku repos remove ~/my-dists # by path or by nameAfterwards install / search / dependency resolution all consult this repo.
Two things worth keeping apart:
A local repo is a source of sources, not installed packages โ installed ones live in
$RAKUPM_TARGET/site/;A single package you are developing does not need a repo: just
raku-pm install ./my-pkg(its dependencies are installed automatically). A repo pays off for several local packages you want to install by name and have resolved as dependencies.
Install prefix
--target=<path> sets the install prefix (one prefix = one self-contained package
environment). It overrides the RAKUPM_TARGET env var and defaults to ~/.raku-pm.
It works before or after the command โ both positions are equivalent:
raku -Ilib bin/raku-pm.raku install Foo --target=./vendor
raku -Ilib bin/raku-pm.raku --target=./vendor install Foo # same thingA leading ~ is expanded: --target=~/myenv means myenv in your home directory,
not a literal directory named ~ under the current one. (Rakudo itself does not
expand ~ inside an IO::Path; raku-pm does that at the entry point.)
The prefix is self-consistent: the wrapper in <prefix>/bin/ passes its own prefix
to the CLI explicitly โ whichever prefix's wrapper you invoke, that's the prefix you
operate on. Same semantics as <venv>/bin/python.
Whether to write the global site/bin: after installing, raku-pm copies the wrapper
into <rakudo>/share/perl6/site/bin (the directory zef uses, already on your PATH) so
the command works immediately. That default applies only to the default prefix;
a custom prefix never writes there โ the global site/bin is shared machine-wide, so a
local install writing into it pollutes the global environment, and multiple prefixes
would overwrite each other:
| Prefix | Default | Override with |
|---|---|---|
default ~/.raku-pm | writes global site/bin (zef-like: usable right away) | --no-promote-bin |
custom <path> | writes nothing outside the prefix | --promote-bin |
With a custom prefix, the install prints exactly what you need to configure:
export RAKULIB="inst#<prefix>/site" # module search path (the inst# prefix is required)
export PATH="<prefix>/bin:$PATH" # command search path (put it first)raku-pm env prints these two lines for the current prefix.
Why bin must come first on PATH: across directories it is PATH order that
decides, and only within a single directory does PATHEXT get to compare extensions.
So putting <prefix>/bin first beats a <name>.exe sitting in zef's directory โ
no need to touch other tools' files.
Recommended: write nothing outside the prefix (PATH mode)
The default above copies the wrapper into the global site/bin (zef-style). If you'd
rather have raku-pm leave files only inside its own directory, switch the default
prefix to PATH mode too:
# 1) put the prefix's own bin first on PATH (add to your shell profile)
export RAKULIB="inst#$HOME/.raku-pm/site"
export PATH="$HOME/.raku-pm/bin:$PATH" # order matters: before rakudo's site/bin
# 2) from now on, never write to the global site/bin (self-upgrade honours it too)
raku-pm self-upgrade --no-promote-bin
# 3) optional: remove the three copies previously written to the global site/bin
# (<rakudo>/share/perl6/site/bin/raku-pm, raku-pm.bat, raku-pm.ps1).
# Only do this after step 1 works (`raku-pm env` shows [0] = <prefix>/bin),
# otherwise you end up with no entry point at all.Three upsides: (1) nothing lives outside the prefix, so upgrading or reinstalling
rakudo cannot take it away; (2) you never modify files left behind by another package
manager (the 0.48.0 "entry check" rename was only a remedy); (3) undoing it is just a
PATH edit, with no leftovers. The cost: you configure those two lines once โ
raku-pm env prints them for the current prefix.
Multiple projects / versions side by side: one prefix is one isolated environment:
raku-pm --target=./projA/.raku-env install Foo # project A's own environment
raku-pm --target=./projB/.raku-env install Foo # project B's โ versions may differA custom prefix is in PATH mode by default (it writes no global site/bin), and the
wrapper in <prefix>/bin/ passes its own prefix to the CLI โ whichever prefix's
wrapper you invoke, that's the prefix you operate on.
โ A global RAKULIB is a trap when developing raku-pm itself
Exporting RAKULIB from your shell profile (as suggested above) is right for
consuming packages โ plain raku can then use Foo too. But note the side effect:
plain raku will also load raku-pm's own copy of RakuPM from <prefix>/site.
If that copy is old, running tests or scripts inside the repo makes you think you are
testing the working tree when you are actually testing the stale copy. The symptom is
that old bugs get reported as failures of the current code, sending you in the
wrong direction entirely (0.49.3 cost a long debugging session for exactly this; it
affects t/ and xt/ files that do not use lib).
Three habits that keep you safe:
Run tests only via
raku tools/run-suite.raku: it passes-I<worktree>/libto every test and self-checks that the chain head really is the worktree, aborting if not (no false green). Besides running tests it carries three static gates: named-argument check, docs discipline (version consistency across three places / provides completeness / bin / Changes) and personal-trace check (nothing that ships in the package may contain the local user name or an absolute path โ this class of leak recurs in new disguises, so it is pinned as a gate;--docs-onlyruns it in seconds);Run a single file as
raku -Ilib t/<x>.t(-Ialways comes beforeRAKULIB);To see which copy plain
rakuwould use:raku-pm which RakuPM::Version(it also lists the shadowed ones);raku-pm now self-checks on startup too: when multiple distinct RakuPM versions appear on the load chain it prints
โ multiple RakuPM versions detected on the load chainto stderr, warning that the loaded copy may not be the one you expect (since 0.54.1; a pure warning, zero-cost, does not change loading behavior).
To clean up stale RakuPM copies in the prefix (use the command, do not delete files by hand):
raku-pm installed RakuPM # versions in the prefix; * = the one `use` picks
raku-pm self-upgrade # make the prefix current (highest version is preferred)
raku-pm uninstall RakuPM --version=0.36.14 # then shed the old ones you no longer wantOutput:
ๅทฒ้
็ฝฎ็ไปๅบ๏ผๅ
ฑ 2 ไธช๏ผๆไผๅ
็บงไปไธๅฐไธ๏ผ๏ผ
[0] zef ่ฟ็จ็ดขๅผ https://360.zef.pm
[1] local ๆฌๅฐ็ฎๅฝ .Search tags each hit with the index it came from:
==> ๆๅ็ๆ็ดขๅผ [zef]๏ผhttps://360.zef.pm
ๅน้
ๅฐ 1 ไธชๅ่ก็๏ผ
ยท [zef] NativeLibs 0.0.9 (zef:raku-community-modules)
Native libraries utilitiesField differences between indexes
Index records are largely isomorphic (name / version / provides / depends),
but two differences matter:
Tarball URL:
360.zef.pmgives a relativepath(needs the host prepended); REA and cpan give an absolutesource-url.!tarball-urlpreferssource-url.auth: some REA records have no top-levelauth; the identity sits inside thediststring (ADT:ver<0.5>:auth<github:timo>).!auth-offalls back to parsing that.
Both the index cache and the unpack directories are namespaced by repository
name: rea and cpan both live on raw.githubusercontent.com (host-based cache
names would clobber each other), and the same distribution+version may exist in
several indexes (so unpack results are isolated per repository).
Local directory layout
$RAKUPM_TARGET/ default ~/.raku-pm
โโโ cache/ ecosystem indexes + downloaded tarballs & unpack dirs
โ โโโ index-360.zef.pm.json
โ โโโ dist/<dist>/<version>/
โโโ store/ home of "downloaded" packages
โ โโโ <dist>/<version>/ multiple versions coexist
โ โโโ META.json raku-pm's own normalised metadata
โ โโโ META6.json Raku standard metadata (the only one CUR reads)
โ โโโ lib/ sources
โ โโโ resources/ resources (native .so/.dll build output lives here)
โ โโโ build.json build-trace sidecar (since 0.44.0; not in the digest)
โ โโโ .digest content digest (MD5, tamper detection)
โโโ site/ โ
module search path: CompUnit::Repository::Installation
โ โโโ precomp/ precompilation, managed by Rakudo
โ โโโ dist/ per-distribution metadata (multiple versions coexist)
โโโ installed.json raku-pm's own installed ledger
โโโ raku-pm.lock lockfileWhy two layers, store and site (ร la cargo's registry/cache + src):
store/is the cache โ one download, many deployments; reinstalling after an uninstall never touches the network again;site/is the runtime, handed to Raku's ownCompUnit::Repository::Installation(the same thing zef installs into), which resolves multiple versions from metadata.
Earlier versions used a flat
lib/*.rakumoddirectory here. That was wrong โ a flat directory carries no version metadata at all, souse Foo:ver<1.1.0>silently loads whatever is there. See the next section.
Path-safety invariant: sanitise foreign strings before they enter a path (0.87.0)
Every <dist>/<version> above is a path component. Both values come from the
package's own META6.json / the ecosystem index row โ i.e. they are controlled
by the publisher, and are therefore untrusted input.
Before the fix, Store only did subst('::','-') (the old private helper was named
!safe-name, but it only handled the module separator, not path-structure
characters), and the $version on that same path was not handled at all. So
"name": "../../../../../../tmp/PWNED" wrote an entire store entry outside the
target โ "publish one package, write files anywhere on the user's machine".
The invariant (do not route around it):
Anything that joins a foreign string into a path must first pass it through
RakuPM::Fs.path-componentโ the project's single sanitiser. Internally it splits input into two families that are deliberately handled differently (the line was drawn explicitly in 0.87.4 โ do not conflate them):Traversal family (
/,\, exactly./.., control characters, trailing dots) โdieoutright. They either escape the target directory or make two distinct names land in the same place. Silently rewriting../xtoxwould make unrelated distributions collide in one directory โ worse than refusing.Windows-reserved family (
*?"<>|) โ percent-encode (*โ%2Aโฆ with%itself encoded as%25so the mapping stays injective). These cannot escape the target; they merely make the filename illegal on Windows. The old behaviour producedInvalid argumenton Windows (retried 3ร, then rolled back, with an error that revealed nothing) and a file literally named*elsewhere (which a shell will glob-expand). Measured on the live index, the affected entries are the 109 whoseversionis the*placeholder (?"<>|%occur 0 times) โ encoding makes them installable on Windows too (verified end-to-end:useworks afterwards). Why not "replace with_": that collides with the real_; injectivity is the one hard constraint here.::and a lone:map to-(the latter is the Windows ADS separator).
Before writing to disk, assert containment with
RakuPM::Fs.assert-under. That is the second line of defence: entry points multiply as the code evolves, and any one that forgets the sanitiser turns into a loud error instead of a silent out-of-bounds write.The seven sites that need it today: โ
Store.path-for(name + version); โกClient/Testertest log dirs; โข theRepository/Ecosystemdownload cache (index-row controlled, and it runs before the store); โฃ theInstallertemp filename; โคAuthorarchive names / publish destination; โฅClient/Git.!fetch-source'sfetch --tooutput directory; โฆClient/Git.!git-cache-key's git cache directory name (added in 0.88.6 โ the only site that joined a whole path verbatim: the address can come from the CLI, fromself-upgrade --from, or from a malicious distribution's META6.jsongit-deps, the last one entirely invisible to the user). (There is one further redundant site,Author.new, whose module name is already regex-validated โ kept so that relaxing that regex later cannot silently open a traversal.) A new path is a new sanitisation site.โ
assert-underis inherently blind to..traversal (measured, documented in 0.88.6): it compares literal prefixes ($child.absolutestarting with$root.absolute ~ sep), and a path containing..literally is underneath. On top of that Raku'sIO::Pathdoes not collapse..(.absoluteand.cleanupboth keep it verbatim; the kernel is what resolves it) โ so such checks look perfectly fine. Conclusion: sanitisation must happen when the path is built; a later assertion cannot repair it. Item 2 above is only a backstop against someone bypassing the sanitiser. โฆ is implemented as stack-style normalisation (..pops one level, an out-of-range..is dropped) rather than "die on sight of..":..is illegal in a remote URL but perfectly legitimate in a local path (self-upgrade --from ../repo), so a blanket die would kill valid usage.
Regression guard: t/path-safety.t โ including an end-to-end assertion that the
escape destinations the old implementation would have produced do not exist, plus a
positive control (a legal name really does install) so the negative assertions
cannot pass vacuously.
Separately: traversal via archive member names (a tar containing ../x) is not
ours to defend โ GNU tar and the BSD tar shipped with Windows both reject embedded
.. and strip leading / and drive letters (verified empirically). That relies on
external defaults, which is exactly why the sanitiser above covers the paths we
build; the two are complementary.
That premise is pinned by xt/tar-member-escape.t (mutation check: adding -P to the
extraction command makes the file land outside the destination and the test goes red on
the spot) โ precisely because it is "an external tool's default behaviour" does someone
need to watch it: adding -P so that some package installs would not be caught by any
other test.
Version management
Multiple versions coexist:
store/can hold bothJSON/1.0.0andJSON/2.0.0; after installing intosite/, both are reachable viauseand you pick with:ver<>.Pin a version:
install JSON --version=1.0.0(downgrades too).Upgrade:
upgrade JSONmoves to the highest version in the repositories.upgradewith no package name upgrades all installed packages (thezef upgrade/apt upgradeconvention).Move to a specific version:
upgrade JSON --version=1.0.0.Downgrade caveat (important): Raku's
use JSONwithout a version constraint always loads the highest version โ that is Rakudo's CUR::Installation behaviour and raku-pm cannot change it. So installing an older version does not switchuseover by default; it just adds another version alongside, and the command tells you so. To make the old version actually take effect, either runupgrade JSON --version=1.0.0 --only(removes the higher ones first), or writeuse JSON:ver<1.0.0>in your code.Uninstall one version:
uninstall JSON --version=1.0.0drops just that version and keeps the rest; without--versionit removes the whole package (all versions).Pre-release ordering (since 0.87.3): follows semver 2.0.0 ยง11 โ
1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0(numeric identifiers sort below alphanumeric ones; numeric identifiers compare numerically rather than lexically; a release outranks any pre-release of the same core;+buildis ignored). There is exactly one implementation of this order:RakuPM::Version.key(with!pre-keybuilding the pre-release segment key), andcmp/sort-versions/pick-bestall go through it. Do not go back to folding the pre-release into a boolean: then alpha/beta/rc1 all share one key,cmpreportsSame, andpick-bestreturns an answer that depends on input order โ no error, no crash, just silently wrong. Measured on the live ecosystem,Heywould degrade from the correct1.0.0-beta.9to1.0.0-beta.2.Uninstall success is decided, not announced (since 0.87.3):
uninstallgoes by what was actually removed from the CUR (Installer.uninstallreturns the list of removed versions). All four "nothing was removed" cases โ package not installed, version doesn't exist, range matched nothing, refused because of reverse dependencies โ now error out with exit code 1 instead of printing "uninstalled". Scripts judge by exit code, and a false success is worse than no message at all.No implicit self-targeting:
uninstallnever defaults to raku-pm itself โ writeuninstall RakuPMexplicitly, so a missing argument can't delete your package manager.Managing raku-pm itself:
self-upgrade [--from=<git-url>] [--from-dir=<dir>] [--dry]โ upgrade raku-pm itself. Unlikeupgrade RakuPM, it does not depend on raku-pm being recorded in our own ledger (it's often zef-installed, outside our ledger), and it does not require RakuPM to be published to any ecosystem index (it isn't โ looking it up in the repos would always fail). Source priority:--from <url>: pull source from the given git remote (overrides everything);if the running copy lives in a git checkout (dev/self-run) โ
git pullthat repo + reinstall from local source;otherwise pull from the default upstream https://gitee.com/skyter10086/raku-pm.git. Requires Git on PATH.
--dryresolves only, without actually replacing anything. Before upgrading it compares versions: if the local copy is already up to date (same version AND same git commit) it skips the download/install entirely; if the version matches but the git commit differs (upstream force-pushed / rewrote history, so content changed) it reinstalls anyway.--forceskips all checks and reinstalls.
self-remove [--dry]โ uninstall raku-pm itself, doing more thanuninstall RakuPM: โ remove the distribution from CUR::Installation; โก deletestore/RakuPM/(per-version source snapshots, usually the bulk of the size); โข delete theraku-pm/raku-pm.batwrappers undertarget/bin/; โฃ drop the RakuPM entries from the ledger and lockfile.--dryonly reports what would be deleted.Caveat: if zef also installed raku-pm, that copy is outside our management โ after
self-remove, theraku-pmcommand may still exist (running zef's copy). Clean it up withzef uninstall RakuPM.
Coexistence โ conflict: with no version given,
usegets the highest one; with:ver<>, Rakudo matches exactly. "Highest" here means realRakuPM::Version.cmpโ0.13.0>0.9.2. Sorting them as strings (the obvious.sort) puts"0.13.0"<"0.9.2"because'1' < '9', and a freshly-installed0.9.2ends up as the active version over a later-installed0.13.0.installed-versionsusesRakuPM::Version.cmpso the highest is always the highest.
Multiple versions and use Foo:ver<1.2.3>
Raku supports use Module:ver<1.2.3>, but having the files on disk is not
enough โ Raku has to know which version each copy is before it can match.
Why a flat lib/ is wrong (and how quietly it's wrong)
If installing just means copying *.rakumod into a flat target/lib/, that
directory has no version metadata whatsoever. Raku's
CompUnit::Repository::FileSystem derives module names from file paths
(lib/Foo/Bar.rakumod โ Foo::Bar) but cannot derive versions, so a :ver<>
constraint is satisfied unconditionally:
target/lib actually holds 2.0.0
> use MultiVer:ver<1.1.0>
ๆๆฏ 2.0.0 โ no error, just the wrong versionThis is the worst kind of failure: it doesn't error, it just gives you the wrong thing.
The right way: CompUnit::Repository::Installation
It's what zef installs into (site / home / vendor are all such repositories).
It stores metadata per distribution, keeps multiple versions of the same module
side by side, and use Foo:ver<1.1.0> is resolved by Rakudo itself โ which errors
out clearly when nothing matches. raku-pm now installs into $RAKUPM_TARGET/site/:
my $cur = CompUnit::Repository::Installation.new(prefix => $target.add('site'));
my $dist = Distribution::Path.new($store-dir, :meta-file($store-dir.add('META6.json')));
$cur.install($dist, :force); # :precompile defaults on; Rakudo handles precompAnd so:
$ raku-pm install Concurrent::Stack --version=1.1
$ raku-pm install Concurrent::Stack # and 1.3
$ raku-pm installed
Concurrent::Stack 1.1
* Concurrent::Stack 1.3
๏ผ* = ไธๆๅฎ็ๆฌๆถ use ไผๆฟๅฐ็้ฃไธช๏ผ
$ raku -I"inst#$HOME/.raku-pm/site" -e 'use Concurrent::Stack:ver<1.1>; ...'
$ raku -I"inst#$HOME/.raku-pm/site" -e 'use Concurrent::Stack:ver<9.9>; ...'
===SORRY!=== Could not find Concurrent::Stack:ver<9.9> in: ...Two details that matter
1. The inst# prefix is mandatory. Without it, Rakudo treats site/ as an
ordinary file-system repository and all the version metadata goes unread. Let
raku-pm env generate it for you:
$ raku-pm env
# bash / zsh / Git Bash๏ผ
export RAKULIB="inst#C:\Users\...\.raku-pm\site"
# PowerShell๏ผ
$env:RAKULIB = "inst#C:\Users\...\.raku-pm\site"2. The store needs an extra standard META6.json. CUR::Installation reads
nothing else, and its provides is module => relative path โ one thing more
than our own META.json carries. The wrinkle: RakuPM::Distribution.provides
only kept the module names; the paths were dropped while parsing the ecosystem
index. So at admission time we scan lib/ and reconstruct them
(Foo::Bar โ lib/Foo/Bar.rakumod; note .rakudoc is docs, not a module, so it
is excluded), using forward slashes throughout (Windows backslashes make
Distribution::Path build bogus paths). Modules declared in provides with no
file behind them are skipped โ including them would just make the install fail.
Three extensions count as module sources: .rakumod / .pm6 / .pm. That is not
our own invention โ Rakudo itself loads .pm as a Raku module (it prints a
deprecated notice; the same works through an inst# repository), and .pm was
the mainstream extension in the early Perl 6 era. This list is defined in exactly
one place (@MODULE-EXTS in RakuPM::Distribution, read elsewhere via
module-extensions) โ do not inline it anywhere: it used to be written out twice
(in provides-with-paths and !walk-source-files), both times missing .pm, so
every distribution from the .pm era installed unusably.
3. Missing provides falls back to the disk (0.87.1). provides is an
optional field in META6, and a fair number of ecosystem entries omit it. A full
index scan on this machine (25117 rows, 146 without provides / 53 distributions)
is quite telling:
| Index | Rows | Missing provides | What it is |
|---|---|---|---|
| zef (modern ecosystem) | 8060 | 0 | modern tooling always writes provides |
| rea (old Perl 6 archive) | 15141 | 138 (53 distributions) | hand-written META.json era: .pm extensions, no provides |
| cpan | 1916 | 8 | genuine Perl 5 distributions (IO-Compress-*, FindBin-libsโฆ), never Raku packages |
So the entries missing provides are precisely the .pm-era distributions โ both
root causes (no declaration, .pm unrecognised) hit the same victims. Missing
provides plus writing out an empty standard META6 gave you a ghost install:
$ raku-pm install Automata::Cellular โ โ done (rc=0)
$ raku-pm installed โ it is listed
$ raku -e 'use Automata::Cellular' โ Could not find โฆ
$ raku-pm verify โ โ no inconsistencies โ no clue at allSo admission now goes through Distribution.provides-from-disk: if there is a
declaration, use it (keeping only entries whose files really exist); if the
declaration is empty, or none of the declared modules exists on disk, reconstruct
from lib/. lib/ is the distribution's own content and deserves more trust
than a missing or corrupted metadata field. If reconstruction still yields nothing
while lib/ does contain files, Store.put refuses admission (better to
install nothing than to write an entry that use can never reach) โ and that check
runs before anything is written, so a refusal does not even create a directory.
Both directions now live in
RakuPM::Distribution:provides-with-paths(module name โ path),scan-provides(scanlib/โ module names) andprovides-from-disk(the fallback above). They used to be a privateStoremethod (and only the first direction existed). After the move, the consumer side (admission writingMETA6.json) and the producer side (refresh/check) share one implementation.
Finding ghosts already on the machine: check #6 of
verify/doctor(issue kindghost-provides) โ "the store's standardMETA6.jsondeclares no module at all whilelib/does contain module sources". Those cannot be repaired (--fixonly back-fills the ledger); reinstalling is the fix (a reinstall goes through the fallback above). The regression guard ist/ghost-provides.t, including a real install plus an externalrakuprocess provinguseworks, and three mutations (drop the fallback / drop.pm/ re-inline the extension list).
Coexisting with zef: two versions of the same module
zef and raku-pm install into separate places (zef into ~/.raku and rakudo's
site/; raku-pm into $RAKUPM_TARGET/site/). Raku's multi-version design
means they coexist naturally without overwriting each other โ two versions
of the same module can live side by side, picked precisely with :ver<>.
The repository chain order (measured):
entries in RAKULIB (inst#<target>/site โ raku-pm's packages live here)
โ ~/.raku (zef home)
โ rakudo's site / vendor / coreSo with RAKULIB exported (raku-pm env generates it), a script sees:
| how you write it | what loads |
|---|---|
use Foo; | the copy raku-pm installed (RAKULIB comes first, first hit on the chain) |
use Foo:ver<0.1.0>; | zef's 0.1.0 (not in raku-pm's site, so the chain falls through) |
Without RAKULIB raku-pm's packages are invisible and everything goes through
zef as before.
To diagnose, raku-pm version Foo lists the versions installed by both
managers and marks the "active" one โ what use Foo actually loads when no
version is specified.
The direct tool: raku-pm which (0.48.0). "First hit on the chain wins"
sounds simple, but your machine usually has more than two repositories โ
this one has three, measured:
| repo | path | distributions (measured) |
|---|---|---|
| home | ~/.raku | 69 |
| rakudo's site (zef's) | <rakudo>/share/perl6/site | 113 |
| raku-pm's site | $RAKUPM_TARGET/site | 12 |
24 distributions exist in more than one repository (and HTTP::Tiny,
JSON::Fast, MIME::Base64 even differ in version). There's also a
home-beats-site effect: without RAKULIB, use JSON::Fast resolves to home's
0.20.1, not site's 0.19.
So don't estimate โ ask:
raku-pm which JSON::Fast # where this module actually loads from, and which version; lists shadowed copies
raku-pm which # cross-repo conflict overview (which packages live in several repos, at what versions)which takes a module name (e.g. JSON::Fast), not a distribution name.
Without an argument it lists all conflicts by repository-chain index โ lower
index wins.
One thing that's easy to conflate: package name collisions and command name
collisions are different problems. The above is the former (one module in
several repositories). The latter is having multiple raku-pm executables on
PATH: Windows resolves via PATHEXT, where .EXE outranks .BAT, so a
leftover <rakudo>/site/bin/raku-pm.exe from the zef era shadows the
bash/.bat wrapper raku-pm writes โ the symptom being "the upgrade succeeded
but raku-pm version still reports the old version". Since 0.48.0 install and
upgrade run an entry check: it computes which file would actually be
executed and, if it isn't one raku-pm wrote, renames it to <name>.zef-old
(reversible) and says so. raku-pm env also lists the candidates in system
resolution order and marks the one that really runs.
Design note: why there is no separate "local package index". Three layers, each with a job:
installed.json(the ledger) โ the local index of installed packages: name, all versions, provides, digest, and the source directory (self-installs and git clones record where they came from). Dependency resolution queries it (O(1));installed/versiondisplay it;site/is itself a CUR::Installation โ Rakudo maintains an incremental index there (short/+dist/), anduseresolution uses it directly; no scanning needed;store/โ the source cache; any installed package's full source can be retrieved from it (reinstalls never re-download).
"Walk the directories every time" is neither necessary nor sufficient:
scanning only tells you what's in a directory, while dependency resolution
needs to know what is installed and whether versions satisfy โ that is the
ledger's job. There is additionally an explicit local: repository (a
RAKUPM_REPO directory laid out as repo/<name>/META6.json) for the
"a bunch of local packages as a repo" scenario, scanned on demand.
Ledger self-heal. site/ and installed.json are kept in sync by two
triggers: raku-pm installed runs a reconcile pass over the chain on entry,
and the install-chain skip branch adopts any pre-existing own-site dist that
is missing from the ledger before saying "already installed". Both make
self-installed packages show up in installed even when the original record
write was missed (e.g. interrupted installs, ledger written by old code).
Lockfile
raku-pm.lock records exact versions + content digests:
{
"version": 1,
"entries": [
{ "name": "JSON", "version": "2.0.0", "auth": "demo:raku",
"digest": "d47078e2...", "depends": [] }
]
}A normal
installupdates the lock automatically;--lockedturns on strict mode: any mismatch aborts. Good for CI.
Committable lockfile (0.54.0)
The lockfile is written to raku-pm.lock in the current working directory by
default (Cargo.lock-style), so it can be committed alongside the project for
reproducible installs ("same versions on a different machine or a later date"):
cd my-project
raku-pm install JSON # pins exact versions into ./raku-pm.lock
git add -f raku-pm.lock # commit it (-f: .gitignore ignores raku-pm.lock by default)
# teammate, after cloning:
raku-pm install --locked # strictly reuses locked versions; missing/drifted lock abortsThe lock describes the project's dependency closure and is independent of the install location (
--target): even if packages go into a separate prefix like.raku-env, the lock stays in the project root โ you commit the lock, not the prefix.--lock-file=<path>relocates the lock anywhere (CI often pins it to a fixed repo path).install/reinstall/self-upgrade/lockall accept it.raku-pm lockshows the current lock contents.Note:
.gitignoreignoresraku-pm.lockby default (to avoid dev leftovers); usegit add -f, or add!raku-pm.lockto your project's.gitignoreto commit it.
Concurrency: a cross-process file lock
One install is a long chain of writes โ read raku-pm.lock, resolve, write to the
store, admit into site, write the lockfile back. Two raku-pm processes running at
once step on each other:
both writing the same version into the store โ half a package, digest mismatch,
verifyfails forever after;both writing
raku-pm.lockโ the later write wipes the earlier one wholesale;both promoting bin wrappers โ a wrapper pointing at a path that isn't finished yet.
So every write command (install / uninstall / upgrade / fetch /
self-upgrade / repos add|remove|update) takes a cross-process file lock first,
and any other raku-pm queues up:
$ raku-pm install Foo # while another terminal installs something else
โณ ๅฆไธไธช raku-pm ๆญฃๅจๅๅ
ฅ /home/user/.raku-pm๏ผ็ญๅพ
ๅฎ็ปๆโฆDetails (all in RakuPM::FileLock):
flock, not "lock file exists": when a process is killed, Ctrl+C'd, or the machine restarts, the OS reclaims the lock โ no stale lock to clean by hand. The lock file is
target/.raku-pm.run.lock; it holds no state. Who/what/when lives in a sidecar.ownerfile instead, because Windows' LockFileEx is a mandatory lock โ while it's held, even reading the locked file is refused.Timeout, not an infinite wait: 600s by default (
RAKUPM_LOCK_TIMEOUT); on timeout it fails loudly and says who is holding the lock.Re-entrant within one process:
installcallsinstall/upgradeinternally, so a reference count is kept โ only the outermost release really unlocks, otherwise a nested release would hand the lock away too early.A "token" is handed to child processes:
Build.pmcallingraku-pm install Xis common, and such children must not wait on the lock their own parent holds (self-deadlock). So on acquiring the lock we mint a random token (written to a.tokensidecar next to the lockfile, plus theRAKUPM_LOCK_TOKENenv var); a child is let through only if both the on-disk token matches and the lock is currently held. The token dies with the lock โ once the parent releases, a late-arriving child queues like everyone else, and a raku-pm started by hand in another terminal has no token at all. (Before 0.44.0 this was "broadcastRAKUPM_NO_LOCK=1while holding" โ i.e. open the door for everyone: any process inheriting that variable got a free pass, including test suites run under the lock, whose assertions then held trivially.)Escape hatch:
--no-lockorRAKUPM_NO_LOCK=1skips the lock entirely โ concurrent writes corrupt the store and lockfile, so only use it when you're sure nothing else is running. This is an explicit user bypass, distinct from the child-process token above: it validates nothing, it just takes effect.
One more from the same family: the ecosystem index cache (~10MB) used to be written
with $cache.spurt($text), so two refreshes at once could interleave into a half
JSON file โ and the victim is the next command, long after the evidence is gone.
It now writes a temp file and renames it into place (atomic within a filesystem),
so readers only ever see the old file or the new one.
Test execution: serial (as of 0.80.0; 0.38.0โ0.79.x ran concurrently)
When installing, the t/ tests run serially, file by file: each test file in its own
raku subprocess (process-isolated, but not sandboxed), one after another. The zef ecosystem
itself runs tests serially, so this is the form closest to the ecosystem default.
$ raku-pm install Foo
ยท Running tests: Foo 0.1.0 (3 files, serial)
โ 01-basic.rakutest
โ 02-race.rakutest
| Failed test 'concurrent push' at โฆ/02-race.rakutest line 12
โป Re-verifying 1 failed case serially once (flaky upstream / transient environment issues)
โ 02-race.rakutest (passed re-verify โ the first-run failure was a false negative, ignored; the first-run log is kept at 02-race.rakutest.log)Why we went back from concurrent to serial (0.80.0): running tests concurrently is fine
in itself; the problem is that keeping it from harming installs required two extra safety
nets โ โ a serial precomp warm-up (removing the rakudo precomp-cache race caused by several
subprocesses first-compiling the same lib/*.rakumod at once, whose symptom is a bogus
===SORRY!===); โก a serial re-verify of failures. That complexity does not pay off for a
single-machine teaching tool, and self-upgrade had already forced serial on its own for
this reason, so 0.80.0 removed the whole concurrency mechanism (and the --test-jobs flag
with it), keeping only the "serial re-verify of failures" โ it guards against upstream
flakiness, unrelated to concurrency.
Key points (locked-in defaults as of 0.38.3):
โ Serial execution: file by file, process-isolated. No parallelism knob any more.
โก Log location defaults to the install target: full output goes to
<target>/log/<dist-name>/<version>/<test-file>.log(<target>defaults to~/.raku-pm); reinstall overwrites, no retention (naturally isolated per dist/version/file). SetRAKUPM_TEST_LOG_DIR=<dir>to relocate the log root to a global spot (e.g.~/.raku-pm) for cross-target inspection.โข Only the first failure line prints to the terminal โ with one exception: nested compile errors, where the innermost cause line(s) after the
===SORRY!===wrappers are printed (up to 3 lines; seeRakuPM::Client::Tester.!failure-lines). Full output stays in the log file above, keeping the terminal clean for later inspection.โฃ Timeout defaults to 300s:
--test-timeout=NorRAKUPM_TEST_TIMEOUTcaps each test file; on timeout the subprocess iskilled and the file counts as failed (still goes through the serial re-verify below).โค Live progress (terminal only): while testing, a single line is redrawn in place on
stderrโtest <file-i>/<file-n> <basename> <cases-done>/<cases-total> cases pct%. Tests run serially (one file at a time), so progress advances by the current file's cases (the numbers keep refreshing; with many cases in one file you can still see it move); before the plan appears, only the done count is shown. Compiling and running each file can go minutes without other output, so silence looks like a hang. Non-TTY (pipe / redirect / test capture) stays silent, leavingstdoutand the log files untouched;RAKUPM_TEST_PROGRESS=0forces it off,=1forces it on.Failures are auto re-verified serially once: upstream flaky cases (e.g.
stress.rakutest) fail intermittently; a re-verify pass means it's a false negative and won't abort the install on a one-off probability. The re-verify log goes to<name>.flake.log, without overwriting the first-run failure log.The known-platform-bug soft-pass (
Log::Async'st/12-context.rakuteston Windows, etc.) and missing-compiler-tool hint are unchanged and still apply.
Test phase vs. install success: the causal chain (0.79.1 / revised 0.80.0)
When a user follows a failure hint to log/ and finds the directory emptied by the rollback
(which is what happened before 0.78.1), this causal chain is exactly what they need.
Where the boundary is: as of 0.80.0 the test phase is serial too; resolve / download / build / store / stage / promote are fully serial (
Client.!install-chain-inneris a plainfor @order -> $dist, bottom-up). The only cross-process concurrency is "tworaku-pmruns at once", serialized byFileLock.What the serial re-verify fixes: upstream flaky cases (
Concurrent::Stack 1.3's stress case was measured failing 3 runs out of 10), giving the user one retry. The re-verify log goes to<name>.flake.log, without overwriting the first-run log.What it cannot fix (residual risks, recorded honestly):
re-verify also fails โ judged a real failure โ whole chain rolls back (dependencies already built in this run are discarded too);
an upstream flaky case that fails both times still fails (re-verify only gives one retry);
a single slow test file: serially, slow cases add up wall-clock time โ may hit
--test-timeout.
Knobs:
--test-timeout=S/--allow-test-failure/--no-test; env varRAKUPM_TEST_TIMEOUT. Full user-facing description is inMANUAL.en.mdยง15.
Cache cleanup: raku-pm clean
The store only ever grows โ every self-upgrade leaves a snapshot, upgrades leave
old versions behind, uninstalled packages leave their store entries, and
downloaded/unpacked tarballs pile up in cache/dist/. Measured on a machine
that had been in use for a while: store 193M (a single package, RakuPM, held
30 versions), cache/dist 110M โ while only the newest copy was actually
installed.
raku-pm clean # dry run: list what could be reclaimed (deletes nothing)
raku-pm clean --yes # actually delete
raku-pm clean --all --yes # also reclaim index cache and git clone cache
raku-pm clean --older-than=30 # only entries untouched for 30 days
raku-pm clean --name=RakuPM # only look at one distributionFour categories are reclaimed by default:
| Category | What it is | Effect of deleting |
|---|---|---|
| Old version snapshots | unreferenced historical versions in the store | reinstall would re-download / re-admit |
| Interrupted admissions | store/<name>/<ver>/ without META.json | cleanup value only |
| Precompiled artifacts | .precomp inside store entries | Rakudo rebuilds on demand |
| Download cache | tarballs + unpacked trees under cache/dist/ | re-downloaded next time |
--all adds two more (rebuilding them costs a download/clone, so they are kept
by default): the cache/index-*.json index caches and the git-cache/ clones.
The safety boundary is the only thing that matters here: only versions that are referenced nowhere are deleted. Three sources are protected โ
versions recorded in the
installed.jsonledger (all entries ofversions);versions actually installed in our own
site/(CUR::Installation) โ the ledger may have gaps, disk wins;versions pinned by
raku-pm.lockโ a--lockedinstall would reuse them.
Nothing inside site/ is touched (it is a separate copy from the store); the
worst outcome of deleting a store entry is a re-download on the next install.
One more guardrail: only paths inside $RAKUPM_TARGET are ever deleted.
Deleting files is irreversible, so the default is a dry run. Deletions run item by item; one busy entry (common on Windows) does not abort the round โ failures are summarized at the end.
Starting over: raku-pm flush
clean is protective reclamation (every rule says what must NOT be deleted). To
nuke and reinstall, use flush: it wipes everything raku-pm installed in the current
prefix โ including its own entry points.
raku-pm flush # dry run: lists what would go (deletes nothing by default)
raku-pm flush --yes # actually deleteRemoved: inside the prefix, store/ site/ git-cache/ cache/ log/
generations/ stage/ bin/ installed.json raku-pm.lock; plus the self-installed
entry points outside the prefix (raku-pm / .bat / .ps1 under
<rakudo>/share/perl6/site/bin).
Two guardrails (for a delete-everything command, the guardrails matter more than the feature):
The directory must look like a raku-pm prefix (
store/,site/orinstalled.jsonpresent) and must not be the home directory itself nor a filesystem root โ otherwise it refuses to run. This is what catches a mistyped--target/RAKUPM_TARGET.Only recognised entry names are deleted; everything else in the prefix is left alone (and listed for you). So a prefix sharing a directory with other files cannot cause collateral damage.
It never touches other tools: a zef-installed RakuPM does not live in this prefix
(clean it with zef uninstall RakuPM), and <name>.exe.zef-old (the only backup of
zef's entry point) is only reported, never deleted.
โ The entry points go too, so after a flush there is no raku-pm command left.
To bring it back:
zef install <path-to-raku-pm-repo> # zef installs it and writes the entry
raku <repo>/bin/raku-pm.raku install . # or use the repo script; it writes the entry itselfHow the three related commands divide up: clean = what can be safely reclaimed;
self-remove = remove raku-pm itself only; flush = wipe everything, start over.
They are deliberately kept separate rather than merged โ so nobody fires off flush
thinking it is an upgraded clean.
Mapping onto zef
| raku-pm component | zef equivalent | role |
|---|---|---|
RakuPM::Distribution | META6.json + Zef::Distribution | distribution metadata |
RakuPM::Repository (role) | Zef::Repository | package-source abstraction |
RakuPM::Repository::Local | Zef::Repository::LocalCache | local repository |
RakuPM::Repository::Ecosystem | Zef::Repository::Ecosystems | remote ecosystem |
RakuPM::Resolver | find-candidates + find-prereq-candidates | dependency resolution |
RakuPM::Installer | Zef::Service::InstallRakuDistribution | installation |
RakuPM::Client | Zef::Client | orchestration |
Core design ideas
1. A distribution is a metadata file
Every package is a directory with a META6.json at its root:
{
"name": "HTTP-Client",
"version": "1.2.0",
"auth": "demo:raku",
"provides": ["HTTP::Client"],
"depends": { "JSON": ">= 1.0.0" }
}The key insight: provides (which modules it offers) and name (the
distribution's name) are two different concepts. One distribution
HTTP-Client may provide several modules, and module names use ::, not -.
Malformed external data: sort it into three classes by "can it be repaired losslessly?" (the line was drawn explicitly in 0.88.1)
Both META6.json and ecosystem index rows are external data (written by the
publisher / generated by a remote index), so malformed shapes are normal. The rule is
neither "be lenient about everything" nor "reject everything" โ ask whether the
malformation can be repaired without losing information:
| Malformation | Handling | Rationale |
|---|---|---|
name / version has the wrong type (1.0 without quotes) or is missing | Error out (die, including file + field + actual type + how to fix) | They are the distribution's identity โ they go into store paths and the ledger โ and they cannot be converted losslessly: "version": 1.0 arrives as a Rat, and ~1.0 yields "1", not "1.0". Silently coercing would record a different version than the user installed |
The same information written in several ways (auth as an array or null, missing provides, depends written as a string) | Normalise (keep one spelling of the same fact; fall back to disk/defaults when absent) | These are synonyms, not errors; normalising loses nothing. An array auth is usually someone copying the authors format |
A null mixed into an array ("resources": [null]) | Filter it out | It is neither a valid value nor worth failing over; leaving it in emits an uninitialized-value warning and leaves an empty-string resource name behind |
Two rules: โ the index path and the META path must share one normalisation
implementation โ measured, the array form of auth was handled only on the META path,
so on the index path Text::Sift4 (cpan index) died with a raw
Type check failed for return value; โก error messages must give an actionable fix,
but must not echo a "corrected literal" (Rat normalises 1.0 to 1, so copying it
would change the version to the wrong value).
2. Repositories are a pluggable role
RakuPM::Repository is a role; implement these and you have a new package source:
method find-providers(Str $module --> List) # who can provide this module
method all-distributions(--> List) # every package
method available-versions(Str $name --> List) # every version of a package
method fetch-distribution(Str $name, Str $version) # materialise the metadata
method source-dir-for(Str $name, Str $version) # source dir (local repos only)That's a simplified take on zef's "plug in a new service backend by editing config.json".
Note source-dir-for: the client must not guess a repository's internal
layout. Directory names and distribution names can differ (directory JSON2/
may hold distribution JSON 2.0.0), so locating sources has to stay inside the
repository.
3. Resolution is recursion + topological sort
Recursive resolution: DFS from the target module over all dependencies.
Conflict detection: when the same module is required with different constraints, check compatibility.
Cycle handling: mutually dependent distributions really do exist in the ecosystem data, so a cycle is no longer fatal. We note it, warn, and append whatever the topological sort can't order at the end (never silently dropping packages).
Topological sort (Kahn's algorithm): dependencies are installed before their dependents.
Constraint semantics:
>=/>/<=/<are ranges;=and==are exact matches; comma-joined constraints are AND;*or empty means any.How to write them in a request string (same for
install/search/installed/store):Foo:ver<>=1.0>(โฅ),Foo:ver<>1.0>(>),Foo:ver<<=1.0>(โค),Foo:ver<<1.0>(<),Foo:ver<>=1.0, <2.0>(range).>=also has the equivalent suffix formFoo:ver<1.0+>(at-least). Note the trailing>is the phaser's closing bracket, not part of the constraint itself.
4. Installing a scripting language = handing sources to Raku's repo machinery
For Raku/Python/Perl, "installing" is fundamentally about putting sources on the
module search path. But the version-aware step can't be skipped: raku-pm takes a
version out of store/, hands it to CompUnit::Repository::Installation, which
writes it into site/; installed.json records which versions were installed
(see "Multiple versions and use Foo:ver<1.2.3>").
5. Content digests (borrowing nix's content addressing)
Every package admitted to the store gets an MD5 digest over META.json +
META6.json + every file under lib/:
content changes โ digest changes โ tampering is detectable;
verifychecks in bulk, andinstallverifies first, refusing to proceed otherwise.
Same lineage as nix's /nix/store/<hash>-name, simplified to verification
rather than addressing.