JSON Output

The sow.cli/v1 envelope, its fields, and the result shape of every command.

Every command that produces data accepts --json. The output is a single line on stdout carrying a versioned envelope, so you can pipe it straight into jq without worrying about which command produced it.

sow status --json
{"schema":"sow.cli/v1","command":"status","ok":true,"repository":"pigsty","operation":null,
 "result":{"repository":"pigsty","status":"clean","ready_to_copy":true,"desired_revision":4,
 "built_generation":4,"dirty_dists":[],"dirty_reasons":[],"pending":{"count":0,"bytes":0},
 "recent_operation":{"id":"8632724976452398569","kind":"add","state":"done",
 "created_at":"2026-08-04T04:07:17.665377Z","updated_at":"2026-08-04T04:07:18.293848Z"},
 "repository_locked":false},"errors":[]}

(Line-wrapped here for readability; the real output is one line.)

The envelope

FieldTypeMeaning
schemastringAlways sow.cli/v1. Check it before parsing anything else.
commandstringThe command as invoked, including the subcommand: add, repo ls, config show.
okbooltrue when errors is empty. Equivalent to exit code 0.
repositorystring or nullThe selected repository, or null for workspace-wide and plain-mode commands.
operationstring or nullThe operation ID for write commands, null for read-only ones.
resultobject or nullCommand-specific payload, described below.
errorsarrayZero or more {code, class, message} objects.

All seven fields are always present. result is null only when the command failed before it could produce anything — an unknown flag, for example.

errors

"errors":[{"code":3,"class":"partial","message":"managed: batch partially succeeded"}]
FieldMeaning
codeThe process exit code1 through 6.
classruntime, usage, partial, lock, integrity, or rejected.
messageThe same text written to stderr.

Branch on class, not on message text. Messages carry paths and package names and will change; the class will not.

Operation IDs are strings

"operation":"8632724976452398569"

Operation IDs are 64-bit values serialized as decimal strings, because they routinely exceed what an IEEE 754 double can represent exactly. In JavaScript, JSON.parse on a bare number would silently corrupt them. Keep them as strings; jq handles them correctly as-is.

stdout and stderr

Results and the JSON envelope go to stdout. Warnings and error diagnostics go to stderr, in addition to appearing in the errors array. So this works:

sow check --json 2>/dev/null | jq -e '.ok'

Result shapes

create

sow create /srv/offline --json
{"schema":"sow.cli/v1","command":"create","ok":true,"repository":null,"operation":null,
 "result":{"dir":"/srv/offline","rpm":4,"deb":3,
 "kept":["blackbox_exporter-0.28.0-1.aarch64.rpm","blackbox_exporter-0.28.0-1.x86_64.rpm",
 "libpq5_18.2-1.pgdg12+1_amd64.deb","pev2-1.23.0-1.noarch.rpm"],
 "removed":[],"marker":false,"noop":true,"recovered":false},"errors":[]}
FieldMeaning
dirThe absolute directory that was indexed
rpm, debPackage counts per format
keptFilenames included in the indexes, sorted
removedPackages deleted by --pigsty cleanup; empty otherwise
markerWhether repo_complete was written
marker_sha256Digest of the marker file; present only with --pigsty
nooptrue when the indexes were already correct and nothing changed
recoveredtrue when this run completed an interrupted previous run
signedFilenames re-signed; present only with --sign-with

init

"result":{"workspace":"/srv/repo","config_created":true,
 "repositories_initialized":0,"dists_initialized":0,"existing":[]}

On a rerun over a workspace that already exists, the counters are 0 and existing names what was found:

"result":{"workspace":"/srv/repo","config_created":false,
 "repositories_initialized":0,"dists_initialized":0,"existing":["sow.yml"]}

config check and config show

"result":{"workspace":"/srv/repo","repositories":1,"dists":2}

config show returns the effective configuration itself, in the same shape as sow.yml after normalization:

"result":{"schema":"sow/v2","architectures":["x86_64","aarch64"],
 "repos":{"pigsty":{"protected":false,
 "signing":{"rpm":{"packages":{"mode":"never"}}},
 "dists":{"el9":{"format":"rpm","architectures":["x86_64","aarch64"],"limit":0,"exclude":null},
          "trixie":{"format":"deb","architectures":["x86_64","aarch64"],"limit":0,"exclude":null}}}}}

