Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a164462e7 | |||
| 9ef42c5fd1 | |||
| aedc9b5c8f | |||
| 5b9dea2118 | |||
| 35eb90a58b |
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: linux-post-install
|
||||
description: Use when working on the Linux_post_install repo — creating, modifying, or fixing pos CLI tools (bin/pos-*, install.sh, features/, apps/, lib/, systemd/, completions/), regenerating docs via make gen, or running the make check / make lint / make test gates. Front-loads the # POS: header system, the pos tool model, the doc-authority order (templates > DEV.md > AGENTS.md > code), the definition of done, and the test conventions.
|
||||
---
|
||||
|
||||
# Linux_post_install — Toolkit Operations
|
||||
|
||||
Operational playbook for changing the Linux_post_install repo (Debian/Ubuntu bootstrap + homelab toolkit, Bash). Read `DOC/AGENT_Context_Project.md` first for any non-trivial task — it opens with a **Document Map** (auto-generated line ranges) to jump straight to the relevant section. For day-to-day *usage* docs see `DOC/HOWTO.md`; for the `pos` CLI reference see `DOC/POS.md`.
|
||||
|
||||
## Repo shape
|
||||
|
||||
- `bin/pos-<cat>-<cmd>` — tools (45 of them); category-less `bin/pos-<cmd>` for dispatcher/dev-level (`pos-config`, `pos-tree`). Legacy `bin/wr-*`, `mp3`, `mp4`, `vbox`, `ssh-load-all` are thin forwarders — keep them that way.
|
||||
- `lib/` — shared libraries: `common.sh` (run/spawn/err/warn, `ensure_user_bus`), `registry.sh` (POS_* header query API — consumers source it, never re-implement sed/grep), `menu-lib.sh` (`menu_ask_value`, `--allow-empty`), `jq-seam.sh` (stub-friendly jq), `config.sh` (env files, `pos config` scopes), `share-lib.sh`, `usb-lib.sh`, `ai-lib.sh` (use the `llamafile` seam in tests).
|
||||
- `features/`, `apps/`, `templates/` (pos-tool.sh, app.sh, feature.sh — starting points for new files), `systemd/`, `completions/pos.bash` (gen output), `tests/` (suite, see below), `x64_bin/`+`arm64_bin/` (prebuilt hotspot binaries), `scripts/` (gen-docs, check-sync, lint-conventions, ci-status), `Makefile` (check/gen/lint/test/hook), `.gitea/workflows/lint.yml` (CI).
|
||||
- Gen blocks `GEN:START`/`GEN:END` (AGENT_Context filetable/tree/dispatch/selfcontained/docmap + `completions/pos.bash`) are `make gen` output — never hand-edit.
|
||||
|
||||
## Tool model
|
||||
|
||||
- Format: shebang → `set -euo pipefail` → `# POS: <cat> <cmd> — <desc>` header (missing header hard-fails `make gen`) → `# POS_FLAGS:` / `# POS_SUBCMDS:` / `# POS_CONFIG:` / `# POS_DEPS:` / `# POS_EXAMPLES:` headers → `command -v` deps guards **before** `-h|--help` dispatch → case-based help.
|
||||
- File must be executable (`100755`) and run standalone from `/usr/local/bin` after install (source `lib/common.sh` via `$(dirname "$0")/../lib/common.sh` fallback chain).
|
||||
- New tools are auto-discovered; `make gen` only uses the text after the first `— ` in the header.
|
||||
- Stdin readers must be added to `INTERACTIVE_CMDS` in `bin/pos` or the logging `tee` pipe hangs/swallows prompts.
|
||||
- Deps: apt → `PACKAGES` array in `preinstall.sh`; non-apt/manual installers → `command -v <bin> || err` guard inside the tool. Secrets never committed; runtime config `~/.config/linux_post_install/<tool>.env` (chmod 600), env-var precedence.
|
||||
- Entertainment plugins (`entertainment/`) must NOT source `lib/common.sh` (stdout is the Telegram message); markers `# POS_PLUGIN:` + `# POS_KEYS:`.
|
||||
- ScaleTail compose is a git submodule — `git submodule update --init` before `pos docker compose *`.
|
||||
- Generators must be byte-order deterministic (`LC_ALL=C`, sort) or CI `git diff --exit-code` trips.
|
||||
|
||||
## Doc authority (MAINTENANCE.md Phase 0)
|
||||
|
||||
1. `templates/*.sh` — codified current convention; required starting point for new files (`cp templates/pos-tool.sh bin/pos-<cat>-<cmd>`).
|
||||
2. `DOC/DEV.md` — convention detail, checklists (wins on detail).
|
||||
3. `AGENTS.md` — operational/process facts (wins on process).
|
||||
4. Code + `# POS:` headers — ground truth for behavior and GEN blocks.
|
||||
|
||||
Drift in POS/HOWTO/README/SCRIPTS/SYSTEMD/APPS docs is a doc bug — fix the doc.
|
||||
|
||||
## Definition of done
|
||||
|
||||
After touching `bin/pos-*` (or anything structural):
|
||||
|
||||
```bash
|
||||
make gen # regenerates gen blocks + completions
|
||||
make gen # run twice — must be byte-idempotent
|
||||
make check # self-consistency: bash -n + exec bits + doc-sync + dispatch smoke
|
||||
make lint # convention gate — must end "0 FAIL, 0 WARN"
|
||||
make test # full suite (18 files / 416 checks) unless tests/README.md says otherwise
|
||||
```
|
||||
|
||||
Also: `bash -n <file>` for new/edited scripts, `git diff --check`, CI gate (`.gitea/workflows/lint.yml`, job `gates`) runs the same four commands on push to main and tags `ci-ok/<sha>`; query with `scripts/ci-status.sh [--wait] [<sha>]`.
|
||||
|
||||
## Test conventions (`tests/`)
|
||||
|
||||
- Suite lives in `tests/`; run standalone after suite load: `test_run <name> <label> <stdin> <expected_rc>` with `expect_eq`/`expect_ok`/`expect_fail` helpers; each file prints `^ <name>: <n> checks, <m> fail, <k> skip`.
|
||||
- Read `tests/README.md` — it documents the harness and suite table; add a row when adding a file.
|
||||
- Hard-skip contract: tests must never fake passes. Infeasible non-TTY cases are documented `skip`; use `# POS:` header checks, `extract_fn` brace extraction, stub-PATH seams, and non-TTY stdin pipes where possible. Zero-skip is preferred but never silence real failures. Negative controls (assert the new check actually catches the old bug) are required for regression tests.
|
||||
|
||||
## Commands relevant to this repo
|
||||
|
||||
- `make gen` + `make check` + `make lint` (+ `make test`) — the gates above.
|
||||
- `make hook` — opt-in pre-commit gate.
|
||||
- `scripts/ci-status.sh [--wait] [<sha>]` — CI tag lookup (0 green / 1 red / 2 pending); reliable path is the Gitea API `https://gitea.skink-platy.ts.net/api/v1/repos/admin/Linux_post_install/commits/<sha>/status`.
|
||||
- `pos tree` — authoritative structure (needs a built/installed tree; falls back to reading `bin/pos-*` directly).
|
||||
- `pos config <scope>` — runtime env-file config (`~/.config/linux_post_install/<tool>.env`); mask tokens in output.
|
||||
@@ -42,6 +42,13 @@ 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<git commit count>` (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.
|
||||
|
||||
- **2026-09-06** — Repo cleanup: removed 114 temp/process files (AgentsReport/ 86 + reportAgents/ 28 agent reports), stale task/plan/audit docs (tmp_request.md, FINAL_SUMMARY.md, IMPLEMENTATION_PLAN.md, AUDIT.md, AUDIT_TABLE.md, registry-design doc), stray `To`/`.n`/`reports/` artifacts; gitignored AgentsReport/ so agent process reports stay local-only. DOC/, tools-docs/ytsync.md, AGENT_TODO.md, bin/lib/apps/tests/config/scripts etc. kept untouched.
|
||||
|
||||
- **2026-09-06** — `pos ai` OpenRouter 402 + unbounded session — Architect→Builder→Reviewer. User hit OpenRouter 402 on the `assist` alias: "You requested up to 131072 tokens, but can only afford 4511" — no provider sent `max_tokens`, so OpenRouter pre-bills the routed model's full worst-case output (131072 on `openrouter/auto`); user also asked to bound session history to last-5 requests. Architect decisions: `AI_MAX_TOKENS` (num, default **2048**, real cost cap) sent as `max_tokens` on OpenRouter and `generationConfig.maxOutputTokens` on Gemini (llamacpp skipped — local/free); `AI_SESSION_TURNS` (messages, 2 per exchange; default 40 kept back-compat; 10 = last 5 conversations) resolved lazily in `session_push` because config loads after the hardcoded line-25 default; both registered in the `bin/pos-ai` `# POS_CONFIG:` `@General` section → visible in `pos config ai`. Reviewer hardening (CHANGES_REQUIRED → fixed): unguarded env input could reach jq `tonumber` (0/-5/010/abc) — both providers + `session_push` now guard with `^[1-9][0-9]*$` fallback-to-default. Verified: fake-curl shim smoke (16 provider-body + 12 session-window checks incl. 010-regression proof), `make gen` idempotent, `make check` OK, `make lint` 0/0, `make test` 17 files/299 checks green; Reviewer ACCEPT (twice). Tester regression round not run this cycle (user's call); permanent coverage remains a follow-up.
|
||||
|
||||
@@ -11,18 +11,18 @@
|
||||
<!-- GEN:START docmap -->
|
||||
| ## 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 |
|
||||
<!-- GEN:END docmap -->
|
||||
|
||||
## 1. Project Overview
|
||||
@@ -211,7 +211,11 @@ Linux_post_install/
|
||||
## 3. Installation Flow
|
||||
|
||||
```
|
||||
User runs: ./install.sh [--apps|--full|--feature|--dry-run|--skip <phase>|--steps <spec>]
|
||||
User runs: ./install.sh [--apps|--full|--feature|--dry-run|--force|--skip <phase>|--steps <spec>]
|
||||
│
|
||||
├─ Version gate: compares installed version (flag) vs current (0.0c<commit count>)
|
||||
│ └─ 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 <phase>|--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 <phase>` | Skip a phase (repeatable): `preinstall`, `scripts`, `postinstall`, `scalepoint`, `apps` |
|
||||
| `--steps <spec>` | Run only specific phases. Format: `1,3,4` or `1-3` |
|
||||
| `--no-color` | Disable colored output |
|
||||
@@ -488,7 +493,7 @@ All `.service` files in `systemd/` are automatically copied to `/etc/systemd/sys
|
||||
- `~/.config/linux_post_install/entertainment.env` — entertainment plugin defaults: weather location + `ENABLED` auto-trigger list (`plugin, interval` pairs scheduled via `pos entertainment enable/disable`, systemd user timers); auto-installed from `config/entertainment.env` by `postinstall.sh` (no clobber, template printed)
|
||||
- `~/.config/linux_post_install/system.env` — shared "system" tool settings (loaded by `pos system health` / `pos system backup` via `load_system_env()` in `lib/common.sh`; env already exported wins over the file); template `config/system.env`
|
||||
- `~/.config/linux_post_install/notify.env` — alerting platform selection (`NOTIFY_PLATFORM=telegram,matrix`, comma-separated = fan out); read by `lib/notify.sh`; template `config/notify.env`
|
||||
- `~/.config/linux_post_install/ai.env` — AI provider config (`AI_PROVIDER`, `AI_API_KEY` secret, `AI_MODEL`, `AI_SYSTEM_PROMPT`, `AI_MAX_TOKENS`, `AI_SESSION_TURNS`, plus legacy fallbacks `AI_GEMINI_API_KEY`, `AI_GEMINI_MODEL`, `OPENROUTER_API_KEY`, `OPENROUTER_MODEL`); read by `pos ai`; template `config/ai.env`, auto-installed by postinstall, edit with `pos config ai`
|
||||
- `~/.config/linux_post_install/ai.env` — AI provider config (`AI_PROVIDER`, `AI_MODEL`, `AI_SYSTEM_PROMPT`, `AI_MAX_TOKENS`, `AI_SESSION_TURNS`, provider keys `AI_GEMINI_API_KEY`/`OPENROUTER_API_KEY` (secrets), legacy shared fallback `AI_API_KEY` accepted when the provider's key is unset, plus model fallbacks `AI_GEMINI_MODEL`, `OPENROUTER_MODEL`); read by `pos ai`; template `config/ai.env`, auto-installed by postinstall, edit with `pos config ai`
|
||||
- `~/.bashrc` — Modified by postinstall (PATH, bash completion)
|
||||
|
||||
### Feature Flags
|
||||
@@ -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) |
|
||||
@@ -644,9 +649,9 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| `bin/pos-network-hotspot` | 93 | Wi-Fi hotspot via create_ap + wihotspot-gui |
|
||||
| `bin/pos-network-ip` | 69 | Show interfaces, routes, public IP + location |
|
||||
| `bin/pos-network-scan` | 272 | Parallel ping sweep of CIDR |
|
||||
| `bin/pos-share-nfs-client` | 504 | Mount NFS shares (ephemeral or persistent systemd mount units) |
|
||||
| `bin/pos-share-nfs-client` | 511 | Mount NFS shares (ephemeral or persistent systemd mount units) |
|
||||
| `bin/pos-share-nfs-server` | 245 | Manage the NFS kernel server (status, share/unshare exports, enable/disable) |
|
||||
| `bin/pos-share-smb-client` | 766 | Mount SMB/CIFS shares (ephemeral or persistent systemd mount units) |
|
||||
| `bin/pos-share-smb-client` | 773 | Mount SMB/CIFS shares (ephemeral or persistent systemd mount units) |
|
||||
| `bin/pos-share-smb-server` | 441 | Manage the Samba server (status, share/unshare exports, users, enable/disable) |
|
||||
| `bin/pos-share-usb-server` | 362 | USB Redirector server control (--ls, --share; prompts when args omitted) |
|
||||
| `bin/pos-ssh-load-keys` | 31 | Load all SSH keys into the agent |
|
||||
@@ -655,7 +660,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| `bin/pos-system-health` | 209 | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL |
|
||||
| `bin/pos-system-schedule` | 151 | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently |
|
||||
| `bin/pos-system-uninstall` | 517 | Remove pos toolkit binaries, services, shell integration, config, and data |
|
||||
| `bin/pos-ai` | 709 | AI assistant: ask, chat, sessions, capture, models, providers |
|
||||
| `bin/pos-ai` | 714 | AI assistant: ask, chat, sessions, capture, models, providers |
|
||||
| `bin/pos-config` | 80 | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) |
|
||||
| `bin/pos-tree` | 118 | Show the pos CLI command tree: categories, commands, and subcommands |
|
||||
| `completions/pos.bash` | 314 | Dynamic bash completion |
|
||||
@@ -692,7 +697,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| Modify Music→USB sync logic | Edit `bin/pos-media-sync` / shared USB layer `lib/usb-lib.sh` (seams `MEDIA_SYNC_SOURCE`/`MEDIA_SYNC_DEST`/`USB_MOUNT_BASE`/`USB_BYID` in `~/.config/linux_post_install/system.env`) |
|
||||
| Modify YouTube channel sync logic | Edit `bin/pos-media-ytsync` (state in `~/.local/share/linux_post_install/ytsync`; config scope `ytsync` via `pos config ytsync`; research notes `tools-docs/ytsync.md`) |
|
||||
| Modify the scheduler / scheduled jobs | Edit `bin/pos-system-schedule` / `lib/scheduler-lib.sh` (jobs in `~/.config/linux_post_install/schedule.d/`) |
|
||||
| Modify AI logic | Edit `bin/pos-ai` (main tool) + `lib/ai-providers/*.sh` (provider adapters); config scope `ai` via `pos config ai`; `AI_API_KEY`/`AI_MODEL`/`AI_PROVIDER` in `~/.config/linux_post_install/ai.env` |
|
||||
| Modify AI logic | Edit `bin/pos-ai` (main tool) + `lib/ai-providers/*.sh` (provider adapters); config scope `ai` via `pos config ai`; `AI_PROVIDER`, `AI_MODEL`, per-provider `AI_GEMINI_API_KEY`/`OPENROUTER_API_KEY` (legacy shared `AI_API_KEY` accepted as fallback) in `~/.config/linux_post_install/ai.env` |
|
||||
| Modify UFW/firewall logic | Edit `bin/pos-system-firewall` |
|
||||
| Modify pos logging | Edit log setup in `bin/pos` |
|
||||
| Modify install phases/flags | Edit arg parsing in `install.sh` |
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ templates (without overwriting an existing file):
|
||||
| `system.env` | `pos system health`, `pos system backup` | `BACKUP_SERVICE_ROOTS`, `HEALTH_BACKUP_MAX_AGE_DAYS` |
|
||||
| `compose.env` | `pos docker compose` | `TS_AUTHKEY`, `TZ`, `DNS_SERVER`, `SERVICES_BASE` |
|
||||
| `entertainment.env` | `pos entertainment *` | plugin keys (`WEATHER_LAT`…), `ENABLED` |
|
||||
| `ai.env` | `pos ai` | `AI_PROVIDER`, `AI_API_KEY`, `AI_MODEL`, `AI_SYSTEM_PROMPT`, `AI_MAX_TOKENS`, `AI_SESSION_TURNS`, `AI_GEMINI_API_KEY`, `AI_GEMINI_MODEL`, `OPENROUTER_API_KEY`, `OPENROUTER_MODEL` |
|
||||
| `ai.env` | `pos ai` | `AI_PROVIDER`, `AI_MODEL`, `AI_SYSTEM_PROMPT`, `AI_MAX_TOKENS`, `AI_SESSION_TURNS`, provider keys `AI_GEMINI_API_KEY` / `OPENROUTER_API_KEY` (+ legacy `AI_API_KEY` fallback) |
|
||||
| `schedule.d/` | `pos system schedule` | one `<name>.env` per job: `INTERVAL`, `NOTIFY`, `MSG`, `RULE`, `COMMAND` |
|
||||
|
||||
```bash
|
||||
|
||||
+4
-4
@@ -88,17 +88,17 @@ Backward compatibility: `pos ai gemini`, `pos ai openrouter`, and `pos ai llamac
|
||||
| Key | Required | Default | Purpose |
|
||||
|-----|----------|---------|---------|
|
||||
| `AI_PROVIDER` | no | `gemini` | Active provider (gemini\|openrouter\|llamacpp) |
|
||||
| `AI_API_KEY` | yes | — | API key for the active provider (secret — masked in `pos config ai`) |
|
||||
| `AI_API_KEY` | no (legacy fallback) | — | Legacy shared API key, used when the active provider's key is empty; not part of the `pos config ai` prompt (set via env or hand-edit); secret |
|
||||
| `AI_MODEL` | no | per provider | Model id used by `ask`/`chat`/`models` |
|
||||
| `AI_SYSTEM_PROMPT` | no | built-in terse prompt | Custom system prompt (overrides built-in; empty to reset) |
|
||||
| `AI_MAX_TOKENS` | no | `2048` | Max output tokens per request (OpenRouter/Gemini cost cap) |
|
||||
| `AI_SESSION_TURNS` | no | `40` | Session message cap — 2 per exchange; 10 = last 5 exchanges |
|
||||
| `AI_GEMINI_API_KEY` | fallback | — | Legacy: Gemini API key (used when `AI_API_KEY` is empty) |
|
||||
| `AI_GEMINI_API_KEY` | yes (gemini) | — | Gemini API key (the active key when provider is gemini; secret — masked in `pos config ai`) |
|
||||
| `AI_GEMINI_MODEL` | fallback | `gemini-2.5-flash` | Legacy: Gemini model id (used when `AI_MODEL` is empty) |
|
||||
| `OPENROUTER_API_KEY` | fallback | — | Legacy: OpenRouter API key (used when `AI_API_KEY` is empty) |
|
||||
| `OPENROUTER_API_KEY` | yes (openrouter) | — | OpenRouter API key (the active key when provider is openrouter; secret — masked in `pos config ai`) |
|
||||
| `OPENROUTER_MODEL` | fallback | `openrouter/auto` | Legacy: OpenRouter model id (used when `AI_MODEL` is empty) |
|
||||
|
||||
Model precedence: `--model` flag > `AI_MODEL` env > provider-specific fallback (`AI_GEMINI_MODEL`/`OPENROUTER_MODEL`) > provider default. API key precedence: `AI_API_KEY` env > provider-specific fallback (`AI_GEMINI_API_KEY`/`OPENROUTER_API_KEY`) > error. `postinstall.sh` copies the repo's `config/ai.env` template to `~/.config/linux_post_install/ai.env` on install (no clobber). Dependencies: `curl` + `jq` (both in `preinstall.sh` PACKAGES). Sessions are stored in OpenAI `messages` format universally; old Gemini-format sessions (`contents[]`) are auto-migrated on load.
|
||||
Model precedence: `--model` flag > `AI_MODEL` env > provider-specific fallback (`AI_GEMINI_MODEL`/`OPENROUTER_MODEL`) > provider default. API key precedence: `<provider>_API_KEY` (`AI_GEMINI_API_KEY` for gemini / `OPENROUTER_API_KEY` for openrouter) > legacy `AI_API_KEY` fallback > error. `AI_API_KEY` is an internal adapter shim and a backward-compat input — it is not offered by `pos config ai`. `postinstall.sh` copies the repo's `config/ai.env` template to `~/.config/linux_post_install/ai.env` on install (no clobber). Dependencies: `curl` + `jq` (both in `preinstall.sh` PACKAGES). Sessions are stored in OpenAI `messages` format universally; old Gemini-format sessions (`contents[]`) are auto-migrated on load.
|
||||
|
||||
**Messaging bridges:** the Telegram and Matrix listeners forward non-command messages starting with `ai ` (case-insensitive) to `pos ai ask` and reply with the model's answer — see [communication → listener](#communication). The Telegram bridge uses one session per chat (`telegram-<chat id>`), the Matrix bridge one per room (`matrix-<room>`).
|
||||
|
||||
|
||||
+3
-1
@@ -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 <num> <name>` decides whether to run it:
|
||||
4. **Version gate:** derive the current version (`0.0c<git commit count>` 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 <v>`, otherwise `Already installed (<v>). 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 <num> <name>` decides whether to run it:
|
||||
- `--skip <phase>` removes a phase (takes precedence).
|
||||
- `--steps <spec>` 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 <phase>` | Skip a phase (repeatable): `preinstall`, `scripts`, `postinstall`, `scalepoint`, `apps` |
|
||||
| `--steps <spec>` | Run only listed phases: `1,3,4` or `1-3` |
|
||||
| `--no-color` | Disable colored output |
|
||||
|
||||
+6
-3
@@ -69,7 +69,7 @@ exactly as before (no added blank lines), so scripting stays byte-stable.
|
||||
2. Configure it (masked input):
|
||||
|
||||
```bash
|
||||
pos config ai # enter AI_API_KEY (or AI_GEMINI_API_KEY)
|
||||
pos config ai # enter AI_GEMINI_API_KEY
|
||||
```
|
||||
|
||||
3. Test:
|
||||
@@ -81,7 +81,10 @@ exactly as before (no added blank lines), so scripting stays byte-stable.
|
||||
```
|
||||
|
||||
`ai.env` lives at `~/.config/linux_post_install/ai.env` (chmod 600); `pos config ai`
|
||||
is the only place the key is written. The key is never printed by `pos`.
|
||||
is where the provider keys are written, and keys are never printed by `pos`. The
|
||||
legacy shared `AI_API_KEY` is still honored as a fallback when the provider's own
|
||||
key is unset — set it via the shell environment or by hand-editing `ai.env` (it is
|
||||
not part of the `pos config ai` prompt).
|
||||
|
||||
## OpenRouter — many providers, one key
|
||||
|
||||
@@ -98,7 +101,7 @@ pos ai openrouter ask "hi"
|
||||
Configure the API key:
|
||||
|
||||
```bash
|
||||
pos config ai # enter AI_API_KEY (or OPENROUTER_API_KEY)
|
||||
pos config ai # enter OPENROUTER_API_KEY
|
||||
```
|
||||
|
||||
The default model is `openrouter/auto` (OpenRouter picks the best available
|
||||
|
||||
+12
-9
@@ -155,12 +155,13 @@ up — a down/unreachable NFS server can't break boot (with fstab it could).
|
||||
(mount / persist / unmount / unpersist / list). Mountpoints are offered from
|
||||
existing mount-layout candidates with manual entry as fallback — the picker
|
||||
also accepts the server-side export path as a "(as on server)" pick when it
|
||||
differs from your local layout, and `n=new` creates a fresh directory in
|
||||
place (y/N confirmed; a failure just returns to the picker). Unmount lists
|
||||
the active NFS mounts as `<mountpoint> ← <source>` picks and asks for
|
||||
confirmation before unmounting (with a typed fallback when nothing is
|
||||
mounted); unmount and unpersist tolerate already-absent targets instead of
|
||||
erroring.
|
||||
differs from your local layout, and `t=type` lets you type an absolute
|
||||
mountpoint — an existing directory is used as-is, a non-existent path
|
||||
creates it in place (y/N confirmed; a failure just returns to the picker).
|
||||
Unmount lists the active NFS mounts as `<mountpoint> ← <source>` picks and
|
||||
asks for confirmation before unmounting (with a typed fallback when nothing
|
||||
is mounted); unmount and unpersist tolerate already-absent targets instead
|
||||
of erroring.
|
||||
|
||||
**Troubleshooting:**
|
||||
- "mount.nfs not found" → `nfs-common` isn't installed; `sudo apt install nfs-common`
|
||||
@@ -292,9 +293,11 @@ fstab it could). `enable --now` arms the automount immediately.
|
||||
an empty user tries guest enumeration first (with an auth retry on denial),
|
||||
then shares and mountpoints are offered as pickers with manual fallback —
|
||||
the account you authenticated with is reused for the mount. The mountpoint
|
||||
picker accepts `n=new` to create a fresh directory in place (y/N confirmed;
|
||||
a failure just returns to the picker); when the server is this machine, its
|
||||
underlying share directory is offered as a "(as on server)" pick too.
|
||||
picker accepts `t=type` to type an absolute mountpoint — an existing
|
||||
directory is used as-is, a non-existent path creates it in place (y/N
|
||||
confirmed; a failure just returns to the picker); when the server is this
|
||||
machine, its underlying share directory is offered as a "(as on server)"
|
||||
pick too.
|
||||
Unmount lists the active CIFS mounts as `<mountpoint> ← <source>` picks and
|
||||
asks for confirmation before unmounting (typed fallback when nothing is
|
||||
mounted).
|
||||
|
||||
@@ -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 <phase>` | Skip a phase: `preinstall`, `scripts`, `postinstall`, `scalepoint`, `apps` |
|
||||
| `--steps <spec>` | Run only specific phases, e.g. `--steps 1,3` |
|
||||
| `--no-color` | Disable colored output |
|
||||
|
||||
+10
-5
@@ -156,10 +156,14 @@ load_config() {
|
||||
resolve_key() {
|
||||
load_config
|
||||
local p="${PROVIDER:-gemini}"
|
||||
# Each provider has its own API key — set AI_API_KEY internally for adapters
|
||||
# Provider-specific key wins (leakage guard); legacy shared AI_API_KEY is a
|
||||
# read-only fallback when the provider's own key is empty — load_config/env
|
||||
# already exported it (env-wins), so never overwrite it here.
|
||||
case "$p" in
|
||||
gemini) [ -n "${AI_GEMINI_API_KEY:-}" ] && export AI_API_KEY="$AI_GEMINI_API_KEY" && return 0 ;;
|
||||
openrouter) [ -n "${OPENROUTER_API_KEY:-}" ] && export AI_API_KEY="$OPENROUTER_API_KEY" && return 0 ;;
|
||||
gemini) [ -n "${AI_GEMINI_API_KEY:-}" ] && export AI_API_KEY="$AI_GEMINI_API_KEY" && return 0
|
||||
[ -n "${AI_API_KEY:-}" ] && return 0 ;;
|
||||
openrouter) [ -n "${OPENROUTER_API_KEY:-}" ] && export AI_API_KEY="$OPENROUTER_API_KEY" && return 0
|
||||
[ -n "${AI_API_KEY:-}" ] && return 0 ;;
|
||||
llamacpp) return 0 ;; # No API key needed for local server
|
||||
esac
|
||||
return 1
|
||||
@@ -626,9 +630,10 @@ cmd_providers() {
|
||||
[ -n "$pmodel" ] || pmodel="unknown"
|
||||
# Check if API key exists for this provider
|
||||
configured="not configured"
|
||||
# Same key set as resolve_key(): provider key, or legacy AI_API_KEY fallback
|
||||
case "$name" in
|
||||
gemini) [ -n "${AI_GEMINI_API_KEY:-}" ] && configured="configured" ;;
|
||||
openrouter) [ -n "${OPENROUTER_API_KEY:-}" ] && configured="configured" ;;
|
||||
gemini) { [ -n "${AI_GEMINI_API_KEY:-}" ] || [ -n "${AI_API_KEY:-}" ]; } && configured="configured" ;;
|
||||
openrouter) { [ -n "${OPENROUTER_API_KEY:-}" ] || [ -n "${AI_API_KEY:-}" ]; } && configured="configured" ;;
|
||||
llamacpp) configured="configured" ;; # Local server — always configured
|
||||
esac
|
||||
current=""
|
||||
|
||||
+20
-13
@@ -223,8 +223,8 @@ cmd_unpersist() {
|
||||
# ── Interactive menu flows ─────────────────────────────────────
|
||||
|
||||
# Mountpoint picker — local port of the share_pick primitive with exactly
|
||||
# two deltas: the hint line offers `n=new`, and typing n runs the
|
||||
# create-new-dir flow below. share_pick cannot intercept `n` (it filters on
|
||||
# two deltas: the hint line offers `t=type`, and typing t runs the
|
||||
# manual-entry flow below. share_pick cannot intercept `t` (it filters on
|
||||
# it) and lib/menu-lib.sh is shared, so the fork lives here. Rendering of
|
||||
# numbered picks / text filter / 0=back is byte-identical to menu_pick.
|
||||
# stdout: chosen item text (or the freshly created dir) · rc 1 = back/cancel.
|
||||
@@ -263,16 +263,16 @@ pick_mountpoint() {
|
||||
done
|
||||
fi
|
||||
} >&2
|
||||
if ! read -rp "${prompt} [1-${n}], n=new, text=filter, 0=back " ans; then
|
||||
if ! read -rp "${prompt} [1-${n}], t=type, text=filter, 0=back " ans; then
|
||||
return 1 # EOF — cancel
|
||||
fi
|
||||
case "$ans" in
|
||||
"") [ -z "$filter" ] || filter="" ; continue ;;
|
||||
"/") filter="" ; continue ;;
|
||||
0 | q | Q | b | B) return 1 ;;
|
||||
n | N)
|
||||
made="$(ask_new_mountpoint)" && { echo "$made"; return 0; }
|
||||
continue # declined/invalid/mkdir-failed → redraw
|
||||
t | T)
|
||||
made="$(ask_mountpoint)" && { echo "$made"; return 0; }
|
||||
continue # cancelled/invalid/mkdir-failed → redraw
|
||||
;;
|
||||
*[!0-9]*)
|
||||
filter="$ans"
|
||||
@@ -289,15 +289,16 @@ pick_mountpoint() {
|
||||
done
|
||||
}
|
||||
|
||||
# Create-new-dir flow behind the picker's `n` key. Validates the shape
|
||||
# (absolute, no trailing slash), confirm-gates the creation, then mkdir -p.
|
||||
# Manual entry flow behind the picker's `t` key. Validates the shape
|
||||
# (absolute, no trailing slash, system-path refusal), then branches:
|
||||
# existing dir → use as-is; new dir → confirm-gate the creation + mkdir -p.
|
||||
# Any decline, invalid input, EOF or mkdir failure is a warning + rc 1 —
|
||||
# the picker redraws, the tool never aborts.
|
||||
ask_new_mountpoint() { # stdout: created dir · rc 1 = cancelled/failed
|
||||
ask_mountpoint() { # stdout: absolute path (existing or newly created) · rc 1 = cancelled/failed
|
||||
# NOTE: runs inside $( ) from the picker — every display line MUST go to
|
||||
# stderr (menu-lib contract: display → stderr, result → stdout).
|
||||
local dir
|
||||
dir="$(share_ask_value "New mountpoint (absolute path)")" || return 1
|
||||
dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1
|
||||
case "$dir" in
|
||||
/*) ;;
|
||||
*) warn "'$dir' is not an absolute path — must start with /" >&2; return 1 ;;
|
||||
@@ -311,6 +312,14 @@ ask_new_mountpoint() { # stdout: created dir · rc 1 = cancelled/failed
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
if [ -d "$dir" ]; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
if [ -e "$dir" ]; then
|
||||
warn "$dir exists and is not a directory — pick another mount point" >&2
|
||||
return 1
|
||||
fi
|
||||
confirm "Create mountpoint ${dir}?" n || return 1
|
||||
if ! run sudo mkdir -p "$dir"; then
|
||||
warn "Could not create ${dir}" >&2
|
||||
@@ -371,9 +380,7 @@ menu_ask_mountpoint() { # [server_path] — stdout: absolute path · rc 1 cancel
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1
|
||||
[ -n "$dir" ] || { warn "No mountpoint given"; return 1; }
|
||||
echo "$dir"
|
||||
ask_mountpoint
|
||||
}
|
||||
|
||||
menu_mount() {
|
||||
|
||||
+20
-13
@@ -495,8 +495,8 @@ smb_server_path() { # <host> <share> — stdout: server-side dir · rc 1 = unres
|
||||
}
|
||||
|
||||
# Mountpoint picker — local port of the share_pick primitive with exactly
|
||||
# two deltas: the hint line offers `n=new`, and typing n runs the
|
||||
# create-new-dir flow below. share_pick cannot intercept `n` (it filters on
|
||||
# two deltas: the hint line offers `t=type`, and typing t runs the
|
||||
# manual-entry flow below. share_pick cannot intercept `t` (it filters on
|
||||
# it) and lib/menu-lib.sh is shared, so the fork lives here. Rendering of
|
||||
# numbered picks / text filter / 0=back is byte-identical to menu_pick.
|
||||
# stdout: chosen item text (or the freshly created dir) · rc 1 = back/cancel.
|
||||
@@ -535,16 +535,16 @@ pick_mountpoint() {
|
||||
done
|
||||
fi
|
||||
} >&2
|
||||
if ! read -rp "${prompt} [1-${n}], n=new, text=filter, 0=back " ans; then
|
||||
if ! read -rp "${prompt} [1-${n}], t=type, text=filter, 0=back " ans; then
|
||||
return 1 # EOF — cancel
|
||||
fi
|
||||
case "$ans" in
|
||||
"") [ -z "$filter" ] || filter="" ; continue ;;
|
||||
"/") filter="" ; continue ;;
|
||||
0 | q | Q | b | B) return 1 ;;
|
||||
n | N)
|
||||
made="$(ask_new_mountpoint)" && { echo "$made"; return 0; }
|
||||
continue # declined/invalid/mkdir-failed → redraw
|
||||
t | T)
|
||||
made="$(ask_mountpoint)" && { echo "$made"; return 0; }
|
||||
continue # cancelled/invalid/mkdir-failed → redraw
|
||||
;;
|
||||
*[!0-9]*)
|
||||
filter="$ans"
|
||||
@@ -561,15 +561,16 @@ pick_mountpoint() {
|
||||
done
|
||||
}
|
||||
|
||||
# Create-new-dir flow behind the picker's `n` key. Validates the shape
|
||||
# (absolute, no trailing slash), confirm-gates the creation, then mkdir -p.
|
||||
# Manual entry flow behind the picker's `t` key. Validates the shape
|
||||
# (absolute, no trailing slash, system-path refusal), then branches:
|
||||
# existing dir → use as-is; new dir → confirm-gate the creation + mkdir -p.
|
||||
# Any decline, invalid input, EOF or mkdir failure is a warning + rc 1 —
|
||||
# the picker redraws, the tool never aborts.
|
||||
ask_new_mountpoint() { # stdout: created dir · rc 1 = cancelled/failed
|
||||
ask_mountpoint() { # stdout: absolute path (existing or newly created) · rc 1 = cancelled/failed
|
||||
# NOTE: runs inside $( ) from the picker — every display line MUST go to
|
||||
# stderr (menu-lib contract: display → stderr, result → stdout).
|
||||
local dir
|
||||
dir="$(share_ask_value "New mountpoint (absolute path)")" || return 1
|
||||
dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1
|
||||
case "$dir" in
|
||||
/*) ;;
|
||||
*) warn "'$dir' is not an absolute path — must start with /" >&2; return 1 ;;
|
||||
@@ -583,6 +584,14 @@ ask_new_mountpoint() { # stdout: created dir · rc 1 = cancelled/failed
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
if [ -d "$dir" ]; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
if [ -e "$dir" ]; then
|
||||
warn "$dir exists and is not a directory — pick another mount point" >&2
|
||||
return 1
|
||||
fi
|
||||
confirm "Create mountpoint ${dir}?" n || return 1
|
||||
if ! run sudo mkdir -p "$dir"; then
|
||||
warn "Could not create ${dir}" >&2
|
||||
@@ -624,9 +633,7 @@ menu_ask_mountpoint() { # [server_path] — stdout: absolute path · rc 1 cancel
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1
|
||||
[ -n "$dir" ] || { warn "No mountpoint given"; return 1; }
|
||||
echo "$dir"
|
||||
ask_mountpoint
|
||||
}
|
||||
|
||||
menu_mount() { # ephemeral|persist
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
# API keys (each provider needs its own):
|
||||
# AI_GEMINI_API_KEY=<key> # Gemini API key from aistudio.google.com
|
||||
# OPENROUTER_API_KEY=<key> # OpenRouter API key from openrouter.ai
|
||||
# Legacy fallback: AI_API_KEY=<key> is honored when the provider's own key is unset
|
||||
#
|
||||
# Model overrides (optional, defaults per provider):
|
||||
# AI_GEMINI_MODEL=<model> # Gemini model id (default: gemini-2.5-flash)
|
||||
|
||||
+40
@@ -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 <phase> Skip a phase (repeatable):
|
||||
preinstall, scripts, postinstall, scalepoint, apps
|
||||
--steps <spec> 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
|
||||
|
||||
+4
-1
@@ -42,6 +42,7 @@ silently.
|
||||
| --- | --- |
|
||||
| `t-ai-server-flags.sh` | `pos-ai-server` ExecStart flag set: defaults, CLI, config, dedupe, one-token-per-flag |
|
||||
| `t-ai-hf-download.sh` | `pos ai hf download` success + failure honesty (no `.hf-meta` on partial failure, rc != 0) |
|
||||
| `t-ai-key-resolution.sh` | `pos ai` API-key contract: provider key > legacy `AI_API_KEY` fallback > error; leakage guard, env-wins, providers status sync |
|
||||
| `t-ai-llama-detect.sh` | `pos ai-server status` version detection, "unknown", missing-binary failure |
|
||||
| `t-unsupported-flags.sh` | unsupported-option handling: CLI/config/env hard errors, dropped defaults, word-boundary match |
|
||||
| `t-systemd-unit.sh` | generated unit: one ExecStart, quoted paths, `systemd-analyze verify` |
|
||||
@@ -51,4 +52,6 @@ silently.
|
||||
| `t-config-precedence.sh` | `load_env_file` contract + CLI > env > file > defaults across tools |
|
||||
| `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-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 |
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# t-ai-key-resolution.sh — `pos ai` API-key resolution contract:
|
||||
# provider-specific key (AI_GEMINI_API_KEY / OPENROUTER_API_KEY)
|
||||
# > legacy shared AI_API_KEY fallback > error; llamacpp needs no key.
|
||||
#
|
||||
# Regression for the 7ae2e77 "per-provider keys" migration: AI_API_KEY must be
|
||||
# honored ONLY when the active provider's own key is empty (backward-compat for
|
||||
# howto-taught single-key configs) and must NEVER override a provider key
|
||||
# (cross-provider leakage guard). Also pins env-wins (exported env beats
|
||||
# ai.env) and `pos ai providers` agreeing with resolve_key().
|
||||
#
|
||||
# Seam: `pos ai models` with a curl stub on PATH that records -H headers to a
|
||||
# log and returns deterministic per-provider JSON (no sessions are written by
|
||||
# `models`, unlike ask/chat).
|
||||
|
||||
run_test() {
|
||||
require_cmd jq "pos ai key resolution" || return 0
|
||||
|
||||
local sandbox cfg home stubs curl_log
|
||||
sandbox="$(mksandbox ai-key-resolution)"
|
||||
cfg="$sandbox/cfg"
|
||||
home="$sandbox/home"
|
||||
stubs="$sandbox/stubs"
|
||||
mkdir -p "$cfg" "$home" "$stubs"
|
||||
curl_log="$sandbox/curl.log"
|
||||
: > "$curl_log"
|
||||
: > "$cfg/ai.env"
|
||||
|
||||
# curl stub: log every -H <header> pair; reply with provider-shaped JSON +
|
||||
# the trailing \n%{http_code} line the adapters' --write-out expects.
|
||||
cat > "$stubs/curl" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
all="\$*"
|
||||
while [ \$# -gt 0 ]; do
|
||||
case "\$1" in
|
||||
-H) printf 'HDR %s\n' "\$2" >> "$curl_log"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
case "\$all" in
|
||||
*generativelanguage.googleapis.com*)
|
||||
printf '%s\n200' '{"models":[{"name":"models/gemini-2.5-flash","supportedGenerationMethods":["generateContent"]}]}' ;;
|
||||
*openrouter.ai*)
|
||||
printf '%s\n200' '{"data":[{"id":"openrouter/auto"}]}' ;;
|
||||
*)
|
||||
printf '%s\n200' '{"data":[{"id":"loaded-model"}]}' ;;
|
||||
esac
|
||||
STUB
|
||||
chmod +x "$stubs/curl"
|
||||
|
||||
local posai="$ROOT/bin/pos-ai"
|
||||
# Hermetic env: `env -i` drops every inherited variable (incl. any ambient
|
||||
# AI_*), so each case exercises exactly the values declared below.
|
||||
local path_env=(-i PATH="$stubs:/usr/bin:/bin" CONFIG_FILE="$cfg/ai.env" HOME="$home")
|
||||
|
||||
# ── Case 1: gemini via provider key only ──
|
||||
printf 'AI_GEMINI_API_KEY=GEMFILEKEY\n' > "$cfg/ai.env"
|
||||
test_run_env "${path_env[@]}" -- "$posai" models
|
||||
check_rc "C1 gemini via AI_GEMINI_API_KEY works" 0 "$TR_RC"
|
||||
check_contains "C1 gemini header carries provider key" "x-goog-api-key: GEMFILEKEY" "$(cat "$curl_log")"
|
||||
|
||||
# ── Case 2: gemini via legacy AI_API_KEY only (core regression) ──
|
||||
: > "$curl_log"
|
||||
printf 'AI_API_KEY=AIKEYONLY\n' > "$cfg/ai.env"
|
||||
test_run_env "${path_env[@]}" -- "$posai" models
|
||||
check_rc "C2 gemini via legacy AI_API_KEY works" 0 "$TR_RC"
|
||||
check_contains "C2 gemini header carries legacy key" "x-goog-api-key: AIKEYONLY" "$(cat "$curl_log")"
|
||||
|
||||
# ── Case 3: gemini with both → provider key wins (leakage guard) ──
|
||||
: > "$curl_log"
|
||||
printf 'AI_GEMINI_API_KEY=GEMWIN\nAI_API_KEY=AIKEYLOSE\n' > "$cfg/ai.env"
|
||||
test_run_env "${path_env[@]}" -- "$posai" models
|
||||
check_rc "C3 both set still works" 0 "$TR_RC"
|
||||
check_contains "C3 provider key wins" "x-goog-api-key: GEMWIN" "$(cat "$curl_log")"
|
||||
check_not_contains "C3 shared key not leaked" "AIKEYLOSE" "$(cat "$curl_log")"
|
||||
|
||||
# ── Case 4: openrouter via provider key only ──
|
||||
: > "$curl_log"
|
||||
printf 'OPENROUTER_API_KEY=ORFILEKEY\n' > "$cfg/ai.env"
|
||||
test_run_env "${path_env[@]}" -- "$posai" --provider openrouter models
|
||||
check_rc "C4 openrouter via OPENROUTER_API_KEY works" 0 "$TR_RC"
|
||||
check_contains "C4 openrouter auth carries provider key" "Authorization: Bearer ORFILEKEY" "$(cat "$curl_log")"
|
||||
|
||||
# ── Case 5: openrouter via legacy AI_API_KEY only ──
|
||||
: > "$curl_log"
|
||||
printf 'AI_API_KEY=AIKEYONLY\n' > "$cfg/ai.env"
|
||||
test_run_env "${path_env[@]}" -- "$posai" --provider openrouter models
|
||||
check_rc "C5 openrouter via legacy AI_API_KEY works" 0 "$TR_RC"
|
||||
check_contains "C5 openrouter auth carries legacy key" "Authorization: Bearer AIKEYONLY" "$(cat "$curl_log")"
|
||||
|
||||
# ── Case 6: openrouter with both → provider key wins ──
|
||||
: > "$curl_log"
|
||||
printf 'OPENROUTER_API_KEY=ORWIN\nAI_API_KEY=AIKEYLOSE\n' > "$cfg/ai.env"
|
||||
test_run_env "${path_env[@]}" -- "$posai" --provider openrouter models
|
||||
check_rc "C6 both set still works" 0 "$TR_RC"
|
||||
check_contains "C6 openrouter provider key wins" "Authorization: Bearer ORWIN" "$(cat "$curl_log")"
|
||||
check_not_contains "C6 shared key not leaked" "AIKEYLOSE" "$(cat "$curl_log")"
|
||||
|
||||
# ── Case 7: env-wins — exported provider key beats ai.env file value ──
|
||||
: > "$curl_log"
|
||||
printf 'AI_GEMINI_API_KEY=FILEVALUE\n' > "$cfg/ai.env"
|
||||
test_run_env "${path_env[@]}" AI_GEMINI_API_KEY=ENVVALUE -- "$posai" models
|
||||
check_rc "C7 env-wins run works" 0 "$TR_RC"
|
||||
check_contains "C7 exported env key used" "x-goog-api-key: ENVVALUE" "$(cat "$curl_log")"
|
||||
check_not_contains "C7 file value suppressed by env" "FILEVALUE" "$(cat "$curl_log")"
|
||||
|
||||
# ── Case 8: llamacpp needs no key ──
|
||||
: > "$curl_log"
|
||||
: > "$cfg/ai.env"
|
||||
test_run_env "${path_env[@]}" -- "$posai" --provider llamacpp models
|
||||
check_rc "C8 llamacpp works with no key" 0 "$TR_RC"
|
||||
check_contains "C8 llamacpp lists models" "Local llama.cpp models:" "$TR_OUT"
|
||||
|
||||
# ── Case 9: missing key → rc 1 + byte-stable message ──
|
||||
test_run_env "${path_env[@]}" -- "$posai" models
|
||||
check_rc "C9 missing key exits nonzero" 1 "$TR_RC"
|
||||
check_contains "C9 stable error message" "No Gemini API key — run 'pos config ai' and set AI_GEMINI_API_KEY" "$TR_OUT"
|
||||
|
||||
# ── Case 10: providers status agrees with resolve_key (AI_API_KEY only) ──
|
||||
local prov
|
||||
printf 'AI_API_KEY=SHAREDKEY\n' > "$cfg/ai.env"
|
||||
test_run_env "${path_env[@]}" -- "$posai" providers
|
||||
prov="$(printf '%s\n' "$TR_OUT" | grep configured || true)"
|
||||
check_contains "C10 gemini row shows configured" "gemini" "$prov"
|
||||
check_contains "C10 openrouter row shows configured" "openrouter" "$prov"
|
||||
check_not_contains "C10 no 'not configured' row" "not configured" "$TR_OUT"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# t-share-mountpoint.sh — permanent regression for the share-client
|
||||
# ask_mountpoint() manual-entry flow (2026-09-07 `t=type` UX change in
|
||||
# bin/pos-share-smb-client + bin/pos-share-nfs-client; byte-identical bodies).
|
||||
#
|
||||
# Behaviour contract under test (both clients):
|
||||
# existing dir → printed as-is, rc 0, NO confirm, NO mkdir
|
||||
# new dir + y → confirm gate passes, sudo mkdir -p, dir created, rc 0
|
||||
# new dir + n → rc 1, nothing created (confirm EOF also denies)
|
||||
# existing file → rc 1 + "not a directory" warning
|
||||
# relative / trailing-slash / system paths / empty-EOF → rc 1, no side effects
|
||||
# mkdir failure → rc 1 + "Could not create" warning
|
||||
# stream → display on stderr, result on stdout
|
||||
#
|
||||
# Strategy (production logic is never re-typed): the REAL ask_mountpoint body
|
||||
# is brace-extracted verbatim from each client file (extract_fn, same pattern
|
||||
# as t-menu-allow-empty.sh / t-ai-server-validate.sh) and sourced; the REAL
|
||||
# helper chain lib/common.sh (warn/confirm/run/log) + lib/share-lib.sh
|
||||
# (share_ask_value → menu_ask_value → menu_read_value) is exercised through
|
||||
# the deterministic NON-TTY stdin path (stty -g fails on a pipe → plain
|
||||
# IFS= read, common.sh:181-186). Input is fed as `printf '%b' | fn`, exactly
|
||||
# like t-menu-allow-empty.sh feeds menu_ask_value. A fake `sudo` on PATH
|
||||
# records every invocation and honors SUDO_FAIL, so the sandbox never touches
|
||||
# the real system. NOTE: bash suppresses read -rp prompt text on a pipe, so
|
||||
# the confirm prompt string is never asserted — the confirm gate is proven
|
||||
# behaviourally (feed y → created; feed n → rc 1; missing answer → EOF-deny
|
||||
# rc 1) and by SUDO_LOG (mkdir attempted or not).
|
||||
|
||||
# extract_fn <source-file> <fnname> — print one brace-delimited function body.
|
||||
extract_fn() {
|
||||
local file="$1" fn="$2"
|
||||
awk -v fn="$fn" '
|
||||
BEGIN { found=0; depth=0 }
|
||||
{
|
||||
if (!found && $0 ~ ("^" fn "\\(\\)")) { found=1; depth=0 }
|
||||
if (found) {
|
||||
n_open = gsub(/\{/, "{")
|
||||
n_close = gsub(/\}/, "}")
|
||||
depth = depth + n_open - n_close
|
||||
print
|
||||
if (depth <= 0) exit
|
||||
}
|
||||
}
|
||||
' "$file"
|
||||
}
|
||||
|
||||
run_test() {
|
||||
source "$ROOT/lib/common.sh" # warn / confirm / run / log
|
||||
source "$ROOT/lib/share-lib.sh" # share_ask_value → REAL menu_ask_value
|
||||
|
||||
local sandbox stubs
|
||||
sandbox="$(mksandbox share-mountpoint)"
|
||||
stubs="$sandbox/stubs"
|
||||
mkdir -p "$stubs"
|
||||
|
||||
# Fake sudo: records every invocation to $SUDO_LOG; exits 1 (no side
|
||||
# effect) when SUDO_FAIL=1, otherwise `exec "$@"` (real mkdir) so the
|
||||
# confirmed-create case genuinely creates the dir inside the sandbox.
|
||||
cat > "$stubs/sudo" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
printf 'sudo %s\n' "$*" >> "${SUDO_LOG:?fake sudo needs SUDO_LOG}"
|
||||
if [ "${SUDO_FAIL:-0}" = "1" ]; then
|
||||
printf 'sudo: permission denied (fake)\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
[ $# -gt 0 ] && exec "$@"
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$stubs/sudo"
|
||||
export PATH="$stubs:$PATH"
|
||||
export SUDO_LOG="$sandbox/sudo.log"
|
||||
export SUDO_FAIL=0
|
||||
: > "$SUDO_LOG"
|
||||
|
||||
# ═══ Part A0: non-TTY stdin contract of the real reader chain ═══
|
||||
# The whole matrix below is only deterministic if menu_read_value falls
|
||||
# back to plain `IFS= read` on a pipe and confirm() also reads plain
|
||||
# stdin. Prove both against the REAL helpers.
|
||||
local probe_out probe_rc
|
||||
set +e
|
||||
probe_out="$(printf 'x\n' | menu_read_value "probe" 2>/dev/null)"; probe_rc=$?
|
||||
set -e
|
||||
check_rc "non-TTY: menu_read_value pipe → rc 0 (plain-read fallback)" 0 "$probe_rc"
|
||||
check_eq "non-TTY: menu_read_value pipe → value" "x" "$probe_out"
|
||||
|
||||
set +e
|
||||
printf '' | stty -g >/dev/null 2>&1; probe_rc=$?
|
||||
set -e
|
||||
check_rc "non-TTY: stty -g fails on a pipe (plain-read path active)" 1 "$probe_rc"
|
||||
|
||||
set +e
|
||||
printf 'y\n' | confirm probe n >/dev/null 2>&1; probe_rc=$?
|
||||
set -e
|
||||
check_rc "non-TTY: confirm y → rc 0" 0 "$probe_rc"
|
||||
set +e
|
||||
printf 'n\n' | confirm probe n >/dev/null 2>&1; probe_rc=$?
|
||||
set -e
|
||||
check_rc "non-TTY: confirm n → rc 1" 1 "$probe_rc"
|
||||
set +e
|
||||
printf '' | confirm probe n >/dev/null 2>&1; probe_rc=$?
|
||||
set -e
|
||||
check_rc "non-TTY: confirm EOF → rc 1 (fail-closed)" 1 "$probe_rc"
|
||||
|
||||
# ═══ Part A/B runner over the REAL extracted ask_mountpoint ═══
|
||||
# mp_case <prefix> <desc> <feed> <expect_rc> <expect_out>
|
||||
# [<stderr-needle> [<stderr-absent> [<sudo_fail>]]]
|
||||
# feed = literal bytes: line 1 = mountpoint, line 2 (when present) = the
|
||||
# confirm answer. SUDO_LOG resets per case; side effects asserted by the
|
||||
# caller. SUDO_FAIL applies to the ask_mountpoint call only (assignment
|
||||
# prefix on the pipeline element; exported attribute carries it to the
|
||||
# fake sudo child process).
|
||||
mp_case() {
|
||||
local prefix="$1" desc="$2" feed="$3" expect_rc="$4" expect_out="$5"
|
||||
local needle="${6:-}" absent="${7:-}" failflag="${8:-0}"
|
||||
local out rc errf="$sandbox/mp.err"
|
||||
: > "$SUDO_LOG"
|
||||
set +e
|
||||
out="$(printf '%b' "$feed" | SUDO_FAIL="$failflag" ask_mountpoint 2>"$errf")"
|
||||
rc=$?
|
||||
set -e
|
||||
check_rc "$prefix: $desc (rc)" "$expect_rc" "$rc"
|
||||
check_eq "$prefix: $desc (stdout)" "$expect_out" "$out"
|
||||
if [ -n "$needle" ]; then
|
||||
check_contains "$prefix: $desc (stderr)" "$needle" "$(cat "$errf")"
|
||||
fi
|
||||
if [ -n "$absent" ]; then
|
||||
check_not_contains "$prefix: $desc (stderr)" "$absent" "$(cat "$errf")"
|
||||
fi
|
||||
}
|
||||
|
||||
local client file
|
||||
for client in smb nfs; do
|
||||
case "$client" in
|
||||
smb) file="$ROOT/bin/pos-share-smb-client" ;;
|
||||
nfs) file="$ROOT/bin/pos-share-nfs-client" ;;
|
||||
esac
|
||||
|
||||
local fn_file="$sandbox/ask_mountpoint-$client.sh"
|
||||
extract_fn "$file" ask_mountpoint > "$fn_file"
|
||||
source "$fn_file" # REAL body — a syntax error here aborts (FAIL, never silent)
|
||||
|
||||
local cdir="$sandbox/$client"
|
||||
mkdir -p "$cdir/existing"
|
||||
touch "$cdir/file"
|
||||
|
||||
# 1. existing dir → as-is, rc 0. Feed has NO y/n line, so reaching the
|
||||
# confirm gate would hit EOF → rc 1; rc 0 + empty SUDO_LOG proves
|
||||
# the confirm/mkdir branch was never entered.
|
||||
mp_case "$client" "existing dir → used as-is, no create" \
|
||||
"$cdir/existing\n" 0 "$cdir/existing" "" "Created mount point"
|
||||
check_eq "$client: existing dir → sudo NOT attempted" "" "$(cat "$SUDO_LOG")"
|
||||
check_file_exists "$client: existing dir → fixture intact" "$cdir/existing"
|
||||
|
||||
# 2. new dir + confirm y → sudo mkdir -p, dir created, printed, logged
|
||||
mp_case "$client" "new dir confirm=y → rc 0 + created" \
|
||||
"$cdir/newdir\ny\n" 0 "$cdir/newdir" "Created mount point"
|
||||
check_contains "$client: new dir → sudo mkdir -p attempted" \
|
||||
"sudo mkdir -p $cdir/newdir" "$(cat "$SUDO_LOG")"
|
||||
check_file_exists "$client: new dir → created" "$cdir/newdir"
|
||||
|
||||
# 3. new dir + confirm n → rc 1, nothing created
|
||||
mp_case "$client" "new dir confirm=n → rc 1" \
|
||||
"$cdir/declined\nn\n" 1 "" "" ""
|
||||
check_eq "$client: declined → sudo NOT attempted" "" "$(cat "$SUDO_LOG")"
|
||||
check_file_absent "$client: declined → no dir created" "$cdir/declined"
|
||||
|
||||
# 4. new dir + confirm EOF → rc 1 (gate fails closed)
|
||||
mp_case "$client" "new dir confirm=EOF → rc 1 (fail-closed)" \
|
||||
"$cdir/eof\n" 1 "" "" ""
|
||||
check_eq "$client: confirm-EOF → sudo NOT attempted" "" "$(cat "$SUDO_LOG")"
|
||||
check_file_absent "$client: confirm-EOF → no dir created" "$cdir/eof"
|
||||
|
||||
# 5. existing non-directory → rc 1 + not-a-directory warning
|
||||
mp_case "$client" "existing file → rc 1 not-a-directory" \
|
||||
"$cdir/file\n" 1 "" "not a directory" ""
|
||||
check_eq "$client: file → sudo NOT attempted" "" "$(cat "$SUDO_LOG")"
|
||||
|
||||
# 6. relative path → rc 1, warned, no write
|
||||
mp_case "$client" "relative path → rc 1" \
|
||||
"relative/path\n" 1 "" "not an absolute path" ""
|
||||
check_eq "$client: relative → sudo NOT attempted" "" "$(cat "$SUDO_LOG")"
|
||||
|
||||
# 7. trailing slash → rc 1, warned, no write
|
||||
mp_case "$client" "trailing slash → rc 1" \
|
||||
"$cdir/existing/\n" 1 "" "must not end with a slash" ""
|
||||
check_eq "$client: trailing slash → sudo NOT attempted" "" "$(cat "$SUDO_LOG")"
|
||||
|
||||
# 8. system paths → rc 1 (shape glob fires before any FS access)
|
||||
mp_case "$client" "system path /etc → rc 1" "/etc\n" 1 "" "Refusing system path" ""
|
||||
mp_case "$client" "system path /root → rc 1" "/root\n" 1 "" "Refusing system path" ""
|
||||
mp_case "$client" "system path /home/*/.ssh* → rc 1" \
|
||||
"/home/ci-user/.ssh/authorized_keys\n" 1 "" "Refusing system path" ""
|
||||
check_eq "$client: system paths → sudo NEVER attempted" "" "$(cat "$SUDO_LOG")"
|
||||
|
||||
# 9. empty/EOF on the path input → rc 1 (share_ask_value cancel)
|
||||
mp_case "$client" "EOF on path → rc 1" "" 1 "" "" ""
|
||||
check_eq "$client: EOF → sudo NOT attempted" "" "$(cat "$SUDO_LOG")"
|
||||
|
||||
# 10. mkdir failure (sudo shim fails) → rc 1 + could-not-create warning
|
||||
mp_case "$client" "mkdir fails → rc 1" \
|
||||
"$cdir/failmkdir\ny\n" 1 "" "Could not create" "" 1
|
||||
check_file_absent "$client: mkdir-fail → no dir created" "$cdir/failmkdir"
|
||||
|
||||
# ═══ Part B: static guards — the old n=new flow must not regress ═══
|
||||
local thint=0 tarm=0
|
||||
grep -q 't=type' "$file" && thint=1
|
||||
grep -q 't | T)' "$file" && tarm=1
|
||||
check_eq "$client: t=type hint present" 1 "$thint"
|
||||
check_eq "$client: t | T arm present" 1 "$tarm"
|
||||
check_eq "$client: no n=new hint" 0 "$(grep -c 'n=new' "$file" || true)"
|
||||
check_eq "$client: no n | N arm" 0 "$(grep -c 'n | N)' "$file" || true)"
|
||||
check_eq "$client: no ask_new_mountpoint" 0 "$(grep -c 'ask_new_mountpoint' "$file" || true)"
|
||||
|
||||
# Behavioural contract: the existing-dir branch must precede the
|
||||
# confirm/mkdir create flow inside the extracted body, so an existing
|
||||
# dir can never reach confirm/mkdir.
|
||||
check_eq "$client: ask_mountpoint has -d branch" 1 \
|
||||
"$(grep -cF '[ -d "$dir" ]' "$fn_file" || true)"
|
||||
check_eq "$client: ask_mountpoint has create flow" 1 \
|
||||
"$(grep -cF 'confirm "Create mountpoint' "$fn_file" || true)"
|
||||
local dline cline order=0
|
||||
dline="$(grep -nF '[ -d "$dir" ]' "$fn_file" | head -1 | cut -d: -f1 || true)"
|
||||
cline="$(grep -nF 'confirm "Create mountpoint' "$fn_file" | head -1 | cut -d: -f1 || true)"
|
||||
[ -n "$dline" ] && [ -n "$cline" ] && [ "$dline" -lt "$cline" ] && order=1
|
||||
check_eq "$client: existing-dir branch precedes confirm/mkdir" 1 "$order"
|
||||
|
||||
unset -f ask_mountpoint
|
||||
done
|
||||
|
||||
# ═══ Part C: symmetry — the two shipped bodies must stay identical ═══
|
||||
local same=0
|
||||
cmp -s "$sandbox/ask_mountpoint-smb.sh" "$sandbox/ask_mountpoint-nfs.sh" && same=1
|
||||
check_eq "ask_mountpoint bodies byte-identical (smb == nfs)" 1 "$same"
|
||||
}
|
||||
Reference in New Issue
Block a user