Peios Learn
Products
PePeios pkpekit PvProvium UDUniversal Directory TrTrail PrProject WiWispist
Using Peios Security Basics Technical Documentation Source
Using Peios Security Basics Technical Documentation Source
Peios

Package management

Single-page view · as markdown

Package management

Peios / Using Peios / Package management

peipkg is the command that manages software on a running Peios system. It installs packages and the dependencies they need, upgrades them, removes them, and answers questions about what is installed and where it came from.

$ peipkg install nginx
$ peipkg upgrade
$ peipkg list

This page explains what a package is, where packages come from, and the design principle that peipkg holds no authority of its own. It then names every command and points you at the rest of the topic.

What a package is #

Peios distributes software as .peipkg files: signed, self-contained archives. Each one carries a manifest — a name, a version, a target architecture, the other packages it depends on, the packages it conflicts with — and a payload, the files that land on disk when it is installed.

A package is a low-level primitive. It is the unit peipkg installs and tracks. The curated, user-facing concepts that most operators think in — bundles of software, "the web-server role", applications — are built above packages and are out of peipkg's scope: peipkg installs nginx the package; it does not know what a "web server role" is. This topic is about the primitive.

What peipkg does coordinate at the package level is claims: a claim is a single shared name that exactly one installed package may hold, so that two packages offering the same role — for example, registryd and loregd both providing the registry — do not silently collide.

Every installed package is tracked in a private database. peipkg records, for every package, which files it owns — the record that makes a clean removal, a correct upgrade, and the verify check possible.

Where packages come from #

Packages come from repositories: HTTP or HTTPS locations that serve a set of .peipkg files alongside signed indexes describing them. A Peios system is configured with one or more repositories, each one anchored to a signing key the operator has chosen to trust.

peipkg keeps a local, verified copy of each repository's metadata. peipkg refresh updates that copy; peipkg install and peipkg upgrade plan against it. The trust model — how a repository is anchored, how its signing keys rotate, and how unsigned repositories are handled — is covered in Repositories and trust.

A package can also be installed straight from a .peipkg file on disk, with no repository involved. That path trades the repository's trust guarantees for convenience; Installing and removing packages covers what it keeps and what it gives up.

flowchart LR
    A["Repository<br/>signed packages + indexes"] -->|"refresh"| B["Cached metadata"]
    B -->|"install / upgrade"| C["peipkg<br/>resolve → verify → commit"]
    C -->|"file changes"| D["Your system"]
    C -->|"operation events"| E["Audit stream"]

peipkg has no authority of its own #

The key design point is that peipkg works differently from package managers on most systems.

peipkg is not a privileged daemon. It is not setuid. It has no service account, no special identity, no broker it asks to do privileged work. It is an ordinary program, and it runs as you — under your token, with your rights.

The question "may I install this?" is therefore not a question peipkg answers. It is the same question Peios asks of any attempt to write a file: it compares your token against the security descriptor on the directory being written. If the security descriptors on /usr, /opt, and the rest say your token may write there, the install succeeds. If they do not, it fails — at the file operation, the same way any unauthorised write fails.

"Who may install software" is therefore not a peipkg setting. It is just the access rules on the system directories — Administrators, by default. To grant someone install rights, you grant them write access to the directories packages land in; to scope what they may touch, you scope those security descriptors. This is the ordinary access-decision machinery, with nothing package-specific layered on top.

One consequence follows directly: a package cannot grant its installer rights the installer did not already have. Any security descriptors a package asks to set on its own files can only be descriptors the caller already had the authority to set. There is no confused-deputy problem, because peipkg holds no authority that a package could misuse.

Every change is a transaction #

An install, an upgrade, a downgrade, a removal — each is a transaction, and each is atomic. There is a single instant, the commit, before which any failure, interruption, or power loss leaves the system exactly as it was, and after which the operation is complete. There is no partially installed intermediate state for a transaction to get stuck in.

Transactions are also reversible. peipkg keeps a history of them, retains the data needed to walk one back, and offers undo and recover to do it. Transactions and recovery covers the model in full.

Every operation is audited #

peipkg records each operation it performs — what was installed, upgraded, or removed, and the outcome — to the Peios audit stream. When a plan contains an action that needs deliberate authorisation, the authorising act itself is recorded too.

These events are a semantic summary: a readable account of what peipkg set out to do. They are not the security boundary. The authoritative record is the kernel's own audit of the actual file operations — and because peipkg runs with no special authority, it cannot suppress that record.

The command surface #

Every command is invoked as peipkg <command> [arguments].

CommandDoes
installInstall packages, with dependencies, from a repository or a local .peipkg file.
remove (alias uninstall)Remove installed packages.
upgradeMove installed packages to their newest available version.
downgradeMove one package to a specific older version.
undoReverse the most recent transaction.
claimShow or change which package holds a claim — a shared name that exactly one package may own.
refreshUpdate the cached metadata of the configured repositories.
repoConfigure repositories — add, list, remove.
rootManage named roots — add, list, remove, show.
listList the installed packages.
infoShow one installed package's details.
filesList the files a package owns.
ownsReport which package owns a given path.
searchSearch the configured repositories for a package.
verifyCheck installed files against what was recorded at install.
historyShow the transaction log.
recoverRoll back a transaction left pending by an interruption.
cleanDelete cached metadata for repositories no longer configured.

One global option sits before the command: --root TARGET makes peipkg operate on a Peios installation other than the running system at /. TARGET can be a literal path — a Peios installation mounted at DIR, the form used by image builders and offline maintenance — or the name of a named root. Named roots are more than a convenience: they are how peipkg keeps components such as the initramfs current. See Named roots for the detail.

The producer side #

peipkg is the consumer half of the Peios packaging story — the tool that runs on a deployed system and consumes packages. It has a counterpart it never shares a process with: the producer tools (peipkg-build, peipkg-repo, peipkg-manager) that turn source into signed .peipkg files and assemble the repositories peipkg fetches from. Building packages and running a repository are a separate job with their own documentation, the Peios Packages guide. This topic is for the operator of a Peios system, not the operator of a build farm.

peipkg has one consumer-side companion of its own: peipkg-compose, a separate binary — not a peipkg subcommand. Where peipkg mutates a running system in place, peipkg-compose builds a fresh, package-owned root directory offline from a declarative manifest, the form image builders use when they assemble a system from nothing. It consumes the same packages peipkg does. See Composing a root.

Where to start #

For the everyday work — putting software on a system and taking it off — read Installing and removing packages.

For keeping a system up to date, and for walking a change back, read Keeping a system current.

To configure where packages come from and how their authenticity is established, read Repositories and trust.

To understand why an interrupted install is always safe, read Transactions and recovery.

To understand how peipkg decides which versions of which packages a request implies — and why some actions require a second, separate authorisation — read Dependency resolution.

To understand how two packages that offer the same shared name settle on a single winner, read Claims.

To inspect what is installed and check that it is intact, read Inspecting and verifying.

For the more specialised work of operating on a Peios installation other than the running one — and how peipkg keeps things like the initramfs current — read Named roots.

For the specialised work of assembling a fresh root offline from a manifest — the image-builder's path, run with the separate peipkg-compose binary — read Composing a root.

Installing and removing packages

Peios / Using Peios / Package management

peipkg install puts packages on the system; peipkg remove takes them off. They are the two commands you reach for most, and they share one flow — peipkg works out the full set of changes, shows it to you, and waits for your approval before touching anything.

$ peipkg install nginx
$ peipkg remove oldtool

Installing packages #

peipkg install <package|file.peipkg>...

Each argument is either the name of a package to fetch from a configured repository, or the path of a local .peipkg file (recognised by its .peipkg suffix). You can mix the two in one command.

Installing a package rarely means installing just that package. peipkg works out everything the request implies — the dependencies the package needs, and the dependencies of those in turn — and presents the whole set. How that set is computed is the subject of Dependency resolution; this page is about the flow around it.

The plan-and-confirm flow #

install, remove, and the commands on Keeping a system current all work the same way. peipkg first produces a plan — the ordered list of changes that satisfy your request — and prints it:

$ peipkg install nginx
the following changes will be made:
  install    pcre2 10.44
  install    zlib 1.3.1
  install    nginx 1.27.4
proceed? [y/N]

Nothing has been downloaded and nothing on the system has changed. peipkg waits for an answer. Anything other than y or yes — including pressing Enter, or end-of-input — is a refusal, and the command exits having done nothing.

Answer y and peipkg carries the plan out as a single transaction: it downloads and verifies every package, then commits the change atomically.

