feat: scenarios for curator/memo-inbox/pi-grok, deploy and backup tooling

Scenarios
- memo-inbox: mirrored by copying; the live directory was not moved or modified
  and the service was not restarted. All four tracked files match byte for byte
  (pi-diff.sh reports SAME). Marked deploy = "mirror" so deploy-scenario.sh
  refuses --apply: applying a mirror would invert the direction of truth and
  could change a service in daily use.
- curator: target configuration, not yet deployed. .pi/SYSTEM.md replaces pi's
  coding-assistant prompt; durable role text is in .pi/APPEND_SYSTEM.md;
  profile.toml is the single source of truth for the launch contract.
- pi-grok: registered only. It is genuinely a coding agent, so the isolation
  baseline does not apply in full.

Corrections to the documentation, found by testing rather than by reading
- AGENTS.override.md does NOT block parent-directory context files; it only
  shadows its own directory. Verified: with an override file in the workspace, a
  marker in /tmp/AGENTS.md still reached the system prompt. The only effective
  switch is --no-context-files, so durable role text must live in
  .pi/APPEND_SYSTEM.md, which is a system-prompt file and unaffected by -nc.
  Verified end state: no coding-assistant framing, no pi-docs block, own
  identity and role text present, no parent pollution, only own skills/tools.
- PI_CODING_AGENT_DIR isolates settings/models/auth/trust/extensions/skills/
  prompts/themes under the agent directory -- stronger than the --no-* flags
  because it also repoints credentials -- but does NOT cover ~/.agents/skills.
  Measured: find-skills, modsearch and summarize still leak. So it complements
  --no-skills rather than replacing it.
- --append-system-prompt accepts a file path, which pi-grok relies on.
- cwd is what anchors .pi discovery: a probe that forgot cwd silently lost
  .pi/SYSTEM.md and kept the coding-assistant persona.

Tooling (all dry-run by default; none of them restarts a service)
- pi-diff.sh: compares tracked config against the live install in both
  directions, with a key-redacted comparison for models.json
- deploy-scenario.sh: installs a workspace and renders profile.toml into
  .pi/launch.json, then checks that every referenced path exists
- deploy-runtime.sh: renders models.json from its template, refusing placeholder
  or missing keys. Verified byte-identical to the live file
- pi-backup.sh / pi-restore.sh: archives outside the repo, sha256 manifest
  verified before any restore, live paths preserved rather than overwritten

Fixed while testing: pi-backup.sh compared the destination against the repo root
literally, so a relative --dest ./backups wrote credential archives into the work
tree. Now canonicalised with realpath; ./backups, an absolute in-repo path and
./docs/../backups are all refused.
This commit is contained in:
Kai
2026-08-26 23:17:12 -07:00
parent 7b5e0b093d
commit 07dd648b5f
20 changed files with 2611 additions and 17 deletions
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# Install the user-level Pi configuration from runtime/ into ~/.pi/agent,
# rendering models.json from its template.
#
# Dry run by default.
#
# Usage:
# scripts/deploy-runtime.sh
# scripts/deploy-runtime.sh --apply
# ---------------------------------------------------------------------------
# shellcheck source=lib/common.sh
. "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh"
APPLY=0
[ "${1:-}" = "--apply" ] && APPLY=1
SRC="$REPO_ROOT/runtime/agent"
DST="$HOME/.pi/agent"
SECRETS="$REPO_ROOT/secrets/zenmux.env"
head1 "deploy runtime -> $DST"
[ "$APPLY" -eq 1 ] || info "${C_DIM}(dry run; pass --apply to write)${C_OFF}"
# ---------------------------------------------------------------------------
# models.json: render the template
# ---------------------------------------------------------------------------
head1 "models.json"
if [ ! -f "$SECRETS" ]; then
die "missing $SECRETS
cp secrets/zenmux.env.example secrets/zenmux.env && chmod 600 secrets/zenmux.env
then fill in ZENMUX_API_KEY"
fi
PERM="$(stat -c '%a' "$SECRETS")"
[ "$PERM" = "600" ] || warn "$SECRETS has mode $PERM; expected 600"
# shellcheck disable=SC1090
set -a; . "$SECRETS"; set +a
[ -n "${ZENMUX_API_KEY:-}" ] || die "ZENMUX_API_KEY is empty in $SECRETS"
case "$ZENMUX_API_KEY" in
'<REDACTED>'|'<'*'>'|'${'*) die "ZENMUX_API_KEY in $SECRETS is still a placeholder" ;;
esac
RENDERED="$(python3 - "$SRC/models.json.template" <<'PY'
import json, os, pathlib, re, sys
text = pathlib.Path(sys.argv[1]).read_text()
def sub(match):
name = match.group(1)
value = os.environ.get(name)
if not value:
sys.exit(f"unresolved placeholder ${{{name}}}")
return json.dumps(value)[1:-1] # escape for a JSON string context
text = re.sub(r'\$\{([A-Z_][A-Z0-9_]*)\}', sub, text)
json.loads(text) # fail early on malformed output
sys.stdout.write(text)
PY
)" || die "cannot render models.json.template"
if [ -f "$DST/models.json" ] && [ "$RENDERED" = "$(cat "$DST/models.json")" ]; then
ok "models.json already current"
else
info " write models.json ${C_DIM}(rendered, mode 600)${C_OFF}"
if [ "$APPLY" -eq 1 ]; then
install -d -m 700 "$DST"
printf '%s' "$RENDERED" > "$DST/models.json"
chmod 600 "$DST/models.json"
fi
fi
unset ZENMUX_API_KEY
# ---------------------------------------------------------------------------
# Everything else is copied verbatim
# ---------------------------------------------------------------------------
head1 "settings, extensions, prompts"
CHANGES=0
while IFS= read -r rel; do
src="$SRC/$rel"
dst="$DST/$rel"
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then continue; fi
CHANGES=$((CHANGES + 1))
[ -f "$dst" ] && info " update $rel" || info " create $rel"
if [ "$APPLY" -eq 1 ]; then
install -d -m 700 "$(dirname "$dst")"
install -m 600 "$src" "$dst"
fi
done < <(cd "$SRC" && find settings.json extensions prompts -type f 2>/dev/null | sort)
[ "$CHANGES" -eq 0 ] && ok "already current"
# ---------------------------------------------------------------------------
# Report host-side files this repository deliberately does not own
# ---------------------------------------------------------------------------
head1 "not managed here"
for f in auth.json trust.json models-store.json; do
[ -e "$DST/$f" ] && info " ${C_DIM}$f (host state)${C_OFF}"
done
if [ -e "$DST/extensions/herdr-agent-state.ts" ]; then
info " ${C_DIM}extensions/herdr-agent-state.ts (installed by herdr)${C_OFF}"
fi
printf '\n'
if [ "$APPLY" -eq 1 ]; then
ok "applied"
info ""
info "Restart any Pi gateway that should pick up new provider settings:"
info " systemctl --user restart curator.service pi-memo-telegram.service"
else
info "${C_DIM}dry run complete; re-run with --apply${C_OFF}"
fi
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# Install a scenario's tracked workspace into its live location, and render the
# launch contract from profile.toml.
#
# Dry run by default. Never restarts a service: restarting is a decision about
# when to interrupt a live conversation, and belongs to the operator.
#
# Usage:
# scripts/deploy-scenario.sh <scenario> # show what would change
# scripts/deploy-scenario.sh <scenario> --apply
# scripts/deploy-scenario.sh --list
# ---------------------------------------------------------------------------
# shellcheck source=lib/common.sh
. "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh"
if [ "${1:-}" = "--list" ]; then
list_scenarios
exit 0
fi
NAME="${1:-}"
APPLY=0
[ "${2:-}" = "--apply" ] && APPLY=1
DIR="$(require_scenario "$NAME")"
PROFILE="$DIR/profile.toml"
[ -f "$PROFILE" ] || die "scenario '$NAME' has no profile.toml"
TRACKED="$(toml_get "$PROFILE" scenario tracked)"
if [ "$TRACKED" = "false" ]; then
die "scenario '$NAME' is registered but not tracked; nothing to deploy"
fi
# A scenario is either an authoritative source ("managed") or a record of what
# the live host already does ("mirror"). Applying a mirror would invert the
# direction of truth and, for a service in daily use, risks a behaviour change
# that nobody asked for. Mirrors are diffable but not deployable.
MODE="$(toml_get "$PROFILE" scenario deploy)"
MODE="${MODE:-managed}"
if [ "$MODE" = "mirror" ] && [ "$APPLY" -eq 1 ]; then
die "scenario '$NAME' is a mirror of the live configuration, not an authoritative source.
Refusing --apply. Use scripts/pi-diff.sh $NAME to compare, and promote the
scenario to deploy = \"managed\" in profile.toml once it is the source of truth."
fi
WORKSPACE="$(toml_get "$PROFILE" scenario workspace)"
[ -n "$WORKSPACE" ] || die "scenario '$NAME': [scenario].workspace is not set"
[ -d "$DIR/workspace" ] || die "scenario '$NAME' has no tracked workspace/"
head1 "deploy $NAME -> $WORKSPACE"
[ "$APPLY" -eq 1 ] || info "${C_DIM}(dry run; pass --apply to write)${C_OFF}"
# ---------------------------------------------------------------------------
# 1. Workspace files
# ---------------------------------------------------------------------------
CHANGES=0
while IFS= read -r rel; do
src="$DIR/workspace/$rel"
dst="$WORKSPACE/$rel"
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
continue
fi
CHANGES=$((CHANGES + 1))
if [ -f "$dst" ]; then
info " update $rel"
else
info " create $rel"
fi
if [ "$APPLY" -eq 1 ]; then
install -d -m 700 "$(dirname "$dst")"
install -m 600 "$src" "$dst"
fi
done < <(cd "$DIR/workspace" && find . -type f -printf '%P\n' | sort)
# Executables under bin/ need the execute bit back.
if [ "$APPLY" -eq 1 ] && [ -d "$DIR/workspace/bin" ]; then
while IFS= read -r rel; do
[ -x "$DIR/workspace/$rel" ] && chmod 700 "$WORKSPACE/$rel"
done < <(cd "$DIR/workspace" && find bin -type f -printf '%P\n' 2>/dev/null | sed 's|^|bin/|')
fi
[ "$CHANGES" -eq 0 ] && ok "workspace already matches"
# ---------------------------------------------------------------------------
# 2. Launch contract
#
# profile.toml is the single source of truth for launch flags. Rendering it to
# JSON lets the gateway read the contract instead of hard-coding flags, and lets
# the gateway fail closed when the file is missing -- silently running without
# --no-extensions would widen the agent's reach.
# ---------------------------------------------------------------------------
if [ "$MODE" = "mirror" ]; then
head1 "launch contract"
info " ${C_DIM}skipped: '$NAME' is a mirror; its launch flags live in its own"
info " gateway, not in profile.toml${C_OFF}"
printf '\n'
ok "dry run complete (mirror scenario; nothing to apply)"
exit 0
fi
LAUNCH="$WORKSPACE/.pi/launch.json"
head1 "launch contract -> $LAUNCH"
RENDERED="$(python3 - "$PROFILE" "$WORKSPACE" <<'PY'
import json, sys, tomllib, pathlib
profile = tomllib.loads(pathlib.Path(sys.argv[1]).read_text())
workspace = pathlib.Path(sys.argv[2])
def rel(paths):
return [str(workspace / p) for p in paths or []]
iso = profile.get("isolation", {})
res = profile.get("resources", {})
pers = profile.get("personality", {})
out = {
"_generated_from": "profile.toml",
"_note": "Do not edit. Regenerate with scripts/deploy-scenario.sh.",
"scenario": profile.get("scenario", {}).get("name"),
"workspace": str(workspace),
"session_dir": profile.get("scenario", {}).get("session_dir"),
"model": profile.get("model", {}),
"session": profile.get("session", {}),
"isolation": iso,
"personality": {
"system_prompt": str(workspace / pers["system_prompt"]) if pers.get("system_prompt") else "",
"append_system_prompt": str(workspace / pers["append_system_prompt"]) if pers.get("append_system_prompt") else "",
"context_files": pers.get("context_files", []),
},
"resources": {
"extensions": rel(res.get("extensions")),
"skills": rel(res.get("skills")),
},
"tools": profile.get("tools", {}),
"budget": profile.get("budget", {}),
"env": profile.get("env", {}),
"bridge": profile.get("bridge", {}),
}
print(json.dumps(out, ensure_ascii=False, indent=2))
PY
)" || die "cannot render profile.toml"
if [ -f "$LAUNCH" ] && [ "$RENDERED" = "$(cat "$LAUNCH")" ]; then
ok "launch.json already current"
else
info " write .pi/launch.json"
if [ "$APPLY" -eq 1 ]; then
install -d -m 700 "$WORKSPACE/.pi"
printf '%s\n' "$RENDERED" > "$LAUNCH"
chmod 600 "$LAUNCH"
fi
fi
# ---------------------------------------------------------------------------
# 3. Referenced paths must exist
# ---------------------------------------------------------------------------
head1 "referenced resources"
MISSING=0
while IFS= read -r p; do
[ -n "$p" ] || continue
if [ -e "$p" ]; then ok "$p"; else warn "missing: $p"; MISSING=$((MISSING + 1)); fi
done < <(printf '%s\n' "$RENDERED" | python3 -c '
import json, sys
d = json.load(sys.stdin)
for p in d["resources"]["extensions"] + d["resources"]["skills"]:
print(p)
for key in ("system_prompt", "append_system_prompt"):
if d["personality"].get(key):
print(d["personality"][key])
')
printf '\n'
if [ "$APPLY" -eq 1 ]; then
ok "applied"
info ""
SERVICE="$(toml_get "$PROFILE" scenario service)"
if [ -n "$SERVICE" ]; then
info "The service was NOT restarted. When you are ready:"
info " systemctl --user restart $SERVICE"
info " systemctl --user status $SERVICE"
fi
else
info "${C_DIM}dry run complete; re-run with --apply${C_OFF}"
fi
[ "$MISSING" -eq 0 ] || warn "$MISSING referenced path(s) missing; the agent will not start correctly"
exit 0
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Shared helpers for the pi-agent-config scripts.
# shellcheck shell=bash
set -uo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || {
echo "error: not inside the pi-agent-config git work tree" >&2
exit 1
}
export REPO_ROOT
if [ -t 1 ]; then
C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'
C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'; C_OFF=$'\033[0m'
else
C_RED=''; C_GREEN=''; C_YELLOW=''; C_BOLD=''; C_DIM=''; C_OFF=''
fi
info() { printf '%s\n' "$*"; }
ok() { printf '%s %s%s\n' "${C_GREEN}OK${C_OFF}" "$*" ''; }
warn() { printf '%s %s\n' "${C_YELLOW}WARN${C_OFF}" "$*" >&2; }
die() { printf '%s %s\n' "${C_RED}ERROR${C_OFF}" "$*" >&2; exit 1; }
head1() { printf '\n%s%s%s\n' "$C_BOLD" "$*" "$C_OFF"; }
# List the scenarios that exist, excluding the template.
list_scenarios() {
find "$REPO_ROOT/scenarios" -mindepth 1 -maxdepth 1 -type d \
-not -name '_*' -printf '%f\n' | sort
}
require_scenario() {
local name="${1:-}"
[ -n "$name" ] || die "a scenario name is required. Available: $(list_scenarios | tr '\n' ' ')"
[ -d "$REPO_ROOT/scenarios/$name" ] \
|| die "unknown scenario '$name'. Available: $(list_scenarios | tr '\n' ' ')"
printf '%s' "$REPO_ROOT/scenarios/$name"
}
# Read a flat "key = value" entry from a TOML section.
# Intentionally minimal: these profiles are hand-written and flat. Anything more
# structured should be read by the Python consumer, not by shell.
toml_get() {
local file="$1" section="$2" key="$3"
awk -v want_section="$section" -v want_key="$key" '
/^[[:space:]]*\[/ {
s = $0; sub(/^[[:space:]]*\[/, "", s); sub(/\][[:space:]]*$/, "", s)
current = s; next
}
{
line = $0
sub(/[[:space:]]*#.*$/, "", line)
if (current != want_section) next
if (line !~ /=/) next
k = line; sub(/=.*$/, "", k); gsub(/[[:space:]]/, "", k)
if (k != want_key) next
v = line; sub(/^[^=]*=[[:space:]]*/, "", v)
gsub(/^"|"$/, "", v)
gsub(/[[:space:]]+$/, "", v)
print v; exit
}
' "$file"
}
# Print a unified diff between a tracked file and its live counterpart.
# Returns 0 when identical, 1 when different or missing.
diff_file() {
local tracked="$1" live="$2" label="$3"
if [ ! -f "$live" ]; then
printf '%s %s %s(live file absent)%s\n' "${C_YELLOW}DIFF${C_OFF}" "$label" "$C_DIM" "$C_OFF"
return 1
fi
if cmp -s "$tracked" "$live"; then
printf '%s %s\n' "${C_GREEN}SAME${C_OFF}" "$label"
return 0
fi
printf '%s %s\n' "${C_YELLOW}DIFF${C_OFF}" "$label"
diff -u --label "repo/$label" "$tracked" --label "live/$label" "$live" \
| sed 's/^/ /' || true
return 1
}
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# Back up the live Pi installation and every scenario workspace.
#
# Archives are written OUTSIDE the repository by design. The precedent to avoid
# is hermes-agent-config, which committed hermes-secrets-*.tar.gz into its own
# working tree; had that tree ever been pushed, the credentials would have gone
# with it.
#
# Usage:
# scripts/pi-backup.sh # default destination
# scripts/pi-backup.sh --dest /path/to/dir
# scripts/pi-backup.sh --no-secrets # skip credential archives
# ---------------------------------------------------------------------------
# shellcheck source=lib/common.sh
. "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh"
DEST_ROOT="/mnt/truenas/multimedia/curator/backup/pi-agent-config"
WITH_SECRETS=1
while [ "$#" -gt 0 ]; do
case "$1" in
--dest) DEST_ROOT="${2:?--dest needs a path}"; shift 2 ;;
--no-secrets) WITH_SECRETS=0; shift ;;
-h|--help) sed -n '2,16p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) die "unknown argument: $1" ;;
esac
done
# Canonicalise before comparing: a relative path such as ./backups would
# otherwise slip past a literal prefix test and write credential archives into
# the working tree. (.gitignore would still stop them being committed, but the
# archives should not be there at all.)
DEST_ROOT="$(realpath -m -- "$DEST_ROOT")"
REPO_REAL="$(realpath -- "$REPO_ROOT")"
case "$DEST_ROOT" in
"$REPO_REAL"|"$REPO_REAL"/*)
die "refusing to write backups inside the repository
requested: $DEST_ROOT
repository: $REPO_REAL
Backups contain plaintext credentials and must live outside the work tree." ;;
esac
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
DEST="$DEST_ROOT/$STAMP"
install -d -m 700 "$DEST" || die "cannot create $DEST"
head1 "backup -> $DEST"
# --- 1. Pi agent directory, excluding sessions -----------------------------
if [ -d "$HOME/.pi/agent" ]; then
tar --exclude='agent/sessions' -czf "$DEST/pi-agent-runtime.tar.gz" -C "$HOME/.pi" agent
ok "pi-agent-runtime.tar.gz ${C_DIM}(contains plaintext apiKey)${C_OFF}"
chmod 600 "$DEST/pi-agent-runtime.tar.gz"
fi
# --- 2. Sessions, separately: bulky and lower value -----------------------
if [ -d "$HOME/.pi/agent/sessions" ]; then
tar -czf "$DEST/pi-global-sessions.tar.gz" -C "$HOME/.pi/agent" sessions
ok "pi-global-sessions.tar.gz"
fi
# --- 3. Each scenario workspace and session directory ---------------------
while IFS= read -r name; do
profile="$REPO_ROOT/scenarios/$name/profile.toml"
[ -f "$profile" ] || continue
workspace="$(toml_get "$profile" scenario workspace)"
session_dir="$(toml_get "$profile" scenario session_dir)"
if [ -n "$workspace" ] && [ -d "$workspace" ]; then
tar --exclude='.venv' --exclude='node_modules' --exclude='__pycache__' \
--exclude='.ccgram-uploads' \
-czf "$DEST/workspace-$name.tar.gz" \
-C "$(dirname "$workspace")" "$(basename "$workspace")"
ok "workspace-$name.tar.gz"
fi
if [ -n "$session_dir" ] && [ -d "$session_dir" ]; then
tar -czf "$DEST/sessions-$name.tar.gz" \
-C "$(dirname "$session_dir")" "$(basename "$session_dir")"
ok "sessions-$name.tar.gz"
fi
done < <(list_scenarios)
# --- 4. Credentials -------------------------------------------------------
if [ "$WITH_SECRETS" -eq 1 ]; then
STAGE="$(mktemp -d)"
found=0
while IFS= read -r f; do
[ -f "$f" ] || continue
cp -p "$f" "$STAGE/$(printf '%s' "$f" | tr '/' '_')"
found=1
done <<EOF
$HOME/.config/curator/curator.env
$HOME/.secrets/pi-memo-telegram.env
$HOME/.ccgram/.env
$REPO_ROOT/secrets/zenmux.env
EOF
if [ "$found" -eq 1 ]; then
tar -czf "$DEST/secrets.tar.gz" -C "$STAGE" .
chmod 600 "$DEST/secrets.tar.gz"
ok "secrets.tar.gz ${C_DIM}(mode 600 -- never commit)${C_OFF}"
fi
rm -rf "$STAGE"
else
info " ${C_DIM}secrets skipped (--no-secrets)${C_OFF}"
fi
# --- 5. systemd units and an environment freeze ---------------------------
STAGE="$(mktemp -d)"
cp -p "$HOME"/.config/systemd/user/*.service "$HOME"/.config/systemd/user/*.timer "$STAGE/" 2>/dev/null || true
systemctl --user list-units --all --no-pager --no-legend > "$STAGE/list-units.txt" 2>&1 || true
systemctl --user list-unit-files --no-pager --no-legend > "$STAGE/list-unit-files.txt" 2>&1 || true
tar -czf "$DEST/systemd-units.tar.gz" -C "$STAGE" .
rm -rf "$STAGE"
ok "systemd-units.tar.gz"
{
echo "# Environment freeze -- $(date -u +%FT%TZ)"
echo; echo "## pi"; pi --version 2>&1
echo; echo "## npm -g"; npm ls -g --depth=0 2>/dev/null
echo; echo "## node"; node --version
echo; echo "## python3"; python3 --version
echo; echo "## pi-agent-config HEAD"
git -C "$REPO_ROOT" log --oneline -1
git -C "$REPO_ROOT" rev-parse HEAD
} > "$DEST/environment-freeze.txt"
ok "environment-freeze.txt"
# --- 6. Manifest ----------------------------------------------------------
( cd "$DEST" && sha256sum ./*.tar.gz environment-freeze.txt > MANIFEST.sha256 )
ok "MANIFEST.sha256"
printf '\n'
( cd "$DEST" && sha256sum -c MANIFEST.sha256 >/dev/null 2>&1 ) \
&& ok "checksums verified" \
|| die "checksum verification FAILED -- do not rely on this backup"
info ""
info "Restore with: scripts/pi-restore.sh --from $DEST"
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# Compare tracked configuration against the live installation.
#
# Run this before every deploy, and after every migration, to prove that the
# repository and the host agree.
#
# Usage:
# scripts/pi-diff.sh # runtime + every scenario
# scripts/pi-diff.sh runtime
# scripts/pi-diff.sh <scenario>
#
# Exit status: 0 when everything matches, 1 when anything differs.
# ---------------------------------------------------------------------------
# shellcheck source=lib/common.sh
. "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh"
DIFFS=0
diff_runtime() {
head1 "runtime (~/.pi/agent)"
local live="$HOME/.pi/agent"
local tracked="$REPO_ROOT/runtime/agent"
diff_file "$tracked/settings.json" "$live/settings.json" "settings.json" || DIFFS=$((DIFFS + 1))
# models.json is rendered from a template, so compare the redacted forms.
if [ -f "$live/models.json" ]; then
local a b
a="$(mktemp)"; b="$(mktemp)"
python3 - "$tracked/models.json.template" >"$a" <<'PY'
import json, re, sys
print(re.sub(r'"apiKey":\s*".*?"', '"apiKey": "<X>"',
json.dumps(json.load(open(sys.argv[1])), ensure_ascii=False, indent=2, sort_keys=True)))
PY
python3 - "$live/models.json" >"$b" <<'PY'
import json, re, sys
print(re.sub(r'"apiKey":\s*".*?"', '"apiKey": "<X>"',
json.dumps(json.load(open(sys.argv[1])), ensure_ascii=False, indent=2, sort_keys=True)))
PY
if cmp -s "$a" "$b"; then
ok "models.json (key-redacted comparison)"
else
printf '%s models.json (key-redacted comparison)\n' "${C_YELLOW}DIFF${C_OFF}"
diff -u --label repo/models.json.template "$a" --label live/models.json "$b" | sed 's/^/ /'
DIFFS=$((DIFFS + 1))
fi
rm -f "$a" "$b"
else
warn "live ~/.pi/agent/models.json is absent"
DIFFS=$((DIFFS + 1))
fi
local rel
while IFS= read -r rel; do
diff_file "$tracked/$rel" "$live/$rel" "$rel" || DIFFS=$((DIFFS + 1))
done < <(cd "$tracked" && find extensions prompts -type f 2>/dev/null | sort)
}
diff_scenario() {
local name="$1" dir
dir="$(require_scenario "$name")"
local profile="$dir/profile.toml"
[ -f "$profile" ] || die "scenario '$name' has no profile.toml"
local workspace
workspace="$(toml_get "$profile" scenario workspace)"
[ -n "$workspace" ] || die "scenario '$name': [scenario].workspace is not set"
head1 "$name ($workspace)"
if [ ! -d "$dir/workspace" ]; then
warn "no tracked workspace/ for '$name'; nothing to compare"
return
fi
if [ ! -d "$workspace" ]; then
warn "live workspace $workspace does not exist yet"
DIFFS=$((DIFFS + 1))
return
fi
local rel
while IFS= read -r rel; do
diff_file "$dir/workspace/$rel" "$workspace/$rel" "$rel" || DIFFS=$((DIFFS + 1))
done < <(cd "$dir/workspace" && find . -type f -printf '%P\n' | sort)
# Report live files that the repository does not track, so drift is visible in
# both directions. Runtime state and toolchains are expected and are skipped.
local untracked=()
while IFS= read -r rel; do
case "$rel" in
.ccgram-uploads/*|*/__pycache__/*|*/.venv/*|.venv/*|*.pyc|*.bak-*|\
telegram-gateway/*|download.html|.pi/launch.json|*.log) continue ;;
esac
[ -f "$dir/workspace/$rel" ] || untracked+=("$rel")
done < <(cd "$workspace" && find . -type f -printf '%P\n' | sort)
if [ "${#untracked[@]}" -gt 0 ]; then
printf '%sUNTRACKED in live workspace:%s\n' "$C_DIM" "$C_OFF"
printf ' %s\n' "${untracked[@]}"
fi
}
TARGET="${1:-}"
if [ -z "$TARGET" ]; then
diff_runtime
while IFS= read -r s; do diff_scenario "$s"; done < <(list_scenarios)
elif [ "$TARGET" = "runtime" ]; then
diff_runtime
else
diff_scenario "$TARGET"
fi
printf '\n'
if [ "$DIFFS" -eq 0 ]; then
ok "repository and live installation agree"
else
warn "$DIFFS difference(s) found"
fi
exit $((DIFFS > 0))
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# Restore a backup produced by scripts/pi-backup.sh.
#
# Deliberately conservative:
# - refuses to run unless the manifest verifies
# - moves the current directory aside instead of overwriting it
# - never restarts a service
# - dry run by default
#
# Usage:
# scripts/pi-restore.sh --from <dir> [--only <component>] [--apply]
#
# Components: runtime, sessions, workspace-<scenario>, sessions-<scenario>,
# systemd. Secrets are never restored automatically -- unpack
# secrets.tar.gz by hand so that each file lands where you intend.
# ---------------------------------------------------------------------------
# shellcheck source=lib/common.sh
. "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh"
FROM=""
ONLY=""
APPLY=0
while [ "$#" -gt 0 ]; do
case "$1" in
--from) FROM="${2:?--from needs a path}"; shift 2 ;;
--only) ONLY="${2:?--only needs a component}"; shift 2 ;;
--apply) APPLY=1; shift ;;
-h|--help) sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) die "unknown argument: $1" ;;
esac
done
[ -n "$FROM" ] || die "--from <backup dir> is required"
[ -d "$FROM" ] || die "no such directory: $FROM"
[ -f "$FROM/MANIFEST.sha256" ] || die "no MANIFEST.sha256 in $FROM"
head1 "verify $FROM"
( cd "$FROM" && sha256sum -c MANIFEST.sha256 ) || die "manifest verification failed; refusing to restore"
ok "manifest verified"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
[ "$APPLY" -eq 1 ] || info "\n${C_DIM}(dry run; pass --apply to write)${C_OFF}"
# Move a live path aside rather than overwriting, so a bad restore is reversible.
preserve() {
local path="$1"
[ -e "$path" ] || return 0
local aside="$path.pre-restore-$STAMP"
info " preserve $path -> $aside"
[ "$APPLY" -eq 1 ] && mv "$path" "$aside"
return 0
}
extract() {
local archive="$1" into="$2"
info " extract $(basename "$archive") -> $into"
if [ "$APPLY" -eq 1 ]; then
install -d -m 700 "$into"
tar -xzf "$archive" -C "$into"
fi
}
want() { [ -z "$ONLY" ] || [ "$ONLY" = "$1" ]; }
if want runtime && [ -f "$FROM/pi-agent-runtime.tar.gz" ]; then
head1 "runtime"
preserve "$HOME/.pi/agent"
extract "$FROM/pi-agent-runtime.tar.gz" "$HOME/.pi"
fi
if want sessions && [ -f "$FROM/pi-global-sessions.tar.gz" ]; then
head1 "global sessions"
extract "$FROM/pi-global-sessions.tar.gz" "$HOME/.pi/agent"
fi
while IFS= read -r name; do
profile="$REPO_ROOT/scenarios/$name/profile.toml"
[ -f "$profile" ] || continue
if want "workspace-$name" && [ -f "$FROM/workspace-$name.tar.gz" ]; then
workspace="$(toml_get "$profile" scenario workspace)"
if [ -n "$workspace" ]; then
head1 "workspace $name"
preserve "$workspace"
extract "$FROM/workspace-$name.tar.gz" "$(dirname "$workspace")"
fi
fi
if want "sessions-$name" && [ -f "$FROM/sessions-$name.tar.gz" ]; then
session_dir="$(toml_get "$profile" scenario session_dir)"
if [ -n "$session_dir" ]; then
head1 "sessions $name"
preserve "$session_dir"
extract "$FROM/sessions-$name.tar.gz" "$(dirname "$session_dir")"
fi
fi
done < <(list_scenarios)
if want systemd && [ -f "$FROM/systemd-units.tar.gz" ]; then
head1 "systemd units"
info " ${C_DIM}not extracted automatically: the archive holds every user unit,"
info " and most are unrelated to Pi. Unpack and install selectively:${C_OFF}"
info " mkdir /tmp/units && tar -xzf $FROM/systemd-units.tar.gz -C /tmp/units"
info " install -m 600 /tmp/units/<unit> ~/.config/systemd/user/"
info " systemctl --user daemon-reload"
fi
if [ -f "$FROM/secrets.tar.gz" ]; then
head1 "secrets"
info " ${C_DIM}not restored automatically. Filenames are path-encoded; unpack and"
info " place each file deliberately, then chmod 600:${C_OFF}"
info " mkdir -m 700 /tmp/sec && tar -xzf $FROM/secrets.tar.gz -C /tmp/sec && ls /tmp/sec"
fi
printf '\n'
if [ "$APPLY" -eq 1 ]; then
ok "restore applied; preserved copies carry the suffix .pre-restore-$STAMP"
info ""
info "Restart the affected gateways yourself, then check health:"
info " systemctl --user restart curator.service pi-memo-telegram.service"
info " curl -fsS http://127.0.0.1:8766/api/health"
else
info "${C_DIM}dry run complete; re-run with --apply${C_OFF}"
fi