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.linkhardlinks the source instead of copying its content -- fast, and free of disk use, but-m/-o/-g/--chownthen change the shared inode, i.e. the source too. A fileos.linkcan'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 perinstallinvocation rather than one per file.moveconsumes 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/--excludeis renamed as a whole tree in one step; otherwise (an existing destination, or an-Xthat 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-sourceis redundant withmoveand 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
symlink
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.