OptionEffect
--dry-runProduce and print the plan, then stop — never prompt, never change anything.
--yes, -ySkip the proceed? prompt and apply the plan.
--no-claimInstall a provider without taking any claim it offers.
--allow-staleProceed although a repository's trust state exceeds its maximum trusted age. See Repositories and trust.
--claim <names>Comma-separated claims to force-claim, overriding the current holder(s).
--claim-allForce-claim every claim the installed packages provide, overriding incumbents.
--dangerously-bypass-path-restrictionsPermit packages that declare special_system_package to install outside the payload layout rules. Exempts nothing that has not declared itself special, and never reaches /lcl/policy. Needed only for the handful of packages whose job is to lay down the filesystem structure those rules protect.

--dry-run is the safe way to see what a command would do. --yes is for scripts and unattended runs — but note that it skips only the routine prompt. A plan that contains an action needing deliberate authorisation will still stop and ask; --yes does not override that. See Elevated authorisation for which actions those are and why.

--claim-all cannot be combined with --claim or --no-claim. Claims — shared names exactly one package may hold — are covered in Claims.

An install can also target a root other than the current one — either explicitly with --root, or because a package declares its own default root. See Named roots for how roots are named and nested.

Installing from a local file #

When an argument is a path ending in .peipkg, peipkg installs that file directly:

$ peipkg install ./nginx-1.27.4.peipkg

This is a raw install, and it differs from a repository install in one specific way: it skips the repository trust layer. There is no signed index to check the file against, no signing key to verify it under, and none of the freshness or rollback protection a repository provides. You are vouching for the file yourself.

Everything else still happens. The package format is fully verified: the archive structure, the manifest, the integrity manifest, and the hash of every payload file are all checked before anything is staged. A corrupt or truncated .peipkg is rejected in the same way as one from a repository. The file's dependencies still resolve normally against your configured repositories — a locally-installed package can pull in repository packages to satisfy what it needs.

A package supplied as an explicit local file takes precedence over any repository's version of the same package, so install ./foo.peipkg installs that file even if a repository offers foo too. In the plan, a local-file operation is marked so the choice is visible:

  install    nginx 1.27.4  (local file)

In the future, peipkg will be able to consult system policy to decide whether raw installs are permitted at all, and that gate will be configurable. For now a raw install is always allowed; the verification above is what stands behind it.

Removing packages #

peipkg remove <package>...
peipkg uninstall <package>...

remove and uninstall are the same command. Each argument names an installed package; peipkg plans the removal — the files to take off disk — and runs the plan-and-confirm flow described above.

A removal leaves shared directories in place and removes only the files the package owns. peipkg knows which files those are from its database, so a removal is clean and complete.

Removing something that is depended on #

peipkg will not, by default, leave the system inconsistent. If you ask to remove a package that another installed package depends on, the plan is refused: peipkg tells you what still needs it, and stops.

OptionEffect
--cascadeAlso remove every installed package that depends on the ones named.
--dry-runPrint the plan and stop.
--yes, -ySkip the proceed? prompt.

--cascade turns that refusal into a wider plan: peipkg computes the full set of packages that would be left with a broken dependency and adds them to the removal. The plan then shows everything that will be removed. Review it before approving, because a cascade can reach further than expected.

$ peipkg remove --cascade libfoo
the following changes will be made:
  remove     toolA 2.1
  remove     toolB 1.0
  remove     libfoo 3.3
proceed? [y/N]

Exit status #

CodeMeaning
0The operation succeeded — or, for --dry-run, the plan was produced. A declined prompt is also 0: nothing failed.
1The operation failed — a package was not found, a dependency could not be satisfied, a download or verification failed, or a file operation was denied.
2A usage error — an unknown command or a malformed option.

Keeping a system current

Peios / Using Peios / Package management

Keeping a Peios system current is two commands in sequence — refresh to learn what is available, then upgrade to move to it. The other two commands here, downgrade and undo, go the other way: they walk a change back when an upgrade turns out badly.

Refreshing repository metadata #

peipkg refresh [repository]...

peipkg plans against a local, verified copy of each repository's metadata. That copy does not update itself. peipkg refresh is what updates it: for every configured repository — or just the ones you name — peipkg fetches the current signed descriptor and index, verifies them, and replaces the cached copy.

$ peipkg refresh
refreshed "official"
refreshed "internal"

Refresh has two properties worth knowing:

  • Repositories are independent. If one repository is unreachable or fails verification, peipkg reports it and carries on with the rest. One bad repository never blocks a refresh of the others.
  • A failed refresh changes nothing. If a repository cannot be refreshed, peipkg keeps the metadata it already had. It never falls back to unverified or stale-but-unchecked content. The worst a failed refresh does is leave you on yesterday's view.

What "verified" means here — signing keys, freshness floors, the handling of an unsigned repository — is the subject of Repositories and trust.

Run refresh before an upgrade. An upgrade plans against whatever metadata is cached, so an upgrade without a recent refresh moves you to the newest version peipkg last heard about.

Skip it for long enough and peipkg stops waiting for you: an install, upgrade, or downgrade against a repository whose trust state has passed its maximum trusted age (30 days by default) refreshes that repository itself, and refuses to proceed against one that stays stale — unreachable, or frozen on an unchanging index — unless you pass --allow-stale. The bound and its configuration are covered in Repositories and trust.

Upgrading #

peipkg upgrade [package]...

With no arguments, upgrade considers every installed package and moves each one that has a newer available version forward to it. With one or more package names, it upgrades only those — and still pulls in any new dependencies they need.

$ peipkg refresh && peipkg upgrade
the following changes will be made:
  upgrade    zlib 1.3.1 -> 1.3.2
  upgrade    nginx 1.27.4 -> 1.27.5
proceed? [y/N]

upgrade uses the same plan-and-confirm flow as install — peipkg shows the full set of changes and waits for approval — and the same options:

OptionEffect
--dry-runPrint the plan and stop. A good way to preview what an upgrade would move.
--yes, -ySkip the proceed? prompt.
--no-recurseConfine the upgrade to the current root only — disable the cascade into nested named roots.
--allow-staleProceed although a repository's trust state exceeds its maximum trusted age. Warned and audited.

By default, when named roots are configured, upgrade cascades: it reconciles the current root and every named root nested under it, each as an independent continue-on-error transaction with its own summary. --no-recurse disables that and upgrades the current root alone. See Named roots for the named-roots model.

If there is nothing to do — every package already at its newest version — peipkg says so and exits.

Downgrading #

peipkg downgrade <package> <version>

downgrade moves one package to a specific older version. You name the package and the exact version you want:

$ peipkg downgrade nginx 1.27.4

Older versions are not in a repository's active index — that index lists current versions only. They live in its archive index, which peipkg fetches on demand when you ask for a version that is not current. The package must still exist in some configured repository's archive for the downgrade to be possible.

A downgrade is treated as a deliberate move. Going backward — onto a version that may have known issues a newer one fixed — is one of the actions peipkg flags for explicit authorisation: beyond the routine proceed? prompt, peipkg asks you to authorise the specific downgrade, and --yes does not stand in for that answer. See Elevated authorisation for the full set of actions that work this way.

downgrade accepts --dry-run, --yes, -y, and --allow-stale with the same meaning as elsewhere.

Undoing the last change #

peipkg undo

undo reverses the most recent committed transaction. If that transaction installed a package, undo removes it; if it upgraded, downgraded, or removed packages, undo restores each one to the version it had before.

$ peipkg undo
undoing transaction 47 (upgrade nginx, zlib)
the following changes will be made:
  downgrade  nginx 1.27.5 -> 1.27.4
  downgrade  zlib 1.3.2 -> 1.3.1
proceed? [y/N]

Be precise about what undo is. It is not a rollback of committed state — the previous transaction happened and stays in the history. undo computes the inverse of that transaction and applies it as a new transaction of its own. The history grows; it does not rewind. That new transaction can itself be undone, and so on.

Because restoring an older version is a backward move, undo carries the same explicit-authorisation requirement as downgrade for any package it walks back. It accepts --dry-run and --yes, -y.

To undo something other than the most recent transaction, or to see the history undo works against, use peipkg history — and revert a specific package directly with downgrade.

The routine cycle #

For day-to-day maintenance the loop is:

$ peipkg refresh        # learn what is available
$ peipkg upgrade --dry-run   # preview the move
$ peipkg upgrade        # apply it

downgrade and undo are the recovery commands — use them when an upgrade has brought in a change you want to remove. Every one of these commands is a transaction, so every one of them is atomic and itself reversible.

Exit status #

