Skip to content

Commands

pkgforge [--config FILE] [--db DB] [--buildroot DIR] <command> ...

Global options are read from the command line or the environment:

Option Env Meaning
--db PATH PKGFORGE_DB file DB to read/write; - writes records to stdout for install/scan and reads them from stdin for dbdump (compact and scan --drop-stale refuse it)
--db-format FMT PKGFORGE_DB_FORMAT backend: jsonl / yaml / sqlite (else inferred from the --db suffix)
--buildroot DIR PKGFORGE_ROOT staging root that maps to / in the DB; DESTINATION/PATH must resolve inside it
--own PREFIX a directory prefix the package owns (repeatable; replaces own of the project file): the parents of a recorded entry at or below it are recorded too, see Project file
--config FILE, -c FILE PKGFORGE_CONFIG the project file; else pkgforge.cfg in the working directory when there is one. Given before the command only
-v, --verbose raise the running command's log level (repeatable)
-q, --quiet lower the running command's log level (repeatable)
--loglevel [NAME:]LEVEL[,...] set a logger's level directly; NAME is a logger name (e.g. pkgforge.scan), omitted for the running command

Global flags (all but --config) work either before or after the subcommand (pkgforge --db X install … or pkgforge install --db X …). An empty environment variable (e.g. PKGFORGE_DB_FORMAT="", as a CI pipeline commonly exports an unset input) counts as unset, same as leaving it out.

A setting that the project file also holds takes the first of: the flag, the environment variable, the file, the default.

See Exit codes and completion for the exit-status contract and how to install shell completion.

initdb

Create or reset (truncate) an empty file DB.

pkgforge -r stage --db stage.files.jsonl initdb

Keep the DB (and any dbdump output) outside --buildroot: scan skips its own configured DB file (and, for sqlite, its -journal/-wal/-shm sidecars) if it finds them inside the scanned tree, logging a warning, but it cannot recognize an output file dbdump wrote there earlier.

With no --db (unset or -), initdb logs a WARNING and does nothing -- there's no file to create or reset. install and scan are unaffected by that case: with no --db, they still stage/scan normally and print each recorded entry as a JSON Lines line on stdout instead of writing to a file.

install

Stage a source into the build root and record its entry. A directory or archive install stages the whole tree but records one entry, for the destination itself, unless --record-tree is set: pass it to also record every path below the destination the DB doesn't already hold (see the option table below), or follow up with pkgforge scan --missing DEST for a tree staged by something else (see scan below) -- skip both and the tree's files never reach the rpmspecfiles/debian output at all (rpmspecfiles renders the one entry as %dir; debian skips directory entries in install entirely).

pkgforge install [options] SOURCE... DESTINATION
Option Meaning
-m, --mode 1-4 octal digits (0644 is stored as 644), - (leave default), --/auto (resolve from the staged file); ignored on disk for a symlink (recorded but not applied). Any other value, or an explicit empty value, exits 2 before anything is staged. Write --mode=--, -m--, -m -- or -m auto
-o, --owner / -g, --group owner / group to record; -- (also detached, -o --) reads it from the staged file
-t, --type file / directory / symlink, in any case (auto-detected from the source if unset, or if given as --, also detached, -t --)
-d shortcut for --type directory; not allowed with -t/--type (exit 2)
-p, --parents create missing parent directories of the destination
-T, --no-target-directory treat DESTINATION as the final path, not a directory (else, for an extracted archive, its archive suffix -- .tar, .tar.gz/.tgz, .tar.bz2/.tbz2/.tbz, .tar.xz/.txz, .iso, .zip, matched case-insensitively -- is dropped from the destination name; a directory source keeps its own name unchanged)
-D shortcut for -Tp
-x, --decompress [KIND] decompress the source (gz, xz, bz2, zst or lzma, matched case-insensitively; inferred from the suffix if KIND is omitted)
-X, --exclude PATTERN exclude matches (an absolute pattern is relative to each SRC): pruned from a directory source's copy, or from an archive source's extracted members and directories before anything is merged onto DESTINATION; with only file or symlink sources, logs a warning (nothing to filter)
--method {copy,link,move} (env PKGFORGE_INSTALL_METHOD) how to stage a file or directory source; copy (default) is the only method that leaves the source completely untouched
--record-tree (env PKGFORGE_INSTALL_RECORD_TREE) after installing a directory or archive DESTINATION, also record every path below it not already in the DB: owner/group/meta from this install, mode from a matching rule or [defaults] (the child is chmod'ed to it) else from disk, type from disk (symlinks -), honouring -X; ignored for a file/symlink source, and a no-op under --noentry
--chown apply the recorded owner/group (off by default); an unknown owner/group name exits 2 before anything is staged (a recorded-only name, without --chown, is never resolved)
--remove-source delete the source after staging (files or directories), only once the entry is applied and recorded -- never when the source IS the destination, and refused (exit 2) up front when a directory source contains the resolved destination or the --db file
--noentry stage but do not record a DB entry
-O, --meta KEY=VALUE record free-form metadata on the entry (repeatable); consumed by a symlink's meta.target or by a third-party dump format
-k, --kind KIND how packaging treats the entry: file (default), config, config-noreplace, doc, license or ghost; all but file and ghost need a regular file (exit 2 otherwise); also on mkdir and symlink, where only file and ghost fit
--caps CLAUSE file capabilities the package sets on install: one lowercase setcap clause such as cap_net_bind_service=ep; a regular file that is not a ghost only
-P, --package NAME the package the entry ships in: the [package] name or a [[packages]] name (exit 2 before anything is staged for any other); - takes a matching rule's package, else the [package] name. Applies to the installed entry only. Also on mkdir and symlink

-k KIND marks how the package manager treats the entry; rpmspecfiles writes it as a %config(noreplace), %doc, %license or %ghost directive and debian as conffiles/not-installed lines (see Dump formats):

pkgforge install -p -m 640 -k config-noreplace tool.conf /etc/tool

A symlink source (or an explicit --type symlink) records its meta.target automatically from readlink; only a - (no) source needs -O target=PATH:

pkgforge install -D -t symlink -O target=/usr/lib/tool/bin - /usr/bin/tool

--mode, --chown's owner/group names, and the --db directory (and, without -p, the destination's parent directory) are all checked before anything is staged, so a bad argument exits 2 with nothing on disk.