With --all, signing entries additionally carry key_fingerprint. Private key material and passphrases never appear.

repo ls, repo new, repo show

repo ls returns an array; repo new and repo show return one object of the same shape.

"result":{"repositories":[{"name":"pigsty","path":"/srv/repo/pigsty","protected":false,
 "dists":2,"generation":4,"desired_revision":4,"status":"clean","packages":7,"memberships":7,
 "recent_operation":{"id":"8632724976452398569","kind":"add","state":"done",
  "created_at":"2026-08-04T04:07:17.665377Z","updated_at":"2026-08-04T04:07:18.293848Z"},
 "config":{"protected":false,"signing":{...},"dists":{...}}}]}

packages counts distinct package objects in the pool; memberships counts distribution memberships, so a package in two distributions counts once and twice respectively.

repo rm returns only the outcome:

"result":{"name":"demo","noop":false,"removed":true}

dist ls, dist new, dist show

"result":{"dists":[{"name":"el9","format":"rpm",
 "architectures":[{"family":"x86_64","ecosystem_arch":"x86_64"},
                  {"family":"aarch64","ecosystem_arch":"aarch64"}],
 "desired_members":4,"built_members":4,"generation":3,"dirty":false,"status":"clean",
 "effective_config_sha256":"39913af601d10d4d4033b0c29e8d66df385f8a6eb22f45219773a7fc170d4243",
 "config":{"format":"rpm","architectures":["x86_64","aarch64"],"limit":0,"exclude":null}}]}

Each architecture entry carries both names: family is the canonical form used in configuration, ecosystem_arch is what appears in the published tree — identical for RPM, amd64/arm64 for DEB.

desired_members ahead of built_members, or dirty: true, means a sow build is pending. effective_config_sha256 is the digest of everything that feeds the renderer; when it changes, the distribution becomes dirty.

dist rm mirrors repo rm: {"name":"el9","noop":false,"removed":true}.

add

"result":{"operation":"320653458389425222","repository":"demo",
 "desired_revision":2,"built_generation":2,"dirty":false,
 "accepted":1,"failed":0,"memberships_added":1,"memberships_removed":0,
 "items":[{"input":"/incoming/pev2-1.23.0-1.noarch.rpm","status":"accepted","format":"rpm",
 "coordinate":"pev2-0:1.23.0-1.noarch",
 "sha256":"d06d7f23b9cfc6aedaab7b60c8e890cda020efe84f1f246243414862b98b1229",
 "dists":{"el9":"accepted"}}]}

One items entry per input path, in stable order. status is the item’s overall outcome, and dists gives the per-distribution decision:

statusMeaning
acceptedNew package object, membership created
reusedThe identical object already existed; may still add membership elsewhere
excludedKept out by policy — see dists for whether it was excluded or limited
failedNot admitted; error carries the reason

The per-distribution values are accepted, excluded, and limited. A package can be accepted by one distribution and limited by another in the same command:

"items":[{"input":".../libpq5_18.2-1.pgdg12+1_amd64.deb","status":"excluded","format":"deb",
 "coordinate":"libpq5=18.2-1.pgdg12+1:amd64","sha256":"310611d0...","dists":{"trixie":"limited"}}]

A failed item carries error instead of the package fields:

{"input":"/incoming/broken-1.0-1.x86_64.rpm","status":"failed",
 "error":"invalid RPM package: parse RPM reader: unexpected EOF"}

memberships_added and memberships_removed count both sides, because limit can evict older versions in the same operation that admits a new one.

rm

"result":{"operation":"3422380511083828695","repository":"pigsty",
 "desired_revision":5,"built_generation":5,"dirty":false,"check":false,
 "removed":[{"dist":"el9","sha256":"45171966...",
   "coordinate":"rpm:pgbouncer_fdw_18-0:1.4.0-1PGDG.rhel9.8.x86_64","name":"pgbouncer_fdw_18"}],
 "dists":["el9"],
 "changes":[{"op":"add","path":"dists/el9/x86_64/repodata/1a57aa2f...-filelists.xml.gz",
   "phase":"metadata","size":382,"sha256":"1a57aa2f..."},
  {"op":"update","path":"dists/el9/x86_64/repodata/repomd.xml","phase":"pointer",
   "size":1510,"sha256":"f28ffe14..."},
  {"op":"delete","path":"dists/el9/x86_64/repodata/0df96f0b...-primary.xml.gz","phase":"delete"}]}