CodeMeaning
0The operation succeeded — including a dry run, a plan with nothing to do, and a declined prompt or authorisation (nothing failed).
1The operation failed — a repository could not be refreshed, a repository's trust state exceeded its maximum age and a forced refresh could not clear it, resolution or a download or verification failed, a root in an upgrade cascade failed, there is no committed transaction to undo, or a command's arguments were wrong (downgrade without a package and version, an unparsable version, a malformed option).
2A usage error before any command ran — no command, an unknown command, or a malformed global option (including a --root reference that does not resolve).

Repositories and trust

Peios / Using Peios / Package management

A repository is where packages come from: an HTTP or HTTPS location that serves .peipkg files alongside signed indexes describing them. A Peios system has zero or more repositories configured, and the peipkg repo commands manage that configuration.

Beyond configuration, this page covers trust: establishing that the packages a repository serves are genuinely the ones its operator published, and not something a network attacker or a compromised mirror substituted.

What a repository serves #

At its base URL a repository serves three signed documents:

  • A descriptor — the repository's identity: the set of signing keys it uses, each with a status. This is the root of trust for everything else.
  • An active index — the catalog of current package versions: names, versions, dependencies, hashes, and download locations.
  • An archive index — the catalog of older versions, kept so a downgrade can reach them. peipkg fetches it only on demand.

The descriptor and the indexes each carry a detached signature. peipkg verifies all of them; an unverifiable document is discarded, never used.

Adding a repository #

peipkg repo add <name> <base-url> --anchor <fingerprint>

repo add does two things: it records the repository in your configuration, and it runs the trust ceremony that anchors it.

$ peipkg repo add official https://pkgs.peios.org \
    --anchor ef86709c4b1d8a02e5f3c719d640aa8b7c2e9105f8d3b6470a1c2e9d8b5f3a04
added repository "official"

