#!/usr/bin/env bash # pi-devbox-version — show which pi-devbox image build is running. # # WHY THIS EXISTS # The image bakes ground-truth build info into /etc/pi-devbox/build-manifest.json # at `docker build` time (see Dockerfile.variant): the release tag, build date, # source commit, live `pi --version` at build time, and the actual checked-out # commit of every /opt component clone. That answers "what image am I running?" # — but only if you know to go look for the file. This wraps it into one # command, prints it human-first at container start (see entrypoint-user.sh), # and stays available on demand for the rest of the session. # # USAGE # pi-devbox-version human-readable summary (default) # pi-devbox-version --json raw manifest JSON (for scripting) # pi-devbox-version --quiet one-line "release_tag (source_revision)" form # pi-devbox-version --no-skills skip the skill-source section (used at # container start, where it would be premature) # # EXIT STATUS # 0 on success. 1 if the manifest is missing (e.g. an image built before # this file existed, or a non-pi-devbox base) — prints a short notice # to stderr rather than failing silently. set -euo pipefail MANIFEST=/etc/pi-devbox/build-manifest.json MODE="human" SHOW_SKILLS="yes" # A `case "${1:-}"` here only ever looked at the FIRST argument, so # `--no-skills --json` matched --no-skills, silently dropped --json, and # printed human text to a caller expecting JSON (a real failure: a jq # consumer piping that output gets a parse error, not a wrong-but-parseable # answer). Loop over every argument instead, and reject anything unknown # rather than silently ignoring it the same way. for _arg in "$@"; do case "$_arg" in --json) MODE="json" ;; --quiet|-q) MODE="quiet" ;; --no-skills) SHOW_SKILLS="no" ;; --help|-h) # Print the leading `#`-comment block verbatim, stopping at the first # non-comment line, rather than a hardcoded line range: `sed -n # '2,22p'` was silently truncating --help because this file has grown # usage lines since that range was written, and a fixed range will # drift again the next time a comment is added above it. awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0" exit 0 ;; *) echo "pi-devbox-version: unknown option: $_arg" >&2 echo " try --help" >&2 exit 2 ;; esac done if [ ! -f "$MANIFEST" ]; then echo "pi-devbox-version: no build manifest at $MANIFEST" >&2 echo " (image predates the manifest, or this isn't a pi-devbox image)" >&2 exit 1 fi if ! command -v jq >/dev/null 2>&1; then echo "pi-devbox-version: jq not found; dumping raw manifest instead" >&2 cat "$MANIFEST" exit 0 fi if [ "$MODE" = "json" ]; then cat "$MANIFEST" exit 0 fi release_tag=$(jq -r '.release_tag' "$MANIFEST") build_date=$(jq -r '.build_date' "$MANIFEST") source_rev=$(jq -r '.source_revision' "$MANIFEST") pi_version_baked=$(jq -r '.pi_version' "$MANIFEST") # `// empty` matters: images built before v1.8.6 have no such field, and # `jq -r` renders a JSON null as the 4-char string "null" — which would # print as a bogus version rather than being treated as absent. mp_version_baked=$(jq -r '.mempalace_version // empty' "$MANIFEST") if [ "$MODE" = "quiet" ]; then printf '%s (%s)\n' "$release_tag" "${source_rev:0:7}" exit 0 fi # Live drift check: has `pi` been upgraded since this container was built? # (image is immutable, but a volume-persisted ~/.pi could in theory shadow # the baked binary — this stays honest rather than trusting the manifest # blindly, same "ground truth over intent" spirit as how the manifest # itself is generated in Dockerfile.variant.) pi_version_live="" if command -v pi >/dev/null 2>&1; then pi_version_live=$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n') fi # Same check for the palace, which matters more than it looks: mempalace is # the one component that is BOTH client (here) and server (synlig runs this # same image), so a skew between the two is a real failure mode rather than # cosmetic. `mempalace --version` prints "MemPalace 3.8.0" — name-prefixed, # unlike pi's bare "0.84.3" — hence $NF rather than reading the whole line. mp_version_live="" if command -v mempalace >/dev/null 2>&1; then mp_version_live=$(mempalace --version 2>/dev/null | head -n1 | awk '{print $NF}' | tr -d '\r\n') fi printf 'pi-devbox %s\n' "$release_tag" printf ' built: %s (source %s)\n' "$build_date" "${source_rev:0:12}" if [ -n "$pi_version_live" ] && [ "$pi_version_live" != "$pi_version_baked" ]; then printf ' pi: %s \033[33m(baked as %s — drift detected)\033[0m\n' "$pi_version_live" "$pi_version_baked" else printf ' pi: %s\n' "${pi_version_live:-$pi_version_baked}" fi # Printed only when known, so this degrades quietly on pre-v1.8.6 images # instead of showing an empty or "null" palace line. if [ -n "$mp_version_live" ] || [ -n "$mp_version_baked" ]; then if [ -n "$mp_version_live" ] && [ -n "$mp_version_baked" ] && [ "$mp_version_live" != "$mp_version_baked" ]; then printf ' palace: %s \033[33m(baked as %s — drift detected)\033[0m\n' "$mp_version_live" "$mp_version_baked" else printf ' palace: %s\n' "${mp_version_live:-$mp_version_baked}" fi fi printf ' components:\n' jq -r '.components | to_entries[] | select(.value != null) | " \(.key): \(.value[0:12])"' "$MANIFEST" # ── Which copy of each vendored skill is actually being read? ───────── # The image bakes fallback skills under /usr/local/share/pi-devbox/skills/, # but for skills the skillset repo OWNS (skillset-owned.txt) a mounted live # clone takes over at container start via devbox-skill-reconcile. Nothing # reported which copy won, so a stale baked snapshot and a current live clone # looked identical from inside — and on this fleet the baked mempalace copy is # read by NOBODY (all four compose stacks mount a workspace containing the # skillset), which is exactly the sort of fact that should be visible rather # than reasoned about. Same "drift detected" shape as the pi/palace lines # above: what is live, annotated with what was baked, when they disagree. # # Skipped with --no-skills at container start (entrypoint-user.sh calls this # FIRST, before the baked links exist and long before the skillset deploy and # reconcile run last), because a section that is accurate only after boot # finishes is worse than no section at all. BAKED_SKILLS=/usr/local/share/pi-devbox/skills SKILLS_DIR="${HOME:-/home/developer}/.agents/skills" if [ "$SHOW_SKILLS" = "yes" ] && [ -d "$BAKED_SKILLS" ] && [ -d "$SKILLS_DIR" ]; then # Recorded provenance of the vendored mempalace snapshot (absent on images # built before this existed — `// empty` so a JSON null never prints as the # 4-char string "null", the same trap noted for mempalace_version above). # `_tree_sha256`, not `_sha256`: it is a hash over every file in the # vendored skill DIRECTORY (see tree_sha256() below), not one file, because # a single-file hash reports "identical" against a live checkout that added # or edited a sibling file — pi-extensions already ships two files, so this # is not hypothetical. snap_ref=$(jq -r '.skillset_snapshot_ref // empty' "$MANIFEST") snap_sha=$(jq -r '.skillset_snapshot_tree_sha256 // empty' "$MANIFEST") # Which pi-extensions copy the BUILD baked. Distinct from everything else in # this section, which reports which copy is being READ at runtime: for # pi-extensions the baked tree is itself one of two possible copies, and that # choice was made at build time and is not recoverable by inspection. px_src=$(jq -r '.pi_extensions_skill_source // empty' "$MANIFEST") # Same pipeline Dockerfile.variant uses to measure the baked directory at # build time: relative paths in `find | sort` order, each hashed, the whole # listing folded into one sha256. Keep the two definitions identical — they # run in different processes (image build vs. this container) and are # meaningless to compare unless they agree byte-for-byte on the algorithm. tree_sha256() { ( cd "$1" && find . -type f -print | LC_ALL=C sort | xargs -r sha256sum ) 2>/dev/null | sha256sum | cut -d' ' -f1 } # Iterate the baked tree rather than a hardcoded name list, so vendoring a # fourth skill needs no edit here. The header prints only if the tree is # non-empty, so this can never emit a dangling "skills:" label. _printed_header="no" for _dir in "$BAKED_SKILLS"/*/; do [ -d "$_dir" ] || continue if [ "$_printed_header" = "no" ]; then printf ' skills:\n' _printed_header="yes" fi _name=$(basename "$_dir") _link="$SKILLS_DIR/$_name" if [ ! -e "$_link" ]; then printf ' %-22s not linked\n' "$_name" continue fi _target=$(readlink -f "$_link" 2>/dev/null || echo "$_link") case "$_target" in "$BAKED_SKILLS"/*|"$BAKED_SKILLS") # "baked" alone used to be the whole story. For pi-extensions it is not: # the baked tree holds EITHER the package copy that Dockerfile.variant # lays over the snapshot, OR the vendored floor, when the clone had no # skill/ at that ref. The two are indistinguishable by inspection — same # path, same filenames, same permissions — so the build records which one # it used and this reports it. Without this line a six-week-stale # fallback skill looks exactly like a current one, which is precisely how # the floor went unnoticed from 2026-07-30 to 2026-09-10. if [ "$_name" = "pi-extensions" ] && [ -n "$px_src" ]; then case "$px_src" in package) printf ' %-22s baked (package copy)\n' "$_name" ;; vendored-floor) printf ' %-22s baked \033[33m(FALLBACK: vendored floor — clone had no skill/)\033[0m\n' "$_name" ;; divergent) printf ' %-22s baked \033[33m(MIXED: part package, part floor)\033[0m\n' "$_name" ;; *) printf ' %-22s baked\n' "$_name" ;; esac else printf ' %-22s baked\n' "$_name" fi continue ;; esac # Outside the baked tree: a mounted skillset clone, or a user override. # The link target is /skills/, so the repo root is two up. # Everything here is guarded: this script runs on the container-start path # and must never fail, and `set -e` is in force. _root=$(cd "$_target/../.." 2>/dev/null && pwd) || _root="" _head="" if [ -n "$_root" ]; then _head=$(git -C "$_root" rev-parse HEAD 2>/dev/null || echo "") fi _where="live ${_root:-$_target}" [ -n "$_head" ] && _where="$_where @ ${_head:0:7}" # For the one skill whose baked fingerprint we recorded, say plainly # whether the live copy differs from what shipped. This is the check CI # cannot perform (the skillset is private) and the container can, free. # Hash the whole live DIRECTORY with the same tree_sha256() used to # measure the baked one in Dockerfile.variant — a SKILL.md-only compare # would silently ignore a changed or added sibling file. _live_sha="" if [ -n "$snap_sha" ] && [ "$_name" = "mempalace" ] && [ -d "$_target" ]; then _live_sha=$(tree_sha256 "$_target") fi if [ -z "$_live_sha" ]; then printf ' %-22s %s\n' "$_name" "$_where" elif [ "$_live_sha" = "$snap_sha" ]; then printf ' %-22s %s (identical to baked snapshot)\n' "$_name" "$_where" elif [ -n "$_head" ] && [ "$_head" = "$snap_ref" ]; then # Same commit, different bytes — i.e. uncommitted edits in the live # checkout. Distinguished from plain drift because otherwise the line # reads as a self-contradiction ("@ c04cd15 ... baked snapshot c04cd15 # — live copy differs") and a reader would suspect the tool, not the # working tree. printf ' %-22s %s \033[33m(baked snapshot %s + uncommitted edits)\033[0m\n' \ "$_name" "$_where" "${snap_ref:0:7}" else printf ' %-22s %s \033[33m(baked snapshot %s — live copy differs)\033[0m\n' \ "$_name" "$_where" "${snap_ref:0:7}" fi done fi