check is true when the command ran with -c/--check, in which case nothing was written and changes is a forecast. Note that removed lists membership removals only — pool bytes are never deleted by rm.

build

"result":{"operation":"3701044631565986409","repository":"pigsty",
 "dists":["el9","trixie"],"desired_revision":5,"built_generation":5,
 "noop":true,"dirty":false}

noop: true means the desired state already matched the built tree, so no generation was created. dists lists the distributions considered, not necessarily the ones rebuilt.

status

"result":{"repository":"pigsty","status":"clean","ready_to_copy":true,
 "desired_revision":4,"built_generation":4,"dirty_dists":[],"dirty_reasons":[],
 "pending":{"count":0,"bytes":0},
 "recent_operation":{"id":"8632724976452398569","kind":"add","state":"done",
  "created_at":"...","updated_at":"..."},
 "repository_locked":false}

status is one of clean, dirty, recovering, error. ready_to_copy is the field to read in a deploy script — but remember status exits 0 in every state, so test the field, not the exit code:

sow status --json | jq -e '.result.ready_to_copy' >/dev/null || exit 1

pending counts package bytes held privately after add --skip, not yet published. repository_locked reports whether another process currently holds the write lock.

check

"result":{"repository":"pigsty","status":"clean","ready_to_copy":true,
 "built_generation":4,"desired_revision":4,
 "layers":[{"name":"config","ok":true,"checked":5,"issues":[]},
  {"name":"state","ok":true,"checked":1,"issues":[]},
  {"name":"public-modes","ok":true,"checked":72,"issues":[]},
  {"name":"package-bytes","ok":true,"checked":7,"issues":[]},
  {"name":"desired-membership","ok":true,"checked":7,"issues":[]},
  {"name":"index","ok":true,"checked":2,"issues":[]},
  {"name":"signature","ok":true,"checked":11,"issues":[]},
  {"name":"generation-manifest","ok":true,"checked":4,"issues":[]}]}

Eight layers, always in this order, each with a count of what it examined and any issues found. A dirty repository reports every layer ok: true and still fails with exit 5, because the layers verify consistency while ready_to_copy reports currency:

{...,"ok":false,"result":{"status":"dirty","ready_to_copy":false,...},
 "errors":[{"code":5,"class":"integrity",
  "message":"integrity or recovery error: managed: repository is not ready to copy: repository status is dirty"}]}

changes

"result":{"repository":"pigsty","base":4,"generation":5,"dirty":false,
 "changes":[{"op":"add","path":"dists/el9/x86_64/repodata/1a57aa2f...-filelists.xml.gz",
   "phase":"metadata","size":382,"sha256":"1a57aa2f..."},
  {"op":"update","path":"dists/el9/x86_64/repodata/repomd.xml","phase":"pointer",
   "size":1510,"sha256":"f28ffe14..."},
  {"op":"delete","path":"dists/el9/x86_64/repodata/0df96f0b...-primary.xml.gz","phase":"delete"}]}
FieldValues
opadd, update, delete
phasepayload, metadata, pointer, delete
pathAlways relative to the repository root, always /-separated
size, sha256Present for add and update; omitted for delete

Apply the phases in that order and no client ever sees a dangling reference: package bytes first, then checksum-named metadata, then the protocol pointers (repomd.xml, Release), and only then the deletion of superseded files.

sow changes 0 yields the complete current tree as one add set — a full delivery manifest.

ls, show, where

ls returns an array of package objects; show returns exactly one under package.

"result":{"repository":"pigsty","dists":["el9"],"dirty":false,
 "packages":[{"sha256":"d06d7f23...","format":"rpm","coordinate":"pev2-0:1.23.0-1.noarch",
 "architecture":"noarch","canonical_arch":"neutral",
 "pool_path":"pool/p/pev2/pev2-1.23.0-1.noarch.rpm","filename":"pev2-1.23.0-1.noarch.rpm",
 "size":316372,"name":"pev2","source":"pev2","version":"1.23.0","epoch":"0","release":"1",
 "kind":"main","payload_sha256":"0413d629...","signature_key":"E7935D8DB9BD8B20",
 "storage":"pool","created_revision":3,"dists":["el9"],"built_dists":["el9"]}]}

