#!/usr/bin/env bash
# snapshot-keet-versions.sh — extract {pkg → version} from a Keet AppImage.
#
# Reads every node_modules/*/package.json (including @scope/pkg) and writes
# the resulting map to a JSON file. Use this as the canonical source-of-truth
# for "the versions the messaging stack actually runs on" — then point
# dep-audit.cjs at it to find drift in our projects.
#
# Usage:
# ./snapshot-keet-versions.sh [/path/to/Keet.AppImage] [output.json]
#
# Defaults:
# AppImage: ~/Downloads/Keet.AppImage
# Output: snapshots/keet-<appVersion>.json (relative to this script's parent)
set -euo pipefail
APPIMAGE="${1:-${HOME}/Downloads/Keet.AppImage}"
OUTPUT="${2:-}"
if [ ! -x "$APPIMAGE" ]; then
echo "error: AppImage not found or not executable: $APPIMAGE" >&2
echo " pass the path as the first argument, or place Keet.AppImage in ~/Downloads/" >&2
exit 1
fi
# Always extract fresh to a tmp dir. Hand-extracted /tmp/squashfs-root dirs
# from prior debugging sessions can be partial, which would silently produce
# an incomplete snapshot.
TMPDIR_MADE=$(mktemp -d -t keet-snapshot-XXXXXX)
trap "rm -rf '$TMPDIR_MADE'" EXIT
cd "$TMPDIR_MADE"
echo "extracting node_modules package.json files from AppImage..." >&2
"$APPIMAGE" --appimage-extract 'resources/app/node_modules/*/package.json' >/dev/null
"$APPIMAGE" --appimage-extract 'resources/app/node_modules/@*/*/package.json' >/dev/null 2>&1 || true
"$APPIMAGE" --appimage-extract 'resources/app/package.json' >/dev/null 2>&1 || true
SQRT="$TMPDIR_MADE/squashfs-root"
NM="$SQRT/resources/app/node_modules"
APP_PKG="$SQRT/resources/app/package.json"
# Default output path uses the discovered app version (so multiple AppImages
# don't overwrite each other).
APP_VERSION=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('version','unknown'))" "$APP_PKG" 2>/dev/null || echo "unknown")
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -z "$OUTPUT" ]; then
OUTPUT="$SCRIPT_DIR/snapshots/keet-${APP_VERSION}.json"
fi
mkdir -p "$(dirname "$OUTPUT")"
python3 - "$NM" "$APP_PKG" "$APPIMAGE" "$OUTPUT" <<'PY'
import json, os, sys, glob, datetime, struct
nm, app_pkg, appimage, output = sys.argv[1:5]
out = {
"source": appimage,
"capturedAt": datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
"packages": {}
}
upgrade = None
try:
app = json.load(open(app_pkg))
out["appName"] = app.get("name")
out["appVersion"] = app.get("version")
upgrade = app.get("upgrade")
out["upgrade"] = upgrade
except Exception as e:
out["appName"] = None
out["appVersion"] = None
pjs = sorted(set(glob.glob(os.path.join(nm, "*/package.json")) + glob.glob(os.path.join(nm, "@*/*/package.json"))))
for pj in pjs:
try:
d = json.load(open(pj))
name = d.get("name")
ver = d.get("version")
if name and ver:
out["packages"][name] = ver
except Exception:
continue
# pear-runtime cache check: if app's `upgrade` field is a pear:// URL, derive
# its discoveryKey and look for the cache at ~/.config/pear/by-dkey/<dkey>/0/.
# This tells us whether pear-runtime has fetched an OTA update for this app.
def z32_decode(s, alphabet='ybndrfg8ejkmcpqxot1uwisza345h769'):
bits = ''.join(format(alphabet.index(c), '05b') for c in s)
n = (len(bits) // 8) * 8
return bytes(int(bits[i:i+8], 2) for i in range(0, n, 8))
pear_cache = None
if upgrade and upgrade.startswith('pear://'):
try:
key_z32 = upgrade.split('://', 1)[1].split('/', 1)[0].split('?', 1)[0]
key_bytes = z32_decode(key_z32)
out["upgradeKeyHex"] = key_bytes.hex()
# Scan every ~/.config/pear/by-dkey/<dkey>/0/checkout — the first 32 bytes
# of each checkout file are the drive's public key. If any match, that's
# the cache for this app's pear-runtime updates.
root = os.path.expanduser('~/.config/pear/by-dkey')
if os.path.isdir(root):
for d in os.listdir(root):
ck = os.path.join(root, d, '0', 'checkout')
if not os.path.exists(ck): continue
with open(ck, 'rb') as f: cb = f.read()
if len(cb) < 32: continue
if cb[:32].hex() != key_bytes.hex(): continue
cache_dir = os.path.join(root, d)
pear_cache = {"path": cache_dir, "discoveryKey": d}
# checkout file: <key:32><length:varint><fork:varint><platform:str><arch:str>
def cdec(buf, off):
b = buf[off]
if b < 0xfd: return b, off+1
if b == 0xfd: return struct.unpack_from('<H', buf, off+1)[0], off+3
if b == 0xfe: return struct.unpack_from('<I', buf, off+1)[0], off+5
return struct.unpack_from('<Q', buf, off+1)[0], off+9
try:
p = 32
length, p = cdec(cb, p)
fork, p = cdec(cb, p)
pear_cache["driveLength"] = length
pear_cache["driveFork"] = fork
except Exception:
pass
# Staged app tree (set by applyUpdate before fsx.swap)
staged_app = os.path.join(cache_dir, '0', 'by-arch', 'linux-x64', 'app')
pear_cache["stagedAppPresent"] = os.path.isdir(staged_app)
pear_cache["bootBundlePresent"] = os.path.exists(os.path.join(cache_dir, '0', 'boot.bundle'))
break
except Exception as e:
out["upgradeError"] = str(e)
out["pearRuntimeCache"] = pear_cache
with open(output, "w") as f:
json.dump(out, f, indent=2, sort_keys=True)
print(f"wrote {output}", file=sys.stderr)
print(f" app: {out.get('appName')}@{out.get('appVersion')}", file=sys.stderr)
print(f" packages from AppImage: {len(out['packages'])}", file=sys.stderr)
if pear_cache:
print(f" pear-runtime cache: {pear_cache['path']}", file=sys.stderr)
if 'driveLength' in pear_cache:
print(f" fetched drive length: {pear_cache['driveLength']} (fork {pear_cache['driveFork']})", file=sys.stderr)
print(f" boot.bundle present: {pear_cache['bootBundlePresent']}", file=sys.stderr)
print(f" staged app present: {pear_cache['stagedAppPresent']}", file=sys.stderr)
if not pear_cache['stagedAppPresent']:
print(f" → no swap pending; app runs from AppImage versions verbatim", file=sys.stderr)
else:
print(f" → WARNING: staged app tree exists; pending swap may install newer modules", file=sys.stderr)
else:
print(f" pear-runtime cache: not found (no OTA fetch attempted)", file=sys.stderr)
PY