diff --git a/AGENT_TODO.md b/AGENT_TODO.md index 2a6cffe..9288c47 100644 --- a/AGENT_TODO.md +++ b/AGENT_TODO.md @@ -42,6 +42,9 @@ summary (newest last). ## Done +- **2026-09-08** — `install.sh` version gate (Architect→Builder): skip+abort when installed version == current version, `--force` to bypass, version scheme `0.0c` (auto-bumps per commit). `install_version()` derives `0.0c$(git rev-list --count HEAD)`; empty when `.git` absent → gate skipped (silently); `INSTALL_VERSION_OVERRIDE` env var (presence-check) = test seam. Gate after arg-parse, before phases, numeric comparison (strip `0.0c`, `-eq`); `log "Already installed ($CURRENT_VERSION). Use --force to re-install."` / `--dry-run` → `(dry-run) Would skip install: already at version $CURRENT_VERSION`, both exit 0. `FORCE=0` init, `--force` parse + usage. `flag_set installed_version "$CURRENT_VERSION"` after "Bootstrap complete" banner (only when DRY_RUN≠1 and version non-empty; even under --force). New `tests/t-install-version.sh` (21 checks / 9 cases). Docs: README/SCRIPTS/AGENT_Context (flags, flow, line count 248→301, tests/README row). Verified: `bash -n` clean; `make gen` idempotent; `make check` OK; `make lint` 0 FAIL / 0 WARN; `make test` suite green. + + - **2026-09-07** — `pos ai` API-key contract mismatch fixed (Architect→Builder→Reviewer; docs/history evidence): docs claimed `AI_API_KEY` was the required primary key, but `resolve_key()` read only provider-specific keys (`7ae2e77` had removed shared-key priority to fix cross-provider leakage; docs never updated). Decision C: provider key stays primary (leakage guard intact), legacy `AI_API_KEY` honored as backward-compat fallback when the provider's own key is empty; `cmd_providers()` "configured" mirrors it; `require_key()` messages byte-stable; `AI_API_KEY` NOT re-added to `# POS_CONFIG:`/`# PROVIDER_CONFIG:` registry. Docs reworded (POS.md rows 91/96/98 + precedence, howto/ai.md first-run hints, HOWTO.md, AGENT_Context 2 prose spots, config/ai.env comment). New regression `tests/t-ai-key-resolution.sh` (24 checks / 10 cases: gemini+openrouter via provider key only, via AI_API_KEY only, both→provider wins, env-wins, llamacpp no-key, missing-key message, providers configured status). Verified: suite 19 files / 440 checks / 0 fail / 0 skip; `make gen` byte-idempotent; `make check` OK; `make lint` 0 FAIL, 0 WARN; Reviewer APPROVE_WITH_NOTES with mutation-based disproof (inverted precedence → C3/C6 fail). - **2026-09-07** — opencode project skill: created `.opencode/skills/linux-post-install/SKILL.md` (repo had no `.opencode/`). Skill encodes the repo's operational playbook for agents: repo shape, `pos` tool model (`# POS:` header system, exec-bit, deps-guard-before-help, INTERACTIVE_CMDS, determinism), doc authority order (MAINTENANCE Phase 0: templates → DEV.md → AGENTS.md → code), Definition of Done gates (`make gen` ×2 idempotent → `make check` → `make lint` 0/0 → `make test` 18 files/416 checks), test conventions (hard-skip contract, negative controls), and repo commands (ci-status, gitea API, pos tree/config). Frontmatter validated (name matches folder, description with trigger keywords); auto-discovered at `.opencode/skills/` — no opencode.json change needed; restart opencode to load. diff --git a/DOC/AGENT_Context_Project.md b/DOC/AGENT_Context_Project.md index 81b99c3..3d07d23 100644 --- a/DOC/AGENT_Context_Project.md +++ b/DOC/AGENT_Context_Project.md @@ -11,18 +11,18 @@ | ## 1. Project Overview | 28–43 | | ## 2. Directory Structure | 44–210 | -| ## 3. Installation Flow | 211–264 | -| ## 4. The `pos` CLI System | 265–346 | -| ## 5. Shared Library — `lib/common.sh` | 347–378 | -| ## 6. Docker Compose / ScaleTail | 379–421 | -| ## 7. Optional Apps (`apps/`) | 422–451 | -| ## 8. Entertainment Module | 452–465 | -| ## 9. Systemd Services | 466–477 | -| ## 10. Configuration Files | 478–504 | -| ## 11. Coding Conventions | 505–537 | -| ## 12. Development Workflow | 538–590 | -| ## 13. Key File Quick Reference | 591–666 | -| ## 14. Common Tasks for Agents | 667–700 | +| ## 3. Installation Flow | 211–269 | +| ## 4. The `pos` CLI System | 270–351 | +| ## 5. Shared Library — `lib/common.sh` | 352–383 | +| ## 6. Docker Compose / ScaleTail | 384–426 | +| ## 7. Optional Apps (`apps/`) | 427–456 | +| ## 8. Entertainment Module | 457–470 | +| ## 9. Systemd Services | 471–482 | +| ## 10. Configuration Files | 483–509 | +| ## 11. Coding Conventions | 510–542 | +| ## 12. Development Workflow | 543–595 | +| ## 13. Key File Quick Reference | 596–671 | +| ## 14. Common Tasks for Agents | 672–705 | ## 1. Project Overview @@ -211,7 +211,11 @@ Linux_post_install/ ## 3. Installation Flow ``` -User runs: ./install.sh [--apps|--full|--feature|--dry-run|--skip |--steps ] +User runs: ./install.sh [--apps|--full|--feature|--dry-run|--force|--skip |--steps ] +│ +├─ Version gate: compares installed version (flag) vs current (0.0c) +│ └─ match + no --force → "Already installed. Use --force to re-install." / exit 0 +│ (--dry-run variant prints "Would skip install"); no git or no flag → skip gate │ ├─ Phase 1: preinstall.sh (requires root) │ └─ apt update + installs 25+ packages + yt-dlp + fail2ban @@ -249,6 +253,7 @@ User runs: ./install.sh [--apps|--full|--feature|--dry-run|--skip |--step | `--full` | Core install + all apps (non-interactive) | | `--feature` | Install `features/` scripts to `/usr/local/bin/` (asks before overwriting), set their flags | | `--dry-run` | Preview without executing | +| `--force` | Re-install even if the version matches | | `--skip ` | Skip a phase (repeatable): `preinstall`, `scripts`, `postinstall`, `scalepoint`, `apps` | | `--steps ` | Run only specific phases. Format: `1,3,4` or `1-3` | | `--no-color` | Disable colored output | @@ -592,7 +597,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | File | Lines | Purpose | |------|-------|---------| -| `install.sh` | 248 | Main orchestrator — 4 phases with CLI flags, `--feature`, prebuilt arch bins | +| `install.sh` | 301 | Main orchestrator — 4 phases with CLI flags, `--feature`, `--force`, version gate, prebuilt arch bins | | `preinstall.sh` | 76 | System packages + hotspot deps + yt-dlp + fail2ban | | `postinstall.sh` | 168 | fail2ban config, PATH, bash completion, systemd (flag-gated) | | `lib/common.sh` | 151 | Shared library (log/warn/err/run/spawn, dry-run aware, `load_system_env`, CONFIG_DIR) | diff --git a/DOC/SCRIPTS.md b/DOC/SCRIPTS.md index a01e6e6..7523e6e 100644 --- a/DOC/SCRIPTS.md +++ b/DOC/SCRIPTS.md @@ -34,7 +34,8 @@ Everything that runs during the bootstrap install: `install.sh`, `preinstall.sh` 1. **Pre-parse `--no-color`** before anything else, so colors are disabled early (`TERM=dumb` is exported). 2. Source `lib/common.sh` (logging, `run`, `spawn`, …) and `lib/flags.sh` (feature flags). 3. Parse CLI options. -4. For each phase, `should_run ` decides whether to run it: +4. **Version gate:** derive the current version (`0.0c` via `install_version()`; empty when `.git` is absent). If the installed version (stored as the `installed_version` flag) matches and `--force` is not given, skip the install — with `--dry-run` it prints `(dry-run) Would skip install: already at version `, otherwise `Already installed (). Use --force to re-install.` and exits 0. When no `installed_version` flag exists or the current version cannot be determined (no `.git`), the gate is skipped. +5. For each phase, `should_run ` decides whether to run it: - `--skip ` removes a phase (takes precedence). - `--steps ` restricts the run to the listed phases only (`1,3,4` or `1-3`). - Phase map: `1=preinstall`, `2=scripts`, `3=postinstall`, `4=scalepoint` (+ `apps` handled separately). @@ -63,6 +64,7 @@ No config file — everything is command-line: | `--full` | Core install + every app (non-interactive) | | `--feature` | Install `features/` scripts to `/usr/local/bin/` (prompts on overwrite), sets their flags | | `--dry-run` | Log every action instead of executing. **Note:** applies to `install.sh` itself; `postinstall.sh` runs as a subprocess and does not inherit `DRY_RUN` | +| `--force` | Re-install even if the version matches | | `--skip ` | Skip a phase (repeatable): `preinstall`, `scripts`, `postinstall`, `scalepoint`, `apps` | | `--steps ` | Run only listed phases: `1,3,4` or `1-3` | | `--no-color` | Disable colored output | diff --git a/README.md b/README.md index 594d8ea..91f2af8 100755 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ cd Linux_post_install | `--apps` | Interactive app picker after core install | | `--full` | Core install + all apps (non-interactive) | | `--dry-run` | Preview without executing | +| `--force` | Re-install even if the version matches | | `--skip ` | Skip a phase: `preinstall`, `scripts`, `postinstall`, `scalepoint`, `apps` | | `--steps ` | Run only specific phases, e.g. `--steps 1,3` | | `--no-color` | Disable colored output | diff --git a/install.sh b/install.sh index 7370c85..66d1024 100755 --- a/install.sh +++ b/install.sh @@ -14,11 +14,26 @@ fi source "$(dirname "$0")/lib/common.sh" source "$(dirname "$0")/lib/flags.sh" +# Return the current install.sh version (e.g. "0.0c174"). +# Derived from git commit count. Empty when .git is absent. +# Override with INSTALL_VERSION_OVERRIDE for testing. +install_version() { + if [ "${INSTALL_VERSION_OVERRIDE+x}" ]; then + printf '%s' "$INSTALL_VERSION_OVERRIDE" + return + fi + local repo_dir count + repo_dir="$(cd "$(dirname "$0")" && pwd)" + count="$(git -C "$repo_dir" rev-list --count HEAD 2>/dev/null)" || { printf ''; return; } + printf '0.0c%s' "$count" +} + # Exported so child phases (preinstall.sh, postinstall.sh) inherit it — # otherwise '--dry-run' silently executes them for real. export DRY_RUN=0 RUN_APPS=0 RUN_FEATURES=0 +FORCE=0 SKIP_PHASES="" STEPS_SPEC="" @@ -58,6 +73,7 @@ Options: --full Core install + all optional apps (non-interactive) --feature Install features/ scripts (prompts before overwriting) --dry-run Show what would be done without executing + --force Re-install even if the version matches --skip Skip a phase (repeatable): preinstall, scripts, postinstall, scalepoint, apps --steps Run only specific phases. Format: 1,3,4 or 1-3 @@ -79,6 +95,7 @@ while [[ $# -gt 0 ]]; do --full) RUN_APPS=2; shift ;; --feature) RUN_FEATURES=1; shift ;; --dry-run) DRY_RUN=1; shift ;; + --force) FORCE=1; shift ;; --skip) [ -z "${2:-}" ] && err "Missing value for --skip" SKIP_PHASES="${SKIP_PHASES:+$SKIP_PHASES,}$2" @@ -95,6 +112,24 @@ while [[ $# -gt 0 ]]; do esac done +# ── Version gate ──────────────────────────────────────────────── +CURRENT_VERSION="$(install_version)" +if [ "${FORCE:-0}" -ne 1 ] && [ -n "$CURRENT_VERSION" ]; then + INSTALLED_VERSION="$(flag_value installed_version 2>/dev/null)" || true + if [ -n "$INSTALLED_VERSION" ]; then + current_num="${CURRENT_VERSION#0.0c}" + installed_num="${INSTALLED_VERSION#0.0c}" + if [ "$current_num" -eq "$installed_num" ] 2>/dev/null; then + if [ "${DRY_RUN:-0}" -eq 1 ]; then + log "(dry-run) Would skip install: already at version $CURRENT_VERSION" + else + log "Already installed ($CURRENT_VERSION). Use --force to re-install." + fi + exit 0 + fi + fi +fi + # ── Phase runner ──────────────────────────────────────────────── # Phase names → numbers: preinstall=1 scripts=2 postinstall=3 scalepoint=4 should_run() { @@ -248,6 +283,11 @@ echo "${GREEN}══════════════════════ echo "${GREEN} Bootstrap complete ($(timer_stop))${RESET}" echo "${GREEN}════════════════════════════════════════════${RESET}" +# ── Record installed version ──────────────────────────────────── +if [ "${DRY_RUN:-0}" -ne 1 ] && [ -n "${CURRENT_VERSION:-}" ]; then + flag_set installed_version "$CURRENT_VERSION" +fi + # ── Optional apps ────────────────────────────────────────────── if [ "$RUN_APPS" -eq 1 ]; then echo diff --git a/tests/README.md b/tests/README.md index dd0c08d..02dd9c1 100644 --- a/tests/README.md +++ b/tests/README.md @@ -53,4 +53,5 @@ silently. | `t-uninstall-manifest.sh` | install.sh ↔ POS_LIBS symmetry, user-unit discovery, marker-driven plugin removal | | `t-gen-docs-drift.sh` | `make gen` idempotence on a pristine tracked tree (CI drift gate) | | `t-lint-gate.sh` | `make lint` green on the real tree; planted violations are caught and named | +| `t-install-version.sh` | install.sh version gate: match→skip, mismatch→proceed, --force bypass, dry-run variant, flag write, numeric comparison | | `t-share-mountpoint.sh` | share-client `ask_mountpoint` UX: existing/new/declined/rejected paths, confirm gate, mkdir side effects, non-TTY stdin contract, static `n`→`t` guards | \ No newline at end of file diff --git a/tests/t-install-version.sh b/tests/t-install-version.sh new file mode 100644 index 0000000..63e4013 --- /dev/null +++ b/tests/t-install-version.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +set -euo pipefail +# t-install-version.sh — install.sh version gate contract: +# match→skip with "Already installed", mismatch→proceed, --force bypass, +# no-git (empty) skip, dry-run variant, flag write after run, +# numeric comparison correctness (0.0c10 > 0.0c9). +# +# Seam: INSTALL_VERSION_OVERRIDE env var (presence check → empty = no-git path), +# FLAGS_DIR env var (existing flag store seam). +# +# Proceed-path cases (2/3/4/5/8) use --skip all phases instead of --dry-run +# because postinstall.sh is not fully dry-run safe (cat on non-existent file +# after dry-run copy). Skipping all phases is functionally equivalent — the +# gate fires before phases, and the banner + flag write still execute. + +run_test() { + local sandbox stubs flags_dir install_sh + + sandbox="$(mksandbox install-version)" + stubs="$sandbox/stubs" + flags_dir="$sandbox/flags" + mkdir -p "$stubs" "$flags_dir" + + install_sh="$ROOT/install.sh" + + # sudo stub: just exec its arguments (bypasses real sudo in sandbox) + cat > "$stubs/sudo" <<'STUB' +#!/usr/bin/env bash +exec "$@" +STUB + chmod +x "$stubs/sudo" + + # install stub: delegate to the real install binary (needed so -d/-m flags + # work; we only shadow 'sudo' to avoid needing a tty/password) + cat > "$stubs/install" <<'STUB' +#!/usr/bin/env bash +exec /usr/bin/install "$@" +STUB + chmod +x "$stubs/install" + + # mkdir stub: just exec its arguments + cat > "$stubs/mkdir" <<'STUB' +#!/usr/bin/env bash +exec "$@" +STUB + chmod +x "$stubs/mkdir" + + local base_env=(PATH="$stubs:/usr/bin:/bin" + HOME="$sandbox/home" + FLAGS_DIR="$flags_dir") + + # Helper: run install.sh with env overrides and capture output+rc. + run_install() { + local envs=() args=() + while [ $# -gt 0 ]; do + if [ "$1" = "--" ]; then shift; break; fi + envs+=("$1"); shift + done + args=("$@") + set +e + TR_OUT="$(env "${base_env[@]}" "${envs[@]}" bash "$install_sh" "${args[@]}" 2>&1)" + TR_RC=$? + set -e + } + + # Helper: set a flag value directly (for pre-seeding installed_version) + set_flag() { + local name="$1" value="$2" + mkdir -p "$flags_dir" + printf '%s' "$value" > "$flags_dir/$name" + } + + # Helper: read a flag value + get_flag() { + local name="$1" + local f="$flags_dir/$name" + [ -f "$f" ] && cat "$f" || printf '' + } + + # ═══════════════════════════════════════════════════════════════ + # Case 1: Match → skip ("Already installed") + # Gate exits BEFORE any phase — safe without --dry-run or --skip. + # ═══════════════════════════════════════════════════════════════ + set_flag "installed_version" "0.0c10" + run_install INSTALL_VERSION_OVERRIDE=0.0c10 + check_rc "C1 match→skip exit code" 0 "$TR_RC" + check_contains "C1 match→skip message" "Already installed (0.0c10)" "$TR_OUT" + + # ═══════════════════════════════════════════════════════════════ + # Case 2: Mismatch → proceed (gate doesn't match, phases skipped) + # ═══════════════════════════════════════════════════════════════ + rm -f "$flags_dir/installed_version" + set_flag "installed_version" "0.0c10" + run_install INSTALL_VERSION_OVERRIDE=0.0c11 -- --skip preinstall,scripts,postinstall,scalepoint + check_rc "C2 mismatch→proceed exit code" 0 "$TR_RC" + check_not_contains "C2 mismatch does not skip" "Already installed" "$TR_OUT" + + # ═══════════════════════════════════════════════════════════════ + # Case 3: Force bypasses match + # ═══════════════════════════════════════════════════════════════ + rm -f "$flags_dir/installed_version" + set_flag "installed_version" "0.0c10" + run_install INSTALL_VERSION_OVERRIDE=0.0c10 -- --skip preinstall,scripts,postinstall,scalepoint --force + check_rc "C3 force bypasses exit code" 0 "$TR_RC" + check_not_contains "C3 force bypasses skip" "Already installed" "$TR_OUT" + + # ═══════════════════════════════════════════════════════════════ + # Case 4: No git (empty version via presence check) + # INSTALL_VERSION_OVERRIDE="" with ${var+x} → empty → gate skipped + # ═══════════════════════════════════════════════════════════════ + rm -f "$flags_dir/installed_version" + set_flag "installed_version" "0.0c10" + run_install INSTALL_VERSION_OVERRIDE="" -- --skip preinstall,scripts,postinstall,scalepoint + check_rc "C4 empty override→proceeds exit code" 0 "$TR_RC" + check_not_contains "C4 empty override does not skip" "Already installed" "$TR_OUT" + + # ═══════════════════════════════════════════════════════════════ + # Case 5: No installed flag → gate doesn't block + # ═══════════════════════════════════════════════════════════════ + rm -f "$flags_dir/installed_version" + run_install INSTALL_VERSION_OVERRIDE=0.0c10 -- --skip preinstall,scripts,postinstall,scalepoint + check_rc "C5 no flag→proceeds exit code" 0 "$TR_RC" + check_not_contains "C5 no flag does not skip" "Already installed" "$TR_OUT" + + # ═══════════════════════════════════════════════════════════════ + # Case 6: Dry-run + match → "Would skip" (not "Already installed") + # ═══════════════════════════════════════════════════════════════ + rm -f "$flags_dir/installed_version" + set_flag "installed_version" "0.0c10" + run_install INSTALL_VERSION_OVERRIDE=0.0c10 -- --dry-run + check_rc "C6 dry-run match exit code" 0 "$TR_RC" + check_contains "C6 dry-run shows Would skip" "Would skip install" "$TR_OUT" + check_not_contains "C6 dry-run not Already installed" "Already installed" "$TR_OUT" + + # ═══════════════════════════════════════════════════════════════ + # Case 7: Flag written after successful run + # All phases skipped; sudo/install/mkdir stubs on PATH so + # flag_set's `run sudo install` actually creates the flag file. + # ═══════════════════════════════════════════════════════════════ + rm -f "$flags_dir/installed_version" + set_flag "installed_version" "0.0c10" + run_install INSTALL_VERSION_OVERRIDE=0.0c11 -- --skip preinstall,scripts,postinstall,scalepoint + check_rc "C7 all-skip exit code" 0 "$TR_RC" + local flag_val + flag_val="$(get_flag installed_version)" + check_eq "C7 flag written with current version" "0.0c11" "$flag_val" + + # ═══════════════════════════════════════════════════════════════ + # Case 8: Numeric comparison — 0.0c10 > 0.0c9 (not blocked) + # ═══════════════════════════════════════════════════════════════ + rm -f "$flags_dir/installed_version" + set_flag "installed_version" "0.0c9" + run_install INSTALL_VERSION_OVERRIDE=0.0c10 -- --skip preinstall,scripts,postinstall,scalepoint + check_rc "C8 numeric 10>9 proceeds exit code" 0 "$TR_RC" + check_not_contains "C8 numeric 10>9 does not skip" "Already installed" "$TR_OUT" + + # Also test: 0.0c9 with installed 0.0c10 → NOT a match + rm -f "$flags_dir/installed_version" + set_flag "installed_version" "0.0c10" + run_install INSTALL_VERSION_OVERRIDE=0.0c9 -- --skip preinstall,scripts,postinstall,scalepoint + check_rc "C8 numeric 9≠10 proceeds exit code" 0 "$TR_RC" + check_not_contains "C8 numeric 9≠10 does not skip" "Already installed" "$TR_OUT" + + # ═══════════════════════════════════════════════════════════════ + # Case 9: Force + match → still writes flag + # ═══════════════════════════════════════════════════════════════ + rm -f "$flags_dir/installed_version" + set_flag "installed_version" "0.0c10" + run_install INSTALL_VERSION_OVERRIDE=0.0c10 -- --skip preinstall,scripts,postinstall,scalepoint --force + check_rc "C9 force+match exit code" 0 "$TR_RC" + local flag_val9 + flag_val9="$(get_flag installed_version)" + check_eq "C9 force writes flag" "0.0c10" "$flag_val9" +}