The fields worth knowing:

FieldMeaning
architectureAs it appears in the package header: x86_64, noarch, amd64, all
canonical_archThe family SOW groups by: x86_64, aarch64, or neutral
payload_sha256RPM only — the signature-neutral digest used to recognize re-signed copies
signature_keyKey ID of the embedded signature, when the package carries one
storagepool when published, pending when added with --skip
dists / built_distsDesired membership versus what the last build published

dists longer than built_dists is another way to see that a build is pending.

where searches the whole workspace and returns locations instead of full objects:

"result":{"reference":"pev2","locations":[{"repository":"pigsty","dists":["el9"],
 "built_dists":["el9"],"sha256":"d06d7f23...","coordinate":"rpm:pev2-0:1.23.0-1.noarch"}]}

log

sow log returns the operation ledger, newest first:

"result":{"repository":"pigsty","operations":[{"id":"3701044631565986409","kind":"build",
 "state":"done",
 "payload_json":"{\"version\":2,\"repository\":\"pigsty\",\"kind\":\"build\",\"config_sha256\":\"37eb6dcf...\",\"skip\":false,\"noop\":true,\"dists\":[\"el9\",\"trixie\"],\"build_dists\":[],\"manifest_sha256\":\"125d7266...\"}",
 "result_json":"{\"dists\":2,\"dropped_pending\":[]}",
 "created_at":"2026-08-04T04:08:08.691678Z","updated_at":"2026-08-04T04:08:08.763019Z"}]}

payload_json and result_json are strings containing nested JSON, not objects. They are stored verbatim so the audit record is byte-stable; parse them with a second pass:

sow log --json | jq -r '.result.operations[] | .payload_json | fromjson | .config_sha256'

Passing an operation ID returns the full detail — state transitions, packages, memberships, and every file action:

"result":{"repository":"pigsty","detail":{"operation":{...},"duration_ms":598,
 "events":[{"sequence":0,"state":"planned","detail_json":"{}","occurred_at":"..."},
  {"sequence":1,"state":"staged",...},{"sequence":2,"state":"applied",...},
  {"sequence":3,"state":"built",...},{"sequence":4,"state":"done",...}],
 "packages":[{"sequence":0,"input_path":"pgbouncer_fdw_18","package_sha256":"45171966...",
  "coordinate":"rpm:pgbouncer_fdw_18-0:1.4.0-1PGDG.rhel9.8.x86_64","disposition":"removed"}],
 "memberships":[{"sequence":0,"dist":"el9","package_sha256":"45171966...","action":"remove"}],
 "files":[{"sequence":0,"action":"add","phase":"metadata","path":"dists/el9/x86_64/repodata/1a57aa2f...-filelists.xml.gz","size":382,"sha256":"1a57aa2f..."}]}}

sow log prune returns what it removed:

"result":{"operation":"7140280533435786353","repository":"demo",
 "before":"2026-01-01T00:00:00+08:00","pruned":0}

Note that before echoes the absolute timestamp a bare date resolved to in your local timezone.

log export is not an envelope

sow log export writes JSON Lines — one complete operation record per line, no envelope, no --json flag. It is meant for archiving, not for scripting a single command:

sow log export - | head -1
sow log export operations.jsonl

It refuses to overwrite an existing file, and it refuses a target whose parent directory is a symlink.

A worked example

Fail a deploy unless the repository is both consistent and current, then list exactly what to copy:

#!/usr/bin/env bash
set -euo pipefail

if ! sow check -r pigsty --json 2>/dev/null | jq -e '.ok' >/dev/null; then
  echo "repository is not deliverable" >&2
  exit 1
fi

# Full manifest of the current published tree, in delivery order.
sow changes 0 -r pigsty --json \
  | jq -r '.result.changes[] | [.phase, .op, .path] | @tsv'

See also

Last modified: 2026-08-08: init commit (fe725aa)