--method applies only to a filesystem file or directory source: a - (stdin) source, -x/--decompress, an archive source and a symlink source/type all ignore it, since none of them stage from an existing source file the way a plain copy/link/move would -- setting PKGFORGE_INSTALL_METHOD=move globally never breaks an archive install.

pkgforge install -p --method link ./build/tool /usr/bin
  • copy (the default) never touches the source; this is the only method safe to use when the source is still needed afterwards.
  • link hardlinks the source instead of copying its content -- fast, and free of disk use, but -m/-o/-g/--chown then change the shared inode, i.e. the source too. A file os.link can't span (a different filesystem, fs.protected_hardlinks, the per-inode link limit, or a permission error) falls back to a copy automatically, logging one WARNING per install invocation rather than one per file.
  • move consumes the source: a file is renamed (or, across filesystems, copied then removed) into place, and a directory whose destination doesn't exist yet and has no -X/--exclude is renamed as a whole tree in one step; otherwise (an existing destination, or an -X that must leave some files behind) it moves file by file and removes any source directory left empty, keeping excluded files in place. If staging the entry (applying mode/ownership, or recording it) fails afterwards, the data is moved back onto the source before the error is reported -- except for the merge case above, which -- like a partial copy merge -- is not rolled back. --remove-source is redundant with move and a no-op there.

KIND is optional and consumes the next token: write -x KIND SRC DST, --decompress=KIND, or -x after the paths. -x SRC DST makes SRC the kind and is rejected. An unknown kind, or a bare -x whose source suffix names none of the kinds above, exits 2 before anything is staged.

DESTINATION must resolve inside --buildroot: a .. that climbs above the root, or a symlinked path component that leads outside it, exits 2 before anything is staged, instead of writing or recording outside it. An in-root .. (e.g. /usr/share/../lib/x) is normalized both on disk and in the recorded key.

With several SOURCEs, two that resolve to the same non-directory destination (e.g. sharing a basename, or forced onto one path with -T/-D) exit 2 before anything is staged; directory (and archive) sources sharing a destination still merge into it, as they always have.