The --anchor value is a signing-key fingerprint, and it is the part of the command that establishes trust. peipkg cannot tell you whether a repository is genuine; only you can, by obtaining its fingerprint through a channel you trust (the project's website over HTTPS, a colleague, a printed reference) and supplying it here. The anchor is your out-of-band statement that the key is trusted.

Given an anchor, the ceremony runs:

  1. peipkg fetches the repository's descriptor and its signing keys.
  2. It checks the fetched keys against the anchors you supplied — a fingerprint must match bit for bit.
  3. Only if the descriptor's signature verifies against an anchored key does peipkg accept it and record the trust state.

If the fetched descriptor does not verify against any anchor, repo add fails and leaves nothing behind — no half-added repository, no configuration file. You can supply --anchor more than once to trust several keys at first contact.

OptionDefaultEffect
--anchor FINGERPRINT—A signing-key fingerprint to trust. Repeatable.
--priority N50Resolution priority — a lower number wins. See below.
--policy required|optionalrequiredWhether a valid signature is required. See Signature policy.
--min-index-version N0A freshness floor for the index. See Freshness.
--max-trusted-age-days N30How stale the repository's trust state may grow before operations force a refresh. See Freshness.
--max-index-staleness-days N90How old the index's own generated_at may be before operations force a refresh. See Freshness.
--insecureoffPermit a plain http:// base URL.

--insecure exists for a repository on a trusted local network with no TLS. It lowers transport confidentiality and integrity; the package signatures still protect authenticity, but prefer https:// whenever it is available.

Adding a repository your system already carries #

An image can ship a repository's configuration — its base URL, its policy, and its trust anchors — as a .repo file in /conf/peipkg/ (stored at /lcl/conf/peipkg/, which is what that view exposes). A Peios installation medium does exactly that for the offline repository it carries.

Configuration alone is not trust. peipkg does not trust a configured repository on sight: until the ceremony has run there is no recorded trust state, and every operation skips the repository with a warning rather than quietly using it. The decision to trust a key is yours, never a default.

What a baked-in .repo file changes is only where the anchor comes from. It arrived with the image rather than being typed at the prompt, which is still out-of-band — it did not come from the repository it authenticates. So the ceremony can run against it without you retyping a 64-character fingerprint that is already on disk:

$ peipkg repo add peios-medium
added repository "peios-medium"

Given a name and nothing else, repo add reads that repository's existing configuration and runs the same ceremony as the two-argument form. It is still an explicit act — nothing here happens on its own.

Two differences from the full form are worth knowing:

  • It refuses a configuration with no trust_anchors under the required policy, because there would be nothing to verify the descriptor against.
  • A failed ceremony leaves the .repo file alone. The two-argument form wrote that file and removes it again on failure; here it came from somewhere else, and deleting another party's configuration because a ceremony failed would turn a retryable problem — a medium not mounted yet, a repository not published — into lost settings.

How trust survives key rotation #

A repository operator will, over time, rotate signing keys — retiring an old one, bringing a new one into use. If trust were pinned permanently to the fingerprint you first anchored, every rotation would break every consumer.

Rotation does not break trust, because the descriptor carries the keys. Each key in the descriptor has a status:

StatusMeaning
activeIn current use for signing.
transitioningBeing phased in or out — honoured until its stated expiry.
revokedWithdrawn — never honoured again.

Each time peipkg refreshes a repository, it verifies the new descriptor against the keys in the descriptor it already trusts, and then adopts the new descriptor's key set as the current trust state. A rotation that is itself signed by a still-trusted key propagates automatically: you anchored once, and the chain carries forward from there without you re-anchoring. A revoked key is dropped and never accepted again.

The anchor therefore matters only at first contact. After that, trust is a chain, and each refresh extends it.

Signature policy #

--policy chooses how strict peipkg is about signatures for this repository.

  • required (the default) — every descriptor and index must carry a valid signature from a trusted key. An unverifiable document is rejected. This is the correct setting for any repository reached over a network.
  • optional — combined with no trust anchors, this puts the repository into unsigned mode: peipkg fetches and uses its metadata and packages without cryptographic verification.

Unsigned mode is a deliberate escape hatch for a scratch repository on a build host, or a local mirror you fully control. Every operation that touches an unsigned repository prints a warning:

peipkg: warning: repository "scratch" is unsigned — its metadata and
packages are not cryptographically verified

In unsigned mode, only the transport stands between you and a substituted package. Do not use it for anything reachable from an untrusted network.

Priority #

--priority is a number — lower is stronger, default 50 — that decides which repository wins when more than one offers the same package. If both an official repository at priority 10 and an internal one at priority 20 carry nginx, the resolver takes the official build. Priority does not restrict anything; it only breaks ties. Its full role in version selection is covered in Dependency resolution.

Priority also feeds one of the safety checks there: a package from a lower-priority repository that tries to displace a package installed from a higher-priority one is flagged for explicit authorisation, so a low-trust repository cannot quietly take over a package you got from a trusted one.

Freshness — defeating a stale-repository attack #

A signature proves a document is authentic; it does not prove the document is current. An attacker who cannot forge a signature can still serve you a genuine, correctly-signed, but old index — one from before a security fix was published — and a naive consumer would accept it.

peipkg closes that gap by tracking, per repository, the highest index version it has ever seen, and refusing any index that goes backward. Once you have seen version 5 of an index, version 4 — however well signed — is rejected. A repository can only ever move forward.

--min-index-version N lets you set that floor explicitly, out of band: if you know the current index is at least version N, anchoring that number means peipkg will reject anything older even on the very first fetch, before it has a history to compare against.

Going backward is one half of the attack; standing still is the other. A frozen repository — one that serves the same, correctly-signed index forever — never trips the rollback check, yet it can hold you on a view from before a security fix indefinitely. Against that, peipkg tracks when each repository last refreshed with progress and enforces a maximum trusted age: 30 days by default, tunable per repository with max_trusted_age_days in its .repo file or --max-trusted-age-days at add time.

When an install, upgrade, or downgrade finds a repository's trust state older than its maximum, peipkg refreshes that repository first. If it cannot — the repository is unreachable, or it refreshes without progressing — the operation is refused rather than planned against outdated metadata. Passing --allow-stale to the operation overrides the refusal; the override is warned about and recorded in the audit stream. A maximum above 180 days draws a warning on every operation, because a bound that loose effectively disables the check.

There is a third way to stand still, and it needs its own check. Maximum trusted age asks how long since I last refreshed successfully — and a repository can keep that answer flattering forever by bumping its index version on every publication while stamping the index with an ancient generated_at. Every refresh looks like progress; the metadata never actually moves. So peipkg separately enforces a maximum index staleness measured from the index's own generated_at: 90 days by default, tunable per repository with max_index_staleness_days in its .repo file or --max-index-staleness-days at add time.

The two are deliberately independent, and raising one does not widen the other — a max_trusted_age_days of 180 still leaves the 90-day staleness window in place. An index past that window triggers a refresh before any install proceeds, and the same --allow-stale override applies, with the same warning and audit record. A staleness bound above 365 days draws a warning on every operation.

Listing and removing repositories #

peipkg repo list
peipkg repo remove <name>

repo list prints the configured repositories — name, base URL, priority, signature policy. It accepts --json.

$ peipkg repo list
official  https://pkgs.peios.org  priority=10  required
internal  https://pkg.corp.example  priority=20  required

repo remove deletes a repository's configuration and the trust state peipkg recorded for it. Packages already installed from that repository stay installed — removing a repository controls where future packages come from; it does not remove past ones. Those packages simply no longer have a source for upgrades until you add a repository that carries them again.

Where the configuration lives #

Each repository is one file: /lcl/conf/peipkg/<name>.repo, in flat TOML.

base_url         = "https://pkgs.peios.org"
priority         = 10
signature_policy = "required"
trust_anchors    = ["ef86709c4b1d8a02e5f3c719d640aa8b7c2e9105f8d3b6470a1c2e9d8b5f3a04"]

The files are hand-editable, and editing one is a legitimate way to configure a repository — a trust anchor written into the file is you supplying that anchor out of band. peipkg repo add is the convenient front end: it runs the ceremony and writes the file for you. Who may edit these files is, like everything else on Peios, the security descriptor on /lcl/conf/peipkg/.

Exit status #

CodeMeaning
0The operation succeeded — the repository was added, listed, or removed.
1The operation failed — the trust ceremony failed (repo add backs out and leaves nothing behind), or the subcommand was missing, unknown, or given the wrong arguments (a missing repo add <name> <base-url>, a malformed option). Removing a name that is not configured is not a failure.
2A usage error before any command ran — no command, an unknown command, or a malformed global option.

Transactions and recovery

Peios / Using Peios / Package management

Every change peipkg makes — an install, an upgrade, a downgrade, a removal — is a transaction. A transaction is atomic: it either happens completely or not at all. There is no state in which a package is half-installed, and no failure, signal, or sudden power loss that can leave one there.

This page explains how that guarantee is built, what an interrupted run leaves on disk, and the two commands — recover and history — that exist because of it.

The three phases #

Every operation moves through the same three phases.

flowchart LR
    P["Plan<br/>read-only, no lock"] -->|"you approve"| S["Stage<br/>lock held; fetch + verify"]
    S --> C["Commit<br/>the atomic flip"]
    C --> D["Done"]
    P -.->|"abort"| X["Nothing changed"]
    S -.->|"abort / crash"| X

Plan. peipkg reads your request, reads the installed set, reads the cached repository metadata, and computes the ordered list of changes — or rejects the request. This phase is entirely read-only and takes no lock. It is why --dry-run and the query commands never block, even while another transaction is running, and why a plan can always be abandoned for free.

Stage. Once you approve the plan, peipkg takes a single-writer lock — only one transaction touches the system at a time — and prepares everything. It downloads every .peipkg in the plan and verifies all of them before staging any one of them, so no package's contents can influence another's verification. Verified payloads are written into a staging area, each file hash-checked as it lands. Nothing the system uses has changed yet.

Commit. peipkg moves the staged files into place and records the new package state. This is the phase that changes the system.

The one instant that matters #

Within the commit there is a single instant — the moment peipkg records the new state in its database — that divides the entire operation in two:

  • Before it: any failure, any signal, any power loss rolls everything back. The staged files are discarded, any displaced files are put back, and the system is exactly as it was.
  • After it: the operation is complete and durable.

There is no third outcome. A transaction is never "partly applied". The rest of this page describes the machinery that delivers this guarantee.

Backups make rollback free #

When peipkg replaces or removes a file, it does not overwrite or delete it. It renames the old file aside — to a sibling name in the same directory — and puts the new file in place. The old contents are untouched, just under a different name.

Rolling back is then simply renaming everything back. No data is copied, no contents are reconstructed; the rollback is the same cheap rename operation in reverse. This is why a failed transaction recovers to a byte-exact prior state.

Those set-aside files also outlive a successful commit for a while — retained so that an undo of a recent transaction is fast and needs no network. They are cleaned up automatically as they age out.

What an interruption leaves behind #

If a transaction is interrupted mid-stage or mid-commit, you may find files with these names near where a package was being installed:

NameIs
<name>.peipkg-staged-<id>An incoming file that had been staged but not yet moved into place.
<name>.peipkg-backup-<id>A file that had been renamed aside to make room — the backup.

These names are deliberate. They have no leading dot, so they are visible rather than hidden — you are meant to find them and understand them. The <id> is the transaction number; look it up with peipkg history to see which operation left it.

You do not clean these up by hand. peipkg knows about them and resolves them itself — see recover, next.

Recovering an interrupted transaction #

peipkg recover

When a transaction is interrupted before it commits, peipkg records it as pending. The next time peipkg starts any transaction it checks for a pending one first and rolls it back automatically before doing anything else — so in normal use recovery just happens, and you never see it.

peipkg recover runs that step on demand. Use it when a transaction was interrupted and you want the system put right immediately, without waiting for the next install or upgrade.

$ peipkg recover
recovered: the interrupted transaction was rolled back

Recovery only ever rolls back. A transaction that was interrupted after its commit instant is already complete — there is nothing pending and nothing to recover. Recovery deals exclusively with the "before the instant" case, and it always resolves it the same way: back to the prior state.

If there is no pending transaction, recover says so and exits cleanly.

Across more than one root #

When an operation spans more than one named root, those roots commit as a unit under a single cross-root transaction, and undo reverses all participating roots together. recover reconciles pending cross-root transactions across every reachable root: a torn commit is rolled back, or — if it had already passed the commit instant — rolled forward to completion. See Named roots for how multiple roots come about.

The transaction log #

peipkg history

history prints the transactions peipkg has carried out, most recent first — each with an id, a timestamp, its state (committed, rolled-back, or pending), and a short summary.

$ peipkg history
49  2026-05-19T14:02:10Z  committed    upgrade nginx, zlib
48  2026-05-19T09:31:55Z  committed    install nginx
47  2026-05-18T22:14:03Z  rolled-back  install brokenpkg
OptionEffect
-n NShow at most N transactions. -n 0 shows all of them. The default is 20.
--jsonEmit JSON.

The history is what undo reads to find the most recent transaction, and what ties a stray *.peipkg-backup-<id> file back to the operation that created it.

Configuration files on upgrade #

Upgrading a package raises a question for any configuration file under /etc/ that the package owns: the new version ships a new default, but you may have edited the old one. peipkg decides per file, by comparing the file on disk against the hash it recorded at install:

  • Unchanged since install — peipkg replaces it with the new default. You wanted the package's settings, and you get the current ones.

  • Edited since install — peipkg keeps your file untouched and writes the new default beside it as <name>.peipkg-new. The upgrade report notes it:

    peipkg: warning: /etc/nginx/nginx.conf has been modified since install —
      keeping it; the new default was written to /etc/nginx/nginx.conf.peipkg-new
    

Your edits are never silently discarded, and the new defaults are never silently lost — you are simply told the two diverged and left to merge them when you choose.

Side effects run after commit #

A few packages need a system-wide step after their files are in place — refreshing the shared-library cache, the kernel-module map, or the manual-page index. peipkg runs those steps after the commit instant, once per transaction.

Because they run after the operation is already complete and durable, a side-effect step that fails is reported as a warning, not a failure. The transaction stands; the step is one that corrects itself the next time it runs. An install is never rolled back over a stale cache.

Why this shape #

The pay-off of the three-phase model is concrete:

  • An install interrupted by a crash or a power cut never leaves a broken package — it leaves either the old state or the new one.
  • The plan you approve under --dry-run is the plan that executes — resolution is read-only and deterministic.
  • Queries and dry-runs never wait on an in-flight transaction, because only staging and commit take the lock.
  • Every transaction is reversible, by undo for a recent one or downgrade for a specific package.

The model rests on one principle: all the fallible work — downloading, verifying, staging — happens before the commit instant, so the commit itself is the only point at which the system changes state.

Where to go next #

For the commands that reverse a committed transaction, read Keeping a system current.

For how a transaction that spans several named roots commits and recovers as a unit, read Named roots.

For the events every transaction emits, read Auditing.

Dependency resolution

Peios / Using Peios / Package management

When you ask peipkg to install nginx, the request does not yet describe the full work. nginx needs other packages; those need others; some version of each has to be chosen; everything has to be installed in an order where a package's dependencies come before it. Turning the short request into that concrete, ordered plan is resolution, and it is the first thing every change command does.

Resolution is a pure calculation #

peipkg resolves from metadata alone — the package descriptions in the cached repository indexes and in installed packages' manifests. It does not download any .peipkg files to work out a plan. Downloading happens later, only once a plan is approved.

That makes resolution a pure calculation over three inputs — your request, the set of installed packages, and the set of available packages — and gives it a property worth relying on: it is deterministic. The same inputs always produce the same plan. The plan you inspect with --dry-run is the plan that will execute; nothing is re-decided between previewing and applying.

If a request cannot be satisfied — a dependency that no repository can provide, two requirements that contradict — resolution rejects it, with an explanation, before anything is fetched or changed.

The relationships between packages #

A package's manifest declares how it relates to others. Four relationships drive resolution.

Dependencies. A package can require other packages, each by a version range. peipkg pulls every dependency into the plan, and their dependencies in turn, until the request is closed.

Conflicts. A package can declare that it cannot coexist with another. If a plan would put two conflicting packages on the system at once, resolution rejects it.

Provides. Several packages can advertise the same capability — a virtual name that is not itself a package. A dependency written against that name is satisfied by any package that provides it. This is how "needs a mail transport agent" can be met by whichever one you actually install.

You can ask for a virtual name directly, too: peipkg install coreutils works even when nothing is named coreutils, and installs whichever package provides it. Because you asked for one name and got a package with another, peipkg says which one it chose. Upgrades and removals are the exception — those name a package you already have, so they match by name only and never substitute one package for another.

Replaces. A package can declare that it supersedes another — the usual case being a rename, or a merge of two packages into one. Installing a package that replaces another causes the replaced package to be removed as part of the same plan.

provides has a stronger cousin: a claim, a single shared name that exactly one package may hold at a time. Where any number of packages can advertise the same provides name at once, a claim has one holder — see Claims.

A dependency can also be routed into a different root, written Depends: foo IN <root>, so that a whole root can be composed through the dependency graph — see Named roots.

Choosing a version #

When more than one version of a package could satisfy the plan, peipkg chooses by a fixed rule:

  1. Prefer the highest version that satisfies every constraint on that package.
  2. When two repositories offer the same version, prefer the one with the stronger (lower-numbered) priority — see Repositories and trust.

When the candidates are competing to fill a virtual name rather than being versions of one package, "highest version" means the version each one advertises for that name, not its own. Two packages providing coreutils are unrelated software numbered on unrelated scales, so peipkg compares what each offers the role — otherwise the role would go to whichever project numbers its releases higher. If everything above still ties, package name order settles it, so the answer never depends on the order packages happened to be read in.

For an upgrade, the installed version anchors the search: peipkg looks for something newer and stays put if there is nothing newer to move to.

Optional dependencies are never chosen automatically. A package can suggest companions that are useful but not required; peipkg surfaces those as suggestions and leaves the decision to you. A plan only ever contains what is genuinely needed plus what you asked for.

The plan #

The output of resolution is the ordered list of operations you see at the proceed? prompt — installs, upgrades, downgrades, and removals, sequenced so every package's dependencies are in place before it.

A removal gets one extra step. peipkg first computes the reverse-dependency closure — everything that would be left needing a package you are removing — and either refuses the plan or, with --cascade, widens it to include them. That logic is covered in Installing and removing packages.

Resolution is also bounded. A dependency graph cannot be crafted to make peipkg search forever; the resolver works under a hard cap and gives up cleanly rather than hanging if a graph is pathological.

Elevated authorisation #

Most of a plan is routine, and the single proceed? prompt covers it. A plan can also contain an action that a routine yes should not cover — one you must review and approve individually, on its own.

peipkg detects three such actions and, for each one in a plan, asks a separate question:

this operation requires elevated authorisation:
  nginx would move backward from 1.27.5 to 1.27.4
authorise this specific action? [y/N]

The three actions that trigger it:

ActionWhy it is elevated
A downgradeMoving a package backward can reintroduce a problem a newer version fixed. It should be a conscious choice, never a side effect of some larger plan.
A foreign replacesA package from a lower-priority repository using replaces to displace a package you installed from a higher-priority one. Left unguarded, a low-trust repository could quietly take over a package you trusted a better source for.
A low-trust providesA package from a low-trust repository advertising a virtual capability in a way that shadows a package from a more-trusted source — satisfying a dependency with its own build instead of the one you would expect.

Two things make this prompt different from the routine one:

  • --yes does not satisfy it. --yes skips the routine proceed? prompt; it has no effect on an elevated-authorisation question.
  • It fails closed. With no terminal to answer on — an unattended script, a pipeline — there is no answer, so the action is not authorised and the operation is cancelled. An elevated action is never authorised by default.

Each elevated action is presented and authorised on its own; approving one does not approve the next. And the authorising act is itself written to the audit stream — the record shows not just what was done, but that it was specifically authorised and what was authorised.

If an elevated action is not one you want, the plan as a whole is the thing to reconsider: where the package is coming from, whether the repository priorities are right, whether the downgrade is really what you meant.

Where to go next #

For the flow that presents and applies the plans resolution produces, read Installing and removing packages.

For the single-holder extension of provides, read Claims.

For the repository priorities and trust states resolution consults, read Repositories and trust.

Claims

Peios / Using Peios / Package management

Some filesystem names can be provided by more than one package. Two registry daemons — loregd and an alternative — both install a working binary, but only one of them can own /usr/bin/registryd. peipkg calls that shared name a claim, and it owns the machinery that decides which package holds it.

Three words are used precisely on this page:

  • A claim is a single shared filesystem name that many installed packages may be able to answer, but exactly one may hold at a time.
  • An eligible provider is an installed package able to answer a given claim.
  • The holder is the one package that currently answers it.

(This is unrelated to token claims — the security-token attribute concept in the identity docs. The same word names a different subsystem.)

What a claim is #

A claim materialises as a symlink on disk. The link lives at the claim path — the shared name, such as /usr/bin/registryd — and points at a file inside the holder's payload, the target, such as /usr/sbin/loregd. Ask the system for the shared name and you reach whichever provider currently holds it.

/usr/bin/registryd -> /usr/sbin/loregd

The claim symlink is owned and managed by peipkg. It is not shipped inside any package's payload; no provider installs it, and removing a provider does not remove it out from under peipkg. peipkg creates, repoints, and tears down the link as part of the transactions that install, remove, grant, and revoke.

The two sides of a claim are declared in package manifests:

  • A provider package declares the target file it offers for a claim — the real binary the shared name would resolve to.
  • A consumer package references the claim path where it expects the shared name to appear.

peipkg joins the two. A provider can offer a target for a claim, a consumer can depend on the name being present, and peipkg mediates between them, keeping exactly one provider wired to the shared path at any moment.

Claims and provides/replaces #

A claim is the "exactly one owner of a shared name" extension of the provides and replaces relationships covered in Dependency resolution. There, several packages can advertise the same virtual name and any one of them satisfies a dependency written against it — that mechanism answers "is something here that provides this?". A claim goes one step further: it answers "which single provider owns this concrete filesystem name right now?", and enforces that the answer is never more than one. Provides establishes that a name can be answered; a claim decides which package answers it on disk.

Auto-claim on install #

Installing an eligible provider auto-claims every claim it provides that is currently unheld. If no package yet holds registryd, installing loregd makes loregd the holder and materialises the link as part of the same transaction — you get a working shared name without a second step.

The rule is strictly unheld-only. Auto-claim never overrides a claim that is already held by another package. Install a second registry daemon while loregd holds registryd and the newcomer is installed as an eligible provider but takes nothing; loregd stays the holder. Reassigning a held claim is always a deliberate act — see the claim command below, or the install flags that force it.

Install flags #

peipkg install accepts flags that override the default auto-claim behaviour for the packages in that install:

OptionEffect
--no-claimClaim nothing. Install the provider(s) without taking any claim, even ones that are currently unheld.
--claim <names>Force-claim the named claims (comma-separated), overriding the current holder of each.
--claim-allForce-claim every claim the installed packages provide, overriding incumbents.

Two combinations are hard errors:

  • --claim-all together with --claim.
  • --claim-all together with --no-claim.

--claim and --claim-all are how you take a claim that is already held during an install; without them, an install only ever fills claims that are empty.

The claim command #

peipkg claim inspects a claim and reassigns its holder. It has three forms.

peipkg claim <claim>
peipkg claim <claim> grant <package>
peipkg claim <claim> revoke

Report status. With just a claim name, peipkg prints the current state of the claim: the current holder, the materialised links shown as path -> target, and the installed eligible providers.

$ peipkg claim registryd
holder: loregd
links:
  /usr/bin/registryd -> /usr/sbin/loregd
eligible providers:
  loregd
  altregd

Grant. grant <package> makes an installed eligible provider the holder. peipkg atomically repoints all of the claim's links to that package's targets — every path the claim covers moves together, or none does. The named package must be an installed eligible provider for the claim.

$ peipkg claim registryd grant altregd

Revoke. revoke removes the grant. The claim becomes unheld and its links are torn down. peipkg does not automatically promote another provider — a revoked claim has no holder until you grant one.

OptionEffect
--yes, -ySkip the confirmation prompt. Applies to grant and revoke.

grant and revoke each run as a standalone transaction. Like every other peipkg change they appear in history, can be reversed with undo, and are fully auditable — each emits a claim event to the audit stream. A reassignment is never a silent, unrecorded edit to a symlink; it is a first-class, reversible operation.

What happens on uninstall #

Uninstalling the current holder auto-withdraws the claim. The holder is going away, so peipkg tears down its links and the claim becomes unheld as part of the removal.

peipkg does not auto-promote another provider in its place — an automatic promotion would be the kind of silent reassignment claims exist to prevent. Instead it surfaces the remaining eligible providers and hands you a ready-to-run command to reassign the claim yourself:

$ peipkg remove loregd
...
claim 'registryd' is now unheld. eligible providers: altregd
to reassign it, run:
  peipkg claim registryd grant altregd

If the holder was the only eligible provider, the claim is left unheld with nothing to promote, and any consumer relying on the shared name will find it absent until a new provider is installed.

Exit status #

CodeMeaning
0The operation succeeded — the status was reported, or the grant or revoke was applied (a declined prompt is also 0: nothing failed).
1The operation failed — the claim has no eligible provider, the named package is not an eligible provider, or a named package or claim is not installed.
2A usage error — an unknown subcommand (the only subcommands accepted after the claim name are grant and revoke) or a malformed option.

Inspecting and verifying

Peios / Using Peios / Package management

The commands on this page change nothing. They report what is installed, look up a package in the repositories, check that installed files are still intact, and tidy the metadata cache. Use them to see what is on a system and to check that it is still as it should be.

Listing what is installed #

peipkg list

list prints every installed package — name, version, and architecture.

$ peipkg list
nginx  1.27.5  x86_64
pcre2  10.44   x86_64
zlib   1.3.2   x86_64

With --json it emits the same set as JSON, with each package's origin repository included.

Showing one package's details #

peipkg info <package>

info prints the full record of one installed package: its version and architecture, the repository it came from (or (local file) if it was installed from a .peipkg directly), when it was installed, and — from its manifest — its description, license, and homepage.

$ peipkg info nginx
name:         nginx
version:      1.27.5
architecture: x86_64
origin:       official
installed:    2026-05-19T14:02:10Z
description:  HTTP and reverse proxy server
license:      BSD-2-Clause
homepage:     https://nginx.org

Listing the files a package owns #

peipkg files <package>

files prints every filesystem object the package owns — the files, directories, and symlinks that were placed by its install and are tracked against it. This is the record peipkg uses to remove the package cleanly and to verify it.

$ peipkg files zlib
/usr/lib/libz.so.1
/usr/lib/libz.so.1.3.2
/usr/include/zlib.h

Finding which package owns a path #

peipkg owns <path>

owns is the reverse lookup: given a path, it reports which installed package placed it.

$ peipkg owns /usr/lib/libz.so.1
zlib

If no installed package owns the path, owns says so and exits non-zero. A path that nothing owns is either not part of any package or was created outside peipkg.

Searching the repositories #

peipkg search <term>

search looks through the configured repositories' active indexes for packages whose name or description contains the term, case-insensitively. It searches what is available, not what is installed — it is how you find a package before installing it.

$ peipkg search proxy
nginx     1.27.5  [official]  HTTP and reverse proxy server
haproxy   2.9.7   [official]  Reliable, high-performance TCP/HTTP load balancer

search reads the cached repository metadata, so run peipkg refresh first if you want it to reflect the latest catalog. A repository with no usable cached metadata is skipped with a warning rather than failing the search. --json emits the matches as JSON.

Verifying installed files #

peipkg verify [package]...

verify checks that what is on disk still matches what was recorded when each package was installed. For every file a package owns it checks:

  • a regular file — that it is present and its content still hashes to the recorded value;
  • a symlink — that it is present and still points where it was recorded to point;
  • a directory — that it is still present and still a directory.

With no arguments, verify checks every installed package. With package names, it checks only those.

$ peipkg verify nginx
nginx: /etc/nginx/nginx.conf has been modified since install
verify: 1 problem(s) found

A reported file is not necessarily a fault. A configuration file you edited on purpose will show up — verify is telling you the file diverged from the package's version. verify reports; it does not judge intent and it does not change anything.

If every checked file is intact, verify says so and exits 0. If anything diverged, it lists each problem and exits non-zero — which makes it usable as a check in a monitoring script.

Cleaning the metadata cache #

peipkg clean

peipkg keeps a verified copy of each repository's metadata in a local cache. When you remove a repository, its cached metadata is no longer needed. clean deletes only those orphaned cache files — the ones belonging to repositories that are no longer configured.

$ peipkg clean
removed 2 orphaned cache file(s)

clean never touches the metadata of a repository that is still configured, so it cannot leave you needing a refresh. It is pure housekeeping, and it is optional; run it whenever you like.

Exit status #

CodeMeaning
0The command succeeded. For verify, every checked file was intact.
1The command failed — a named package is not installed, a path is owned by nothing, or, for verify, at least one file had diverged.
2A usage error — an unknown command or a malformed option.

Named roots

Peios / Using Peios / Package management

Every peipkg command runs against a root — a subtree it treats as a complete Peios installation. By default that root is /, the running system. The --root DIR global option points peipkg at a different one: an image mounted at DIR, an offline system under maintenance, a tree being built.

A single bare path is enough when there is exactly one alternate tree and you always spell it out in full. It stops being enough when a system is several cooperating trees that are built and kept current together. Peios's dynamic initramfs is the case that motivates this page: a real system root at / and an initramfs image at boot/initramfs, both assembled from ordinary packages, both upgraded by the same package manager. Passing --root boot/initramfs on every command that touches the image — and remembering that path everywhere — is the friction named roots remove.

So --root generalises. A root can register other roots under it by name, reference them by that name instead of a path, compose those names by dotting, install a dependency into a different root than its depender, and upgrade every nested root in one command. The mechanism is deliberately general — the initramfs is one arrangement it expresses, not a special case wired into the tool.

What a named root is #

A named root is a registered name for a subtree, recorded in the registry of the root it lives under. The name initramfs bound to the path boot/initramfs is a named root of /: it says "within this root, the name initramfs means the subtree at boot/initramfs, and that subtree is itself a root."

Two ideas follow from that.

Every root has its own registry. The bindings a root knows about are its own. / may register initramfs; what initramfs registers is recorded inside initramfs, not in /. Paths in a registry are stored relative to the root that owns them, so a registry travels with its tree — an image built in one place and mounted in another resolves the same way.

References are resolved from the current root. The current root is whatever --root points at (or / when it is omitted). A reference is resolved by starting there and walking its registry.

Referencing by name #

Once initramfs is registered under /, these two commands target the same tree:

$ peipkg --root boot/initramfs install live-boot
$ peipkg --root initramfs install live-boot

The second names the root instead of spelling out its path. The name is looked up in the current root's registry and resolved to the bound path.

Dotted composition #

Names compose by dotting. initramfs.subroot resolves left to right: from the current root, walk into initramfs's registry, then from there into subroot. Each dot is one more step outward through one more registry.

$ peipkg --root initramfs.subroot install ...

Every dotted segment must match the grammar [a-z0-9][a-z0-9_-]* — a lowercase-alphanumeric lead followed by lowercase alphanumerics, hyphens, and underscores. A segment that names no registered root is a hard error, not a silent miss. A registry arrangement that loops back on itself is detected and reported as a resolution cycle rather than followed forever.

Path or name: how --root decides #

--root accepts either form and tells them apart by a single rule:

  • Any value containing / is a filesystem path and is used exactly as before — --root boot/initramfs, --root /mnt/image, --root ./tree. This is unchanged behaviour.
  • A bare dotted identifier is a named reference — --root initramfs, --root initramfs.subroot — resolved through the registries as above.

The / is the discriminator. If you mean a literal directory, include a slash (even ./name); if you mean a registered name, use the bare identifier.

The root command #

root manages named roots. Every subcommand operates on the current root's registry — the registry of whatever --root points at. To manage the initramfs's own named roots, aim --root at the initramfs first.

peipkg root add <name> <path>
peipkg root remove <name> [--purge]
peipkg root list [--json] [--tree]
peipkg root show <reference> [--json]

root with no subcommand is a usage error — a subcommand is required (add, remove, list, show). An unknown subcommand is a usage error too.

root add #

peipkg root add <name> <path>

Register a named root in the current root's registry. <name> is a single root segment — it must match [a-z0-9][a-z0-9_-]* and must not contain a dot; you register one name at a time, not a dotted chain. <path> is stored relative to the current root. There are no flags.

$ peipkg root add initramfs boot/initramfs

root remove #

peipkg root remove <name> [--purge]

Unregister a named root. By default this removes only the registry entry — the files on disk are left in place, so the subtree survives and can be re-registered. --purge additionally deletes the named root's filesystem tree.

OptionEffect
--purgeAlso delete the named root's filesystem tree, not just its registry entry.

root list #

peipkg root list [--json] [--tree]

Print the current root's registry. Plain output lists the names registered directly under the current root.

OptionEffect
--jsonEmit JSON — for each entry its name, its stored path, its resolved_path, its status, and its children.
--treeRecurse into each child's registry and print the whole nested arrangement. The recursion is cycle-guarded, so a registry that loops is reported rather than followed endlessly.

root show #

peipkg root show <reference> [--json]

Resolve one reference and report on it. <reference> is either a dotted named-root reference or a path — the same path-or-name rule as --root. show reports the reference's resolved path, its status, and the number of packages installed in it.

The status is one of two words:

  • present — the resolved path exists on disk and is a real root.
  • dangling — the reference resolves through the registries, but the path it names is not there. The binding exists; the tree it points at does not.
OptionEffect
--jsonEmit the same report as JSON.

present and dangling are the vocabulary the rest of this page uses for "the binding points at something real" versus "the binding is valid but its target is missing".

Automatic target selection with default_root #

A package's manifest can declare the root a top-level install of it should land in — its default_root. When you install such a package without saying where, peipkg reads that field and re-roots the install onto it for you.

$ peipkg install live-boot

If live-boot's manifest sets default_root: initramfs, this installs into the initramfs root even though no --root was given. The package's manifest records where it belongs, so you do not have to specify it.

Two rules keep this predictable:

  • It applies only when no explicit --root was given. Passing --root with any value, including a named one, states the target outright and suppresses default_root re-rooting entirely. An explicit root always wins.
  • The packages in one command must agree. If the packages named in a single install declare two or more distinct default roots, peipkg cannot pick one and does not guess — the command is rejected as an error. Split it into one command per target.

default_root only chooses a target; it does not create or resolve anything the registry could not already reach. The chosen root is resolved through the registries like any other named reference.

Cascading upgrade #

By default peipkg upgrade reconciles the current root and every named root nested under it, recursively — the whole tree of roots, in one command. Run it against / on a dynamic-initramfs system and both / and boot/initramfs are brought current together.

Each root is upgraded as an independent transaction that continues on error: peipkg walks the tree, upgrades each root on its own, and prints a per-root summary. One root's failure does not abort the others — a broken upgrade in initramfs leaves /'s upgrade to complete and report normally, and vice versa. You get one summary per root and a clear picture of which succeeded.

Each of those per-root upgrades is an ordinary transaction with the full atomic guarantee of Transactions and recovery; the cascade runs several of them, it does not weaken any one.

OptionEffect
--no-recurseConfine the upgrade to the current root only. The cascade into nested roots is disabled.

Use --no-recurse when you deliberately want to move just one root — for example to upgrade the initramfs on its own without touching /, by pointing --root at it and disabling the cascade.

Cross-root dependencies #

A dependency can be routed into a different root than the package that declares it. A manifest writes this with IN:

Depends: foo IN initramfs

This says "I depend on foo, and foo belongs in the initramfs root" — the root name is resolved through the registries from the depending package's root. It is what lets a whole root be composed out of ordinary packages through the dependency graph: a single package installed into / can pull the pieces of the initramfs into initramfs as its dependencies, so the image is built by the same resolver and the same packages as everything else. An image builder starting from nothing can assemble an entire multi-root arrangement like this offline, from a declarative manifest, with the separate peipkg-compose binary — see Composing a root.

install is the only verb that crosses roots. When resolution produces a plan whose changes land in more than one root, that plan is committed as a two-phase commit across the participating roots, under a single generated cross-root transaction id that ties the per-root pieces together. Either the change lands in every participating root or in none — the multi-root plan is atomic as a whole, not merely per root.

A plan that reaches beyond the current root announces it before you approve:

  • a note: this also changes other roots: ... line naming the other roots the plan touches, and
  • a per-line -> <root> tag on each plan entry that lands outside the current root, so you can see at a glance which change goes where.

You are never taken across a root boundary silently; a cross-root plan always shows its routing.

Cross-root undo and recovery #

Because a cross-root install commits as a unit, it reverses as a unit.

undo on a cross-root transaction reverses all of its participating roots together. There is no way to walk back only the / half of a change that also touched initramfs — the cross-root transaction id binds the pieces, and undo reverses the whole. See Keeping a system current for undo in general.

recover understands cross-root transactions too. It reconciles them across every reachable root, walking the registry outward from the current root to find each participant. A cross-root commit that was torn by an interruption is resolved the same way a single-root one is — with one addition that follows from the two-phase commit: a torn commit is rolled back if it was interrupted before its point of no return, or rolled forward to completion if it had already passed that point. Either way every participating root ends in the same, consistent state.

This is the single-root recovery model of Transactions and recovery extended across roots: the same commit-instant guarantee, now applied to a commit that spans several trees at once. Read that page for the underlying transaction model this builds on.

Exit status #

CodeMeaning
0Success.
1Failure — an unregistered name, a dangling or otherwise invalid reference, a resolution cycle, a per-root transaction that failed, or a cross-root commit failure.
2Usage error — a missing or unknown root subcommand, a malformed option, or the wrong number of positional arguments.

Composing a root

Peios / Using Peios / Package management

peipkg mutates a running system in place: every change is an atomic, reversible transaction against a live root. peipkg-compose is its counterpart on the assembly side. Instead of changing a system that already exists, it builds a fresh, fully package-owned root directory from nothing — offline, deterministically, and verifiably, with no live system involved anywhere in the process.

peipkg-compose is a separate binary, not a peipkg subcommand. You run peipkg-compose, and its two verbs (lock and build) are the whole of its command surface.

Given a declarative TOML manifest that names a package set and the repositories to draw it from, compose produces a populated root directory: package payloads laid out at their installed paths, a seeded peipkg state database at var/lib/peipkg/db.sqlite, and each repository written out as lcl/conf/peipkg/<name>.repo. The result is a legitimate peipkg-managed system — once booted it can manage itself, and repository trust bootstraps on its first refresh.

It is meant for image builders — an outer image-assembly tool, or a person standing one up by hand. Because it only ever writes inside the output directory and never touches the host /, it is safe to run on a non-Peios host, and even on a host of a different architecture from the image being built.

The mental model #

Compose runs in two stages, and the two verbs map onto them.

Stage 1 — resolve. Read the manifest, resolve the requested package set against the declared repositories into a complete transitive closure, verify each repository's trust chain, and pin the result. This stage needs repository metadata; its output is the lock file.

Stage 2 — assemble. Fetch the pinned .peipkg payloads, verify them against the lock's hashes, and lay out the root directory. This stage needs only package bytes, not metadata, and can run fully air-gapped once a lock exists.

lock does Stage 1 alone. build does Stage 2, running Stage 1 first when it needs to. Everything build-stamped in the output is derived from the manifest's source_date, so given the same manifest, lock, and packages, the output is reproducible.

peipkg-compose lock #

peipkg-compose lock <manifest> [-o <lock>]

Resolve the manifest against its repositories, verify the trust chain, and write the lock file. lock builds nothing — it produces only the lock.

The <manifest> positional is required. Flag order is not significant: the manifest may appear before or after -o.

OptionEffect
-o <lock>Output lock path. Defaults to the manifest path with a trailing .toml replaced by .lock.toml — so image.toml locks to image.lock.toml. Only the single-dash -o form exists here; there is no --out for lock.

peipkg-compose build #

peipkg-compose build <manifest> --out <dir> [--locked | --update]
                     [--dangerously-bypass-path-restrictions]

Assemble the root directory. The <manifest> positional is required; flag order is not significant.

--out <dir> is required — omitting it is a usage error. The directory named by --out must not already exist: compose creates it, and refuses to build into or over an existing tree.

OptionEffect
--out <dir>The output root directory. Required. Must not already exist.
--lockedRequire an existing lock and do not resolve. Fails if no lock is present. Fetches only package bytes, taking integrity from the lock's hashes — the air-gap-friendly path.
--updateRe-resolve from scratch, overwrite the lock, then build.
--dangerously-bypass-path-restrictionsPermit packages that declare special_system_package to compose payloads outside the payload layout rules. An image built from a package set including the base filesystem needs this; peiso passes it through from bypass_path_restrictions in the image's build spec, so the grant stays a visible decision of the image rather than something the composer assumes.

--locked and --update are mutually exclusive.

With neither flag, build follows Cargo's build / Cargo.lock behaviour:

  • if the sibling lock is missing, it resolves, writes the lock, and builds;
  • if the sibling lock is present, it verifies the lock still matches the manifest and then builds.

Use --locked for reproducible or air-gapped rebuilds where no metadata resolution should happen at all, and --update when you want to pick up newly-available versions and refresh the lock in the same run.

The manifest #

The manifest is a TOML file describing the package set to assemble and the repositories to draw it from. Any filename works — it is passed positionally — and its sibling lock is <stem>.lock.toml. The manifest and the lock are tool-local build artifacts; they are not an on-wire Peios format.

The parser is strict: an unknown key anywhere in the manifest is a hard error, not a warning. A mistyped field name fails the build rather than being silently ignored.

schema = 1
arch = "x86_64"
source_date = "2026-07-01T00:00:00Z"

local_packages = ["./bootstrap/*.peipkg"]

[[repository]]
name = "core"
base_url = "https://repo.example.org/core"
priority = 10
signature_policy = "required"
trust_anchors = ["a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"]

[[root]]
name = "initramfs"
path = "boot/initramfs"

[[package]]
name = "base-system"

[[package]]
name = "nginx"
version = ">=1.27, <1.28"
repository = "core"

[[package]]
name = "live-boot"
root = "initramfs"

Top-level fields #

FieldTypeRequiredMeaning
schemaintyesManifest schema version. Must be 1.
archstringyesTarget architecture; becomes the image's primary architecture.
source_datestringyesAn RFC 3339 timestamp. Fixes every build-stamped time in the output, for reproducibility — the manifest's SOURCE_DATE_EPOCH.
local_packagesarray of stringsnoPaths or globs of local .peipkg files. A bootstrap path for packages that live in no repository.
[[repository]]array of tablesnoPackage sources. Also written into the image as .repo files.
[[root]]array of tablesnoNamed roots, for a multi-root image.
[[package]]array of tablesyesThe packages to install. Must be present and non-empty — an empty package set is an error.

[[repository]] #

A repository table has the same shape as a .repo file. These entries drive metadata fetch and trust verification during the build, and are written verbatim to lcl/conf/peipkg/<name>.repo, so the booted system inherits the same repositories and the same trust anchors.

FieldTypeRequiredMeaning
namestringyesRepository name; also the .repo filename stem.
base_urlstringyesWhere the repository's index and packages are served from.
priorityintnoSelection priority. Default 50.
signature_policystringnorequired (default) or optional.
trust_anchorsarray of stringsnoHex key fingerprints trusted to sign this repository's index.
allow_insecure_transportboolnoPermit non-TLS transport. Default false.
min_index_versionintnoReject an index older than this version.

[[package]] #

Each package table names one package to install. Package identity is (root, name) — the same package may be requested in more than one root, but a duplicate (root, name) pair is an error.

FieldTypeRequiredMeaning
namestringyesThe package to install.
versionstringnoA version constraint — a range like ">=1.27, <1.28" or an exact pin like "9.9.1". Omitted, or "*", means any version and the resolver picks the newest.
repositorystringnoPin the source repository. Must name a declared [[repository]].
rootstringnoTarget a declared [[root]] — like peipkg install --root. Empty means the package's own default_root if it has one, otherwise the anchor root.

There is no default_root key in the manifest. default_root is a property each package carries from its repository index, and compose honours it automatically whenever a package's root is unset — just as the live consumer does.

[[root]] #

Each root table registers a named root in the built image.

FieldTypeRequiredMeaning
namestringyesA single named-root segment matching [a-z0-9][a-z0-9_-]* — no dots.
pathstringyesLocation relative to the output root. May not be absolute, may not escape with .., and may not be ..

The lock file #

The lock is generated by lock, or written implicitly by a default or --update build. Its header reads generated by peipkg-compose — do not hand-edit; it is never edited by hand.

For the full transitive package closure, the lock pins each package's exact version, architecture, source repository (or local for a local .peipkg), the fetch URL, and the content SHA-256 hash of the .peipkg. It records each package's target root when that is not the anchor, along with the manifest's arch and source_date and a digest of the manifest.

A build refuses a lock that no longer matches its manifest. A mismatched arch, a mismatched source_date, or a changed manifest digest all reject the lock. On the default path the error tells you to re-run with --update to refresh it.

The two explicit flags pin down the two ends of this behaviour:

  • build --locked requires the lock to already exist and does no resolution at all — the reproducible, air-gapped rebuild.
  • build --update re-resolves from scratch and rewrites the lock.

An unattended compose run cannot authorise the elevated actions that the live consumer would stop and prompt for. Rather than fail the build, compose surfaces those as stderr warnings. See Elevated authorisation for what those actions are.

Named roots and claims in a composed image #

A composed image can be multi-root, and both mechanisms settle more simply here than on a live system — the detail lives on their own pages; this is only how compose relates to them.

Named roots. Each [[root]] becomes a named-root registry entry in the built image's database, so the booted system resolves --root <name> and cascades upgrades into those roots. Every root is a subdirectory nested under the single --out tree, and the whole multi-root image is assembled as one tree — compose needs none of the consumer's cross-root transaction machinery. The repositories and the named-root registry live at the anchor root.

Claims. A fresh build is the simplest case of claim reconciliation: there are no incumbents and exactly one known, closed package set, so every provided claim is auto-claimed. When two packages provide the same claim, the holder is chosen deterministically — the lexicographically smallest package name, matching peipkg install. Claim symlinks are written with targets relative to the link's own directory, so the root stays self-contained and relocatable.

What ends up in the root #

A successful build leaves a directory that is a valid peipkg-managed system:

  • every package's payload laid out at its installed paths;
  • a seeded peipkg state database at var/lib/peipkg/db.sqlite, recording the installed set, the named-root registry, and the settled claims;
  • each declared repository written to lcl/conf/peipkg/<name>.repo, so the booted system inherits its repositories and their trust anchors.

The build is atomic as a whole tree. Because --out must not already exist, compose assembles into a sibling staging directory on the same filesystem and, on success, renames it into place atomically. A failed or interrupted run leaves the staging directory behind for inspection rather than a half-populated --out — there is no partial output to clean up or mistake for a finished one.

What compose does not do #

Compose's contract stops at producing a valid peipkg root. It is deliberately narrow.

  • Not a full image builder. It produces only a directory tree — no bootloader, kernel, initramfs contents, registry seed, or peinit wiring. Those belong to whatever assembles the image around it.
  • Not a live-system tool. It never touches the host /. There is no three-phase transaction, no commit boundary, no rollback journal, and no crash-recovery — the output is disposable, and its only atomicity is the single whole-tree rename above.
  • Not the producer side. It does not build or sign .peipkg files and it does not serve repositories — those are the separate peipkg-build, peipkg-repo, and peipkg-manager tools. Compose consumes ordinary peipkg packages and repositories unchanged, exactly as PSPU §5 defines them.
  • No side effects. ldconfig, depmod, and man-db are not run; no KACS security descriptors are applied; no audit events are emitted. A booted system runs those itself.

No environment variables are read, and there is no --version flag.

Exit status #

CodeMeaning
0Success — or the help output (-h, --help, help).
1A compose operation failed — resolution, trust verification, a fetch, a hash mismatch, a stale lock a --locked build could not use, or a file operation.
2A usage error — no arguments, an unknown verb, a missing manifest, a missing --out, extra arguments, --locked with --update, or a bad flag.

Peios Learn — documentation for the Peios project.

Built with Trail.