#!/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 # show what would change # scripts/deploy-scenario.sh --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) # --------------------------------------------------------------------------- # 1b. Vendored shared extensions # # A tracked extension that imports from shared/extensions/ cannot resolve that # path once installed outside the repository. The listed modules are copied into # .pi/extensions/_shared/ so the deployed tree is self-contained, while the # repository stays the single source of truth: this overwrites, never merges. # --------------------------------------------------------------------------- SHARED="$(toml_list "$PROFILE" resources shared_extensions)" if [ -n "$SHARED" ]; then while IFS= read -r mod; do [ -n "$mod" ] || continue src="$REPO_ROOT/shared/extensions/$mod" [ -f "$src" ] || die "profile lists shared extension '$mod', which does not exist" dst="$WORKSPACE/.pi/extensions/_shared/$mod" if [ -f "$dst" ] && cmp -s "$src" "$dst"; then continue fi CHANGES=$((CHANGES + 1)) if [ -f "$dst" ]; then info " update .pi/extensions/_shared/$mod" else info " vendor .pi/extensions/_shared/$mod"; fi if [ "$APPLY" -eq 1 ]; then install -d -m 700 "$(dirname "$dst")" install -m 600 "$src" "$dst" fi done < <(printf '%s\n' "$SHARED") fi # 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 # --------------------------------------------------------------------------- # 1c. Application backend # # A scenario that owns a service keeps its source under backend/ (set by # [scenario].backend). It installs into the workspace root, where .pi/ sits # beside it, so the repository is the single source of truth. Only git-tracked # files are copied -- build caches and virtualenvs never leak -- and file modes # (notably the execute bit) are preserved. This overwrites but never prunes: # removing a file the repo no longer tracks from a live tree is too blunt to do # unattended. Like every other step it never restarts the service; it prints a # reminder, because restarting decides when to interrupt a live conversation. # --------------------------------------------------------------------------- BACKEND="$(toml_get "$PROFILE" scenario backend)" if [ -n "$BACKEND" ]; then case "$BACKEND" in /*) ;; *) BACKEND="$REPO_ROOT/$BACKEND" ;; esac [ -d "$BACKEND" ] || die "profile sets [scenario].backend to '$BACKEND', which does not exist" BACKEND_CHANGES=0 while IFS= read -r rel; do src="$BACKEND/$rel" dst="$WORKSPACE/$rel" if [ -f "$dst" ] && cmp -s "$src" "$dst"; then continue fi BACKEND_CHANGES=$((BACKEND_CHANGES + 1)) if [ -f "$dst" ]; then info " update $rel"; else info " create $rel"; fi if [ "$APPLY" -eq 1 ]; then install -d -m 755 "$(dirname "$dst")" install -m "$(stat -c '%a' "$src")" "$src" "$dst" fi done < <(git -C "$BACKEND" ls-files) CHANGES=$((CHANGES + BACKEND_CHANGES)) if [ "$BACKEND_CHANGES" -gt 0 ]; then warn "backend: $BACKEND_CHANGES file(s) changed; restart $(toml_get "$PROFILE" scenario service) to apply" else ok "backend already matches" fi 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