A directory copy never descends into its own resolved destination, the build root, or the file DB, when any of them sits inside the source directory (a project tree commonly contains its own build root, e.g. Debian's debian/tmp) -- their parent directories are still created, just possibly empty, instead of shutil.copytree recursing into its own output until RecursionError.

A tar-family archive given as a directory-typed source is extracted with stdlib tarfile; other archive types (and a - stdin source, even a tar stream) fall back to bsdtar. tarfile extraction also needs its extraction filter (PEP 706; Python 3.9.17+/3.10.12+/3.11.4+, or 3.12+) -- without it, a tar-family source routes to bsdtar too, and if bsdtar isn't installed either, extraction is refused (exit 1) rather than extracting unfiltered.

Archive extraction policy (both paths): an archive's ownership, setuid/setgid bit, group/other write bit, extended attributes, ACLs and file flags are never restored on the staged tree -- even when running as root -- and a device node, FIFO or socket found in an archive is refused (exit 1); apply the mode/ownership you want with -m/--chown instead. The bsdtar path always runs with --no-same-owner --no-same-permissions --no-xattrs --no-acls --no-fflags (needs libarchive 3.3+) and then checks the extracted tree for a special file. The tarfile path uses a staging filter, not stdlib's 'data'/'tar': a symlink member's target is kept exactly as stored, absolute or climbing above the destination included (ordinary content for a build root), but writing through any symlink between the destination and a member's own parent is refused, even one that resolves back inside the destination; a hardlink member's target is resolved against the destination and must stay inside it. Re-extracting the same archive (or one archive after another) over an existing tree replaces a stale entry at each member's path instead of failing.

With -X/--exclude, every member is extracted into a temporary directory first (as it always is), then a matched file, symlink, or directory (with its whole subtree) is removed there before anything is merged or moved onto DESTINATION -- the same -X statements a directory install's own copy uses, with an absolute pattern relative to the archive's member root. The extraction policy above still applies to every member first: an archive holding a device node, FIFO or socket is refused (exit 1) even when -X matches it, since a device node or FIFO on disk is never how the exclusion is expressed.

Re-running a directory install onto an existing destination always works: any stale destination symlink (from an earlier run, or left there by something else) is replaced rather than causing a FileExistsError or, for a regular-file source, being written through to wherever it points.

scan

Walk a path under the build root and record a FileEntry for every directory and file below PATH -- never PATH itself.

pkgforge scan [-m MODE] [--dir-mode MODE] [-o OWNER] [-g GROUP] [-O KEY=VALUE]... [--missing] [--drop-stale] [-X PATTERN] PATH
Option Meaning
-m, --mode recorded on every file entry as given; - (default) leaves it unset, --/auto (write it as --mode=--, -m-- or -m --) reads the on-disk mode. Never applies to a directory or a symlink
--dir-mode recorded on every directory entry instead of -m; same 1-4-octal-digit/-/--/auto grammar. Default: -- (from disk) when -m/--mode is itself --, else - -- an explicit -m value is never inherited by directories
-o, --owner / -g, --group recorded on every entry (file, directory or symlink) as given; - (default) leaves it unset, -- reads the on-disk owner/group name
-O, --meta KEY=VALUE recorded on every entry (file, directory or symlink), same as -m/-o/-g (repeatable)
--missing only fill in entries absent from the DB, leaving existing ones (e.g. ones install already recorded) untouched -- without it, scan replaces them. A path whose entry was previously removed (e.g. by --drop-stale) is treated as absent and re-added if the file is still (or again) on disk; use -X to keep such a path out for good
--drop-stale after scanning, remove (tombstone) each DB entry below PATH whose file is no longer on disk, so dbdump stops listing it. An entry matching -X is kept even if its file is gone (protects a deliberately-absent entry, e.g. an RPM %ghost). Needs a real --db file (exits 2 for an unset or - DB); never touches disk, only the DB
-X, --exclude PATTERN skip paths matching (an absolute pattern is relative to PATH) and prune an excluded directory's subtree (nothing below it is walked or recorded), the same as install; also protects a matching entry from --drop-stale

scan always records each entry's type from the file on disk, so it has no -t/--type (exit 2). A symlink's mode is always recorded as -, whatever -m/--dir-mode say -- Linux ignores a symlink's mode, and rpm warns about an explicit one. -m/--dir-mode/-o/-g default to - (unset), not the on-disk value -- pass --mode=--/--dir-mode=--/--owner=--/--group=-- to read them from disk instead. Never scan a directory the distro itself owns (e.g. /usr, /usr/bin, /usr/share, /etc): every directory scan walks becomes an RPM %dir claim in rpmspecfiles, and a shared directory's mode/owner/group there can conflict with the one the distro's own package ships (see Dump formats). Narrow PATH to a directory your package alone owns, e.g. scan --missing --mode=-- /usr/share/mypkg, or stage that directory explicitly first (install -D -d ...).

PATH must resolve inside --buildroot, the same as install's DESTINATION. A PATH that does not exist under the build root exits 2 with one message, before anything is touched. A symlink PATH is recorded as one symlink entry, never followed -- including a symlinked --buildroot itself for PATH /, which is always walked. scan never records its own configured DB file (or, for sqlite, its -journal/-wal/-shm sidecars) if it finds it inside the scanned tree -- it logs one warning and continues. Keep the DB, and any dbdump output, outside --buildroot.

compact

Collapse an append-log DB (jsonl/yaml) to one record per live path, dropping superseded records and removal tombstones.

pkgforge -r stage --db stage.files.jsonl compact

For sqlite it deletes removal rows and VACUUMs (it never accumulates an append log to collapse). Exits 2 for an unset --db, for --db - (there is no file to rewrite) and for a file that doesn't exist -- nothing is created.

dbdump

Render the file DB into a packaging manifest. See Dump formats. FORMAT is a format's own name (rpmspecfiles, rpmspec, debian) or one of its aliases (rpm for rpmspecfiles, deb for debian) -- either spelling produces identical output.

pkgforge dbdump -f FORMAT [-X PATTERN] [--dirs {all,owned}] [--skeleton] [--force] [OUTPUT]

--skeleton makes debian also write control, changelog, rules and source/format into the OUTPUT directory, and -f rpmspec (or rpmspec-pre419, for rpm older than 4.19) writes the whole spec to a file or -. Both are written from the project file and are meant to be edited, so a file that exists is kept, with a WARNING, unless --force. --force with a format that writes no such file, --skeleton with a format that has no skeleton or without a directory OUTPUT, and a project file missing a key the files need, exit 2.

pkgforge dbdump -f rpmspec hello.spec
pkgforge dbdump -f debian --skeleton debian

From pkgforge.cfg to packages builds both from the example. debian/rules runs pkgforge --config CFG, with the path of the project file (--config, PKGFORGE_CONFIG or ./pkgforge.cfg) relative to the directory above OUTPUT when it lies below it; the spec takes the path at build time: rpmbuild -bb --define "pkgforge_config PATH" hello.spec.

--dirs owned keeps a directory entry only when it is at or below an owned prefix (--own, or own in the project file) and leaves every other entry as it is; it exits 2 when no prefix is set. The default, --dirs all, dumps every directory entry.

dbdump needs a DB to read. With no --db (and no PKGFORGE_DB) it exits 2 with no file DB configured; pass --db or set PKGFORGE_DB, and with a --db that names no file it exits 2 with no file DB at PATH; run pkgforge initdb. An existing but empty DB exits 0 with a WARNING and an empty manifest, and so does a --db with entries none of which survive --exclude.

--db - reads the file DB as JSON Lines from standard input, so an install/scan run with --db - can be piped straight into dbdump to render a manifest without ever writing a DB file:

pkgforge install -D -m 644 --db - ./build/tool /usr/bin/tool | pkgforge dbdump --db - -f rpmspecfiles rpm-files.txt
pkgforge dbdump --db - -f rpmspecfiles rpm-files.txt < records.jsonl

Stdin must be a pipe, a socket or a regular file. A terminal, a closed stdin or a device such as /dev/null (a CI job's usual stdin) exits 2 instead of dumping an empty manifest, so an inherited PKGFORGE_DB=- never passes silently; empty input from a pipe or a file logs a WARNING and dumps an empty manifest, exit 0. A pipe that is never closed blocks (the caller's to close, same as any other stdin source). --db-format is not consulted: the input is always JSON Lines. In an MCP tool call, where stdin is the protocol channel, --db - is a usage error.

stage

Run the [[stage]] steps of the project file, in order: each source is installed, and each directory, empty file and link is created, under the build root, and its entry recorded.

pkgforge --config pkgforge.cfg stage [--init]
Option Meaning
--init reset the file DB first, as initdb does; with no file DB, log a WARNING and go on

Every src is checked before the first step runs, so a missing source stages nothing (exit 2), and a project file with no [[stage]] steps is exit 2. Each step logs one INFO line on pkgforge.stage, then generated N system file(s) (see sysfiles) and staged N step(s), M record(s). With no file DB the records are printed to standard output as JSON Lines.

mkdir

Create directories under the build root and record their entries. Nothing is read from a source, so this is how a directory with no files of its own is packaged. Running it again changes nothing.

pkgforge mkdir [-m MODE] [-o OWNER] [-g GROUP] [-k KIND] [-P PACKAGE] [-O KEY=VALUE]... [-p] DEST...
Option Meaning
-m, --mode as for install
-o, --owner / -g, --group owner / group to record
-k, --kind file (default) or ghost, the only kinds a directory can have; a ghost directory is created at install time by the generated tmpfiles.d file
-P, --package as for install
-O, --meta KEY=VALUE record free-form metadata on the entry (repeatable)
-p, --parents create missing parent directories
pkgforge mkdir -p -m 750 -o root -g adm /var/lib/tool

Create a symbolic link under the build root and record its entry. TARGET is stored as written and may name nothing; a symlink has no mode, so there is no --mode.

pkgforge symlink [-o OWNER] [-g GROUP] [-k KIND] [-P PACKAGE] [-O KEY=VALUE]... [-p] TARGET LINK
Option Meaning
-o, --owner / -g, --group owner / group to record
-k, --kind file (default) or ghost, the only kinds a link can have
-P, --package as for install
-O, --meta KEY=VALUE record free-form metadata on the entry (repeatable)
-p, --parents create missing parent directories of LINK
pkgforge symlink -p tool /usr/bin/t

sysfiles

Write the files that create the package's accounts and directories at install time, and record them. /usr/lib/sysusers.d/NAME.conf holds the accounts that [[users]] and [[groups]] create; /usr/lib/tmpfiles.d/NAME.conf has a line for each ghost directory in the file DB. NAME is [package] name. A file with nothing to say is not written, and one recorded earlier is removed with its entry. stage runs this after its steps, so it is only needed when the files are recorded by other commands.

pkgforge --config pkgforge.cfg sysfiles

It takes no options. Writing a file without [package] name is exit 2, and so is a ghost directory whose path tmpfiles.d cannot spell (whitespace, ", ', a backslash or a control character). It logs one INFO line per file on pkgforge.sysfiles; with no file DB the records are printed to standard output as JSON Lines.

verify

Check the build root against the file DB, the way rpmbuild and dh_missing will see them, before either runs.

pkgforge verify [-X PATTERN]... [-f {text,json}]

It reports a file or symlink on disk that no entry records (unrecorded), an entry whose path does not exist (missing; a ghost entry is never expected on disk), a path whose on-disk type differs from the recorded one (type) and a symlink that points elsewhere than its recorded target (target). A directory with no entry is not reported, since a package owns only the directories it records. Mode, owner and group are not compared: the DB's values are what every dump format ships, and owned parents and scan entries keep their on-disk mode by design. The file DB found inside the build root is skipped with one WARNING.

Text output is one line per issue, sorted by path then problem: PROBLEM PATH, or PROBLEM PATH: recorded R, found F for type and target. -f json prints one document instead, with the four keys path, problem, recorded and found per issue:

pkgforge verify -f json

-X takes DB paths, as for dbdump; a matching path is dropped from both sides, and a matching directory is not descended. The exit status is 0 for a clean tree, 1 when there is any issue (the issues are the output; no pkgforge: error: line is printed) and 2 for no file DB (--db unset or -), a DB file that does not exist or a build root that does not exist. It logs one INFO line, verified N entries: M issue(s), on pkgforge.verify.

package

Build one package file per declared package from the file DB and the build root: the main package first, then each [[packages]] element. Nothing is staged, so run stage and verify first; what is recorded is what ships.

pkgforge package -f {deb,rpm,arch,apk,tar} [-o DIR] [--arch NAME] [--compression KIND]
Option Meaning
-f, --format the package format: deb, rpm (built by rpmbuild), arch, apk or tar. Required
-o, --output the directory the files are written into, created when missing; default .
--arch the architecture name of a package whose arch is any, in the format's own spelling (arm64 for deb, aarch64 for arch and apk); default: the machine's. For rpm it is passed to rpmbuild --target; tar has none
--compression the payload compression the format offers, deb xz, gz, zst or none, arch zst, xz or gz, apk gz, tar gz, xz, zst or none; default: the first listed. rpm takes none
pkgforge package -f deb -o dist

The output is one path per line on standard output, in package order; the log goes to standard error. A format checks that [package] holds the keys it needs, which From pkgforge.cfg to packages lists with the file names, the install-time scripts and the differences from the debian/ and makepkg routes. SOURCE_DATE_EPOCH makes two builds of one build root identical; --compression zst needs Python 3.14 or the zstd extra (pip install "pkgforge[zstd]").

The exit status is 0 when every package was written. It is 1 for a failed rpmbuild or one that is not installed, a file that cannot be read or written, and an entry the format cannot represent (a name over 32 bytes, a path that is not UTF-8 for apk); 2 for a usage problem: an unknown format, --arch or --compression the format cannot take, a key the format needs missing from the project file, no file DB (--db unset or -), a DB file or build root that does not exist, an entry of a package the project file does not declare, or a recorded path that is missing from the build root or of another type (run pkgforge verify). It logs Wrote PATH for each file and built N package(s) with FORMAT on pkgforge.package.