From 5ef38dc46f0eadbd26a0c21f9f1c2781d9eafb63 Mon Sep 17 00:00:00 2001 From: he Date: Fri, 14 Aug 2026 12:57:00 -0400 Subject: [PATCH] fix: resolve all 23 MAINTENANCE audit tickets - deps guards before -h|--help in docker-health/ps, network-scan, usb-server, media-mp3/mp4 (--dry-run pre-scan kept); system-firewall gains usage()/--help; autostart/usb-automount get flags.sh + template - install.sh: normalize N-M range syntax in --steps - bin/pos: INTERACTIVE_CMDS += docker-compose docker-vbox network-hotspot - common.sh: canonical XDG-aware CONFIG_DIR + DIM color var; notify.sh stderr fallback; ent_plugin_* registry renames (runtime plugin API kept) - docker-compose SCALE_DIR/CONFIG_ENV env seams; ffmpeg in PACKAGES; scrcpy.sh exec bit - docs: health is console-only (--send/--markdown removed), POS.md file refs for config/tree/entertainment, DEV.md no-guard exception, docmap/ filetable regenerated (make gen), hand-maintained line rows bumped - add scripts/lint-conventions.sh gate + Makefile lint target; record all VERIFIED outcomes in MAINTENANCE.md; AGENT_TODO Done entry (2026-08-14) - gates: make gen/check/lint all green (0 FAIL, 0 WARN); bash -n sweep clean; restricted-PATH dep tests + step-matrix dry-runs verified --- AGENT_TODO.md | 4 +- DOC/AGENT_Context_Project.md | 26 +- DOC/DEV.md | 1 + DOC/HOWTO.md | 4 +- DOC/POS.md | 14 +- DOC/howto/communication.md | 18 +- DOC/howto/system.md | 21 +- MAINTENANCE.md | 312 ++++++++++++++++++++++++ Makefile | 6 +- apps/media/scrcpy.sh | 0 bin/pos | 2 +- bin/pos-communication-matrix-listener | 2 +- bin/pos-communication-matrix-sender | 2 +- bin/pos-communication-telegram-listener | 2 +- bin/pos-communication-telegram-sender | 2 +- bin/pos-docker-compose | 4 +- bin/pos-docker-health | 7 +- bin/pos-docker-ps | 7 +- bin/pos-entertainment-config | 10 +- bin/pos-entertainment-enable | 4 +- bin/pos-entertainment-send | 4 +- bin/pos-entertainment-status | 2 +- bin/pos-media-mp3 | 20 +- bin/pos-media-mp4 | 20 +- bin/pos-network-download | 1 - bin/pos-network-scan | 11 +- bin/pos-share-usb-server | 6 +- bin/pos-system-firewall | 17 ++ features/autostart.sh | 36 +++ features/usb-automount.sh | 4 + install.sh | 27 +- lib/common.sh | 9 +- lib/config-ui.sh | 4 +- lib/entertainment-lib.sh | 30 +-- lib/notify.sh | 4 +- preinstall.sh | 1 + scripts/lint-conventions.sh | 261 ++++++++++++++++++++ 37 files changed, 791 insertions(+), 114 deletions(-) create mode 100644 MAINTENANCE.md mode change 100644 => 100755 apps/media/scrcpy.sh create mode 100755 scripts/lint-conventions.sh diff --git a/AGENT_TODO.md b/AGENT_TODO.md index 3323d9f..621d500 100644 --- a/AGENT_TODO.md +++ b/AGENT_TODO.md @@ -16,6 +16,8 @@ summary (newest last). ## Done +- **2026-08-14** — Convention-drift maintenance fix session (completed the audit backlog `MAINTENANCE.md`, M-001..M-023, all VERIFIED; gate `scripts/lint-conventions.sh` + `make lint` now 0 FAIL / 0 WARN; `make gen && make check` green). P0 bugs: M-002/003/004 added `docker-compose docker-vbox network-hotspot` to `INTERACTIVE_CMDS` (stdin/log-pipe prompt swallow); M-005 `install.sh --steps` now expands documented `N-M` ranges via `normalize_steps_spec()` (dry-run verified); M-006 feature-vs-docs decision: `--send`/`--markdown` **not restored** (health is a console-only reporter by design since fe7708f; scheduler `NOTIFY=always` covers delivery) — 5 docs corrected instead; M-007 `lib/notify.sh:57` fallback routed to stderr (stdout-leak on standalone source). P1: M-008..M-014 deps guards moved **before** `-h|--help` in docker-health/docker-ps (converted to `command -v X || err`), network-scan, share-usb-server, media-mp3/mp4 (guards before help with a `--dry-run` pre-scan preserving the documented no-deps preview); system-health documented as the sanctioned graceful-degradation no-guard pattern in DEV.md — lint refined accordingly (`first_guard_line` only matches real guards; `first_line` skips comments; precision fixes, not weakenings); M-015 system-firewall gained `usage()`+`-h|--help` (root-gated first; verified via sudo); M-016 `ffmpeg` added to preinstall PACKAGES. P2: M-017/M-018 autostart + usb-automount gained the feature-template preamble (flags.sh load, usage); M-019 `chmod +x apps/media/scrcpy.sh`; M-020 `SCALE_DIR`/`CONFIG_ENV` `:-` seams in pos-docker-compose (verified via overrides; follow-on fix: `DIM` color var missing from common.sh crashed `pos docker compose config` — added it); M-021 `CONFIG_DIR` centralized as the canonical XDG-aware seam in common.sh, per-file duplicates dropped (standalone-sourced notify.sh/config-ui.sh/matrix+telegram tools keep an identical guarded copy — "no shared lib? inline fallbacks"); M-022 `plugin_*` prefix collision resolved by renaming the internal registry helpers to `ent_plugin_*` (the documented plugin-authoring API `plugin_have`/`plugin_require`/`plugin_load_config`/`plugin_http_json` kept for user plugins); M-023 six tools (pos-config, pos-tree, pos-entertainment-{config,enable,disable,status}) now filename-referenced in DOC/POS.md. Hand-maintained AGENT_Context line-count rows bumped (install.sh 223→248, preinstall 75→76, common.sh 144→151, notify.sh 76→87 stale-corrected, autostart 14→50, usb-automount 134→138); `make lint` target wired in the Makefile. `MAINTENANCE.md` kept as the working record (uncommitted by design). + - **2026-08-13** — `pos communication scrcpy` audio control: scrcpy already forwards device audio to the desktop by default (answer: yes, default is sound-to-desktop). Added `SCRCPY_AUDIO` config key (`pos config scrcpy`, default `true`): `false`/`no`/`0` → `--no-audio`, `true`/`yes`/`1` → nothing (default), anything else → error. Docs: POS_CONFIG header, POS.md config table, howto/communication.md Mirror section, HOWTO.md env row. Verified: harness +7 tests (47/47 green — false/true/yes/0/invalid/combined-order), `bash -n`, `make gen && make check` green. - **2026-08-13** — `pos communication scrcpy --new-display` support: new `SCRCPY_NEW_DISPLAY` config key (`pos config scrcpy`) — `true`/`yes` → bare `--new-display` (default size/dpi), `1920x1080`, `1920x1080/420` or `/240` → `--new-display=`; inline validation in `_mirror` (err runs in the main shell, not a process-substitution subshell) rejects anything else with the accepted forms. Docs: POS_CONFIG header, POS.md command+config tables, howto/communication.md Mirror section, HOWTO.md env row, usage() example. CLI pass-through `pos communication scrcpy --new-display=1920x1080` also works verbatim. Verified: harness +8 tests (40/40 green — WxH, true, WxH/DPI, /DPI, invalid-rejected, env>config, combined order), `bash -n`, `make gen && make check` green. @@ -84,8 +86,6 @@ summary (newest last). ## Now -- (none — Tier 1 shipped: `pos system health`, `lib/notify.sh`, digest timer) - ## Next - Wire alerting into more tools as they are added (default: source diff --git a/DOC/AGENT_Context_Project.md b/DOC/AGENT_Context_Project.md index 41e0ed6..2070b59 100644 --- a/DOC/AGENT_Context_Project.md +++ b/DOC/AGENT_Context_Project.md @@ -562,12 +562,12 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | File | Lines | Purpose | |------|-------|---------| -| `install.sh` | 223 | Main orchestrator — 4 phases with CLI flags, `--feature`, prebuilt arch bins | -| `preinstall.sh` | 75 | System packages + hotspot deps + yt-dlp + fail2ban | +| `install.sh` | 248 | Main orchestrator — 4 phases with CLI flags, `--feature`, 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` | 144 | Shared library (log/warn/err/run/spawn, dry-run aware, `load_system_env`) | +| `lib/common.sh` | 151 | Shared library (log/warn/err/run/spawn, dry-run aware, `load_system_env`, CONFIG_DIR) | | `lib/flags.sh` | 60 | Feature flag store (set/clear/is_set/value/list/status) | -| `lib/notify.sh` | 76 | Multi-platform alerting (`notify_send`) — opt-in source, silent-fails | +| `lib/notify.sh` | 87 | Multi-platform alerting (`notify_send`) — opt-in source, silent-fails | | `lib/entertainment-lib.sh` | 311 | Entertainment module lib (ENABLED parsing, last-run state, scheduler sync via user-timers-lib) | | `lib/entertainment-plugin-lib.sh` | 67 | Message-safe helpers for plugins (config load, require, fetch+retry) — plugins MAY source it | | `lib/scheduler-lib.sh` | 760 | Scheduler lib (job parsing, notify policies, per-job user timers via user-timers-lib, legacy migrate) | @@ -575,8 +575,8 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | `bin/flag-reader` | 58 | Inspect flags (list/status/`--raw`) | | `bin/flag-set` | 21 | Set a flag (optionally with a value) | | `bin/flag-clear` | 21 | Unset a flag | -| `features/autostart.sh` | 14 | Boot-time feature (moved from `bin/`, flag-gated service) | -| `features/usb-automount.sh` | 134 | USB automount feature (udev rule + flag-gated service) | +| `features/autostart.sh` | 50 | Boot-time feature (moved from `bin/`, flag-gated service) | +| `features/usb-automount.sh` | 138 | USB automount feature (udev rule + flag-gated service) | | `bin/pos` | 292 | CLI dispatcher with smart arg matching + logging + category help | | `bin/pos-ai-gemini` | 311 | Chat with Google Gemini (ask, chat, models, sessions) | @@ -587,21 +587,21 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | `bin/pos-communication-telegram-sender` | 221 | Send Telegram messages/files/links/stickers via Bot API (send, test) | | `bin/pos-config` | 80 | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) | | `bin/pos-docker-compose` | 366 | Docker Compose service manager (ls/up/down/restart/logs/update/config) | -| `bin/pos-docker-health` | 110 | One-glance container health dashboard (exits 1 if unhealthy) | -| `bin/pos-docker-ps` | 128 | Enhanced container overview (health, IPs, ports, uptime) | +| `bin/pos-docker-health` | 107 | One-glance container health dashboard (exits 1 if unhealthy) | +| `bin/pos-docker-ps` | 125 | Enhanced container overview (health, IPs, ports, uptime) | | `bin/pos-docker-vbox` | 158 | Disposable Docker-based VMs (create/enter/start/stop/rm/ls) | | `bin/pos-entertainment-config` | 143 | Show or edit the entertainment config (ENABLED auto-trigger list, weather location) | | `bin/pos-entertainment-disable` | 32 | Disable a plugin's auto-trigger (remove it from ENABLED) | | `bin/pos-entertainment-enable` | 49 | Enable an auto-trigger for a plugin on a schedule | | `bin/pos-entertainment-send` | 95 | Run a public-API plugin and send its output via the configured notify platforms | | `bin/pos-entertainment-status` | 62 | Show enabled plugins and scheduler state | -| `bin/pos-media-mp3` | 80 | Download audio as MP3 (yt-dlp) | -| `bin/pos-media-mp4` | 126 | Download video as MP4 (smart/interactive format select) | +| `bin/pos-media-mp3` | 86 | Download audio as MP3 (yt-dlp) | +| `bin/pos-media-mp4` | 132 | Download video as MP4 (smart/interactive format select) | | `bin/pos-network-checkport` | 496 | Check TCP/UDP port reachability (nmap, or bash/nc fallback) + local interface view | -| `bin/pos-network-download` | 952 | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) | +| `bin/pos-network-download` | 951 | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) | | `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` | 271 | Parallel ping sweep of CIDR | +| `bin/pos-network-scan` | 272 | Parallel ping sweep of CIDR | | `bin/pos-share-nfs-client` | 138 | Mount NFS shares (ephemeral or persistent systemd mount units) | | `bin/pos-share-nfs-server` | 134 | Manage the NFS kernel server (status, share/unshare exports, enable/disable) | | `bin/pos-share-smb-client` | 217 | Mount SMB/CIFS shares (ephemeral or persistent systemd mount units) | @@ -609,7 +609,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | `bin/pos-share-usb-server` | 218 | USB Redirector server control (--ls, --share; prompts when args omitted) | | `bin/pos-ssh-load-keys` | 31 | Load all SSH keys into the agent | | `bin/pos-system-backup` | 220 | Encrypted (AES-256) folder snapshots (tar + gpg) | -| `bin/pos-system-firewall` | 291 | Interactive UFW management | +| `bin/pos-system-firewall` | 308 | Interactive UFW management | | `bin/pos-system-health` | 209 | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL | | `bin/pos-system-schedule` | 81 | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently | | `bin/pos-tree` | 112 | Show the pos CLI command tree: categories, commands, and subcommands | diff --git a/DOC/DEV.md b/DOC/DEV.md index f6df918..1142858 100644 --- a/DOC/DEV.md +++ b/DOC/DEV.md @@ -108,6 +108,7 @@ esac - Strict mode: `set -euo pipefail` - `--help` flag: accept `-h` / `--help` via `case` pattern - **Deps guards run before `--help`:** `command -v &>/dev/null || err "… (install )"` lines sit at the top of the script, **before** the `-h|--help` case — so `--help` also errors when a dependency is missing. This matches every existing deps-gated tool; keep it that way. + - Exception — tools with **no required deps** (every check degrades gracefully): `pos system health` probes binaries at runtime (`if command -v systemctl; then …`) and needs no guard. The lint (`scripts/lint-conventions.sh`) only enforces guard-before-help for lines that are actual guards (`command -v … ||`, `if ! command -v`, `command -v … \` continuation), never for graceful-degradation probes. If you add a tool like this, keep all checks optional and note it in `usage()`. - Shared library: always source `common.sh` for colors, logging, spinners - Exit codes: `0` success, `1` error - No shared lib? Inline fallbacks: diff --git a/DOC/HOWTO.md b/DOC/HOWTO.md index c55b1ed..d43776c 100644 --- a/DOC/HOWTO.md +++ b/DOC/HOWTO.md @@ -71,8 +71,8 @@ Adding another platform = create `bin/pos-communication-

` implementing ### Scheduling -- **Daily health digest** — add a `daily` schedule job `pos system health --send --markdown` - via `pos system schedule config` (the old `pos-health.{service,timer}` units are gone). See [system](howto/system.md). +- **Daily health digest** — add a `daily` schedule job `COMMAND=pos system health` with + `NOTIFY=always` via `pos system schedule config` (the old `pos-health.{service,timer}` units are gone). See [system](howto/system.md). - **Entertainment auto-triggers** — per-plugin `pos entertainment enable `, uses systemd user timers (or cron fallback). See [entertainment](howto/entertainment.md). - **`pos system schedule` jobs** — run any command on a per-job timer and notify diff --git a/DOC/POS.md b/DOC/POS.md index 0ed21fe..c767571 100644 --- a/DOC/POS.md +++ b/DOC/POS.md @@ -214,10 +214,10 @@ The standalone `vbox` command still works and forwards to `pos docker vbox` (see | `sudo pos system firewall` | `bin/pos-system-firewall` | Interactive UFW ("UFW POWER") menu: add/delete rules, status, enable/disable/reset, default policies | Must run as root. Every command is previewed and confirmed before execution; supports `--dry-run`; keeps a history of executed commands. Executed mutating changes are announced via `lib/notify.sh` | | `pos system backup ` | `bin/pos-system-backup` | Create a gpg-encrypted (AES-256) `tar.gz` snapshot of a folder and verify it | Prompts twice for a password (never stored). Uses `sudo tar`; needs `gnupg` (in `preinstall.sh` PACKAGES). Artifact `_.tar.gz.gpg` in the current directory, `chmod 600`. Once the archive verifies, connected USB storage is offered (auto-detected removable mounts via `lsblk`, or pinned with `BACKUP_USB_ROOT`): the copy lands in `/backups/` and is proven 100% by sha256 before it is announced. Success/failure are announced via `lib/notify.sh` | | `pos system backup --service` | `bin/pos-system-backup` | Lists folders under `/srv` and `~/srv`, lets you pick one, then runs the same backup | Roots via `BACKUP_SERVICE_ROOTS` (space-separated, default `/srv $HOME/srv`) or `~/.config/linux_post_install/system.env` | -| `pos system health [--send] [--markdown]` | `bin/pos-system-health` | Host health dashboard: disk per mount, RAM/swap, failed systemd units, backup age, fail2ban, docker containers. Exits 1 if any check FAILs | `--send`/`--markdown` are notification-only: they send the summary via `lib/notify.sh` to every platform in `NOTIFY_PLATFORM` and do NOT print the dashboard (so wrappers like the Telegram listener don't echo it back — pair with the listener's `@quiet` marker). `HEALTH_BACKUP_MAX_AGE_DAYS` (default 2) and `BACKUP_SERVICE_ROOTS` come from `~/.config/linux_post_install/system.env`; `--help` shows the effective values. Platform list from `~/.config/linux_post_install/notify.env` | +| `pos system health` | `bin/pos-system-health` | Host health dashboard: disk per mount, RAM/swap, failed systemd units, backup age, fail2ban, docker containers. Exits 1 if any check FAILs | Console-only reporter — health itself never sends notifications; forward the output with a wrapper (e.g. the Telegram/Matrix listener map `/status=pos system health`) or schedule it via `pos system schedule` with a `NOTIFY` policy. `HEALTH_BACKUP_MAX_AGE_DAYS` (default 2) and `BACKUP_SERVICE_ROOTS` come from `~/.config/linux_post_install/system.env`; `--help` shows the effective values | | `pos system schedule ` | `bin/pos-system-schedule` | Scheduled jobs — run a command on a timer, notify (or stay silent): `run [name\|all]`, `list`, `config`, `enable [name\|all]`, `disable [name\|all]`, `status`, `migrate`. Each job is a file in `~/.config/linux_post_install/schedule.d/.env` with `INTERVAL` (`5m…59m`, `1h…23h`, `hourly`, `daily`, `weekly`, `OnCalendar=…`), `NOTIFY` policy, optional `MSG`, `RULE` (threshold only), and `COMMAND` = the literal rest of the line (pipes/quotes/`sudo` fine). Policies: `always` (full output every run), `onchange` (send when output differs from the last run; first run always sends), `onerror` (non-zero exit or empty output), `threshold` (first numeric output vs `RULE`, alert on false→true + one recovery — the old event-trigger behavior), `never` (side-effect jobs, no notify) | One systemd **user** timer pair per job (`pos-schedule-.timer` + oneshot `.service`, `Persistent=true`), reconciled on `enable`/`disable`; the legacy single `pos-event-trigger` timer is auto-removed. `migrate` converts a pre-existing `event.env` rule set into `schedule.d/rule-N.env` threshold jobs. `config` is an interactive editor (add/edit/remove/enable/disable, validates interval + threshold); alerts via `lib/notify.sh`; `--dry-run` previews runs/writes/sends; jobs are arbitrary shell commands (chmod 600, same trust model as the Telegram map); starter jobs in `config/schedule.d/` auto-installed no-clobber by postinstall | -A scheduled job is the recommended way to run the health dashboard on a timer, e.g. a `daily` job with `COMMAND=pos system health --send` — no separate systemd unit needed (the old `pos-health.{service,timer}` units are gone; a legacy install may still have them failed/leftover — disable and remove them). +A scheduled job is the recommended way to run the health dashboard on a timer: a `daily` job with `COMMAND=pos system health` and `NOTIFY=always` sends the dashboard output as the alert — no separate systemd unit needed (the old `pos-health.{service,timer}` units are gone; a legacy install may still have them failed/leftover — disable and remove them). ### ssh @@ -295,7 +295,7 @@ The bot token is a secret — it is stored only in `~/.config/linux_post_install | `pos communication telegram listener --sync-commands` | Push the mapped `/commands` to the bot's `/` menu (`setMyCommands`) — also run automatically after every map edit, on `--enable`, and at daemon start | | `pos communication telegram listener --run` | Run the polling loop in the foreground (what the service executes) | -The map file is re-read for every message — edits apply without a restart. The listener only reacts to the owner chat (`TELEGRAM_CHAT_ID`); anyone else's message is ignored. `/help` lists mapped commands; an unmapped command replies "Unknown command". Non-command text starting with `ai ` (case-insensitive, e.g. `ai what is Nvidia`) is forwarded to Gemini via `pos ai gemini ask` and the answer is replied verbatim; an AI failure replies the error plus a `pos config ai` hint. Commands run as your user via `timeout 60 bash -c "…"` (stdout + stderr are replied, truncated to ~3800 chars; empty output → `OK`), so `sudo` inside them needs a NOPASSWD rule. A map value prefixed with `@quiet ` runs the command but does NOT reply — for commands that already send their own notification (e.g. `/status=@quiet pos system health --send`), avoiding a double message. `--enable` warns if linger is off — the service stops when you log out unless you run `sudo loginctl enable-linger $(whoami)`. +The map file is re-read for every message — edits apply without a restart. The listener only reacts to the owner chat (`TELEGRAM_CHAT_ID`); anyone else's message is ignored. `/help` lists mapped commands; an unmapped command replies "Unknown command". Non-command text starting with `ai ` (case-insensitive, e.g. `ai what is Nvidia`) is forwarded to Gemini via `pos ai gemini ask` and the answer is replied verbatim; an AI failure replies the error plus a `pos config ai` hint. Commands run as your user via `timeout 60 bash -c "…"` (stdout + stderr are replied, truncated to ~3800 chars; empty output → `OK`), so `sudo` inside them needs a NOPASSWD rule. A map value prefixed with `@quiet ` runs the command but does NOT reply — for commands that already send their own notification (e.g. `pos system backup` self-notifies, so `/backup=@quiet pos system backup $HOME/Documents` avoids a double message). `--enable` warns if linger is off — the service stops when you log out unless you run `sudo loginctl enable-linger $(whoami)`. Map entries may carry an optional **description** shown in the bot's `/` menu: `/cmd::short description=bash command` (the description falls back to the bash command, truncated to ~40 chars, when omitted). After every add/edit/remove the command list is pushed to the bot via `setMyCommands`, so the menu stays in sync; an empty map clears the menu. Telegram only registers lowercase `[a-z0-9_]` names (1–32 chars) — commands like `/Status` or `/my-cmd` are skipped from the menu with a warning but still resolve when typed. @@ -321,7 +321,7 @@ The access token is a secret — it is stored only in `~/.config/linux_post_inst | `pos communication matrix listener --disable` | Stops, disables, and removes the service | | `pos communication matrix listener --run` | Run the polling loop in the foreground (what the service executes) | -The daemon long-polls `/sync` (30s timeout, per-sync `since` token, compact filter that drops presence/account_data/device noise and only requests `m.room.message` timeline events). It reacts only to messages **from `MATRIX_USER_ID`** (your own account — resolved via `/account/whoami` if unset); a `MATRIX_ROOM_ID` restricts it to one room, otherwise every joined room is watched. `/` and `!` prefixes both resolve (`!status` = `/status`). `/help` lists mapped commands; an unmapped command replies "Unknown command". Non-command text starting with `ai ` (case-insensitive, e.g. `ai what is Nvidia`) is forwarded to Gemini via `pos ai gemini ask` with a per-room session (`matrix-`; `ai /reset` clears it) and the answer is replied verbatim with markdown stripped. Replies are sent as `m.text` threaded with `m.in_reply_to` on your message. Commands run as your user via `timeout 60 bash -c "…"` (stdout + stderr are replied, truncated to ~3800 chars; empty output → `OK`; non-zero exit is prefixed with `exit `), so `sudo` inside them needs a NOPASSWD rule. A map value prefixed with `@quiet ` runs the command but does NOT reply — for commands that already send their own notification (e.g. `/status=@quiet pos system health --send`). Map lines may carry a `/cmd::description=…` description. `--enable` warns if linger is off — the service stops when you log out unless you run `sudo loginctl enable-linger $(whoami)`. +The daemon long-polls `/sync` (30s timeout, per-sync `since` token, compact filter that drops presence/account_data/device noise and only requests `m.room.message` timeline events). It reacts only to messages **from `MATRIX_USER_ID`** (your own account — resolved via `/account/whoami` if unset); a `MATRIX_ROOM_ID` restricts it to one room, otherwise every joined room is watched. `/` and `!` prefixes both resolve (`!status` = `/status`). `/help` lists mapped commands; an unmapped command replies "Unknown command". Non-command text starting with `ai ` (case-insensitive, e.g. `ai what is Nvidia`) is forwarded to Gemini via `pos ai gemini ask` with a per-room session (`matrix-`; `ai /reset` clears it) and the answer is replied verbatim with markdown stripped. Replies are sent as `m.text` threaded with `m.in_reply_to` on your message. Commands run as your user via `timeout 60 bash -c "…"` (stdout + stderr are replied, truncated to ~3800 chars; empty output → `OK`; non-zero exit is prefixed with `exit `), so `sudo` inside them needs a NOPASSWD rule. A map value prefixed with `@quiet ` runs the command but does NOT reply — for commands that already send their own notification (e.g. `pos system backup` self-notifies, so `/backup=@quiet pos system backup $HOME/Documents` avoids a double message). Map lines may carry a `/cmd::description=…` description. `--enable` warns if linger is off — the service stops when you log out unless you run `sudo loginctl enable-linger $(whoami)`. `pos communication scrcpy` in detail: @@ -358,7 +358,7 @@ Requires `scrcpy` + `adb`. `adb` is in `preinstall.sh` PACKAGES; `scrcpy` is **n ### entertainment -**File:** `bin/pos-entertainment-send` +**File:** `bin/pos-entertainment-send` (management subcommands: `bin/pos-entertainment-config`, `bin/pos-entertainment-enable`, `bin/pos-entertainment-disable`, `bin/pos-entertainment-status`) **Purpose:** run a public-API plugin and send its output via `notify_send` — the platform follows `NOTIFY_PLATFORM` (default Telegram, silent-fail when none configured). Plugins are standalone scripts in `entertainment/` that fetch a public API and **print the message to stdout** — that stdout is what gets sent. | Command | Behavior | @@ -436,6 +436,8 @@ Feature-flag management CLIs (see [SCRIPTS.md → lib/flags.sh](SCRIPTS.md#libfl ### config +**File:** `bin/pos-config` + `pos config` is the interactive editor for the tools' runtime config (see [DEV.md](DEV.md#config-files) and §10 of AGENT_Context). Every tool exposes its configuration by declaring a `# POS_CONFIG:` header; `pos config` reads those at runtime — it knows nothing about the variables themselves. Values live in `~/.config/linux_post_install/.env` (chmod 600). | Command | Purpose | @@ -446,6 +448,8 @@ Feature-flag management CLIs (see [SCRIPTS.md → lib/flags.sh](SCRIPTS.md#libfl ### tree +**File:** `bin/pos-tree` + `pos tree` prints the `pos` command tree — every category, command, and subcommand the dispatcher can reach, annotated with each tool's `# POS:` description. Data is derived live from the `bin/pos-*` filenames and their `# POS_SUBCMDS:` headers, so it always matches what `pos` can actually run. | Command | Purpose | diff --git a/DOC/howto/communication.md b/DOC/howto/communication.md index 1a2f3d4..fbb19d5 100644 --- a/DOC/howto/communication.md +++ b/DOC/howto/communication.md @@ -80,7 +80,7 @@ pos communication telegram listener --disable # remove it one `/cmd=bash command` per line — re-read on every message, so edits apply instantly. Example: ``` - /status=@quiet pos system health --send + /status=pos system health /temp=sensors | grep -i 'Tctl\|package id 0' /update=cd /path/to/repo && git pull ``` @@ -88,7 +88,7 @@ pos communication telegram listener --disable # remove it (`setMyCommands`) after every map edit, on `--enable`, and at daemon start (force it anytime with `--sync-commands`). Add a short description with the `/cmd::description=bash command` syntax — e.g. - `/backup::Encrypted nightly backup=@quiet pos system backup --send` — or it + `/backup::Encrypted nightly backup=@quiet pos system backup $HOME/Documents` — or it falls back to the bash command. Telegram only registers lowercase `[a-z0-9_]` names (1–32 chars); `/Status` or `/my-cmd` are skipped from the menu but still work when typed. An empty map clears the menu. @@ -99,8 +99,10 @@ pos communication telegram listener --disable # remove it `sudo` inside a command needs a NOPASSWD rule. - **`@quiet` prefix:** a map value starting with `@quiet ` runs the command but does NOT reply — for commands that already send their own notification, so - you don't get it twice. `/status=@quiet pos system health --send` delivers - one digest via the notify system and nothing else. + you don't get it twice. `pos system backup` self-notifies, so + `/backup=@quiet pos system backup $HOME/Documents` sends the backup notification + and nothing else. `pos system health` is console-only (it never sends), so map + it WITHOUT `@quiet` — `/status=pos system health` replies with the dashboard. - **Daemon lifecycle:** the service is a systemd **user** unit; it stops at logout unless you enable linger: `sudo loginctl enable-linger $(whoami)`. `--enable` prints this warning if linger is off. @@ -197,7 +199,7 @@ pos communication matrix listener --disable # remove it one `/cmd=bash command` per line — re-read on every message, so edits apply instantly. Example: ``` - /status=@quiet pos system health --send + /status=pos system health /temp=sensors | grep -i 'Tctl\|package id 0' /update=cd /path/to/repo && git pull ``` @@ -212,7 +214,9 @@ pos communication matrix listener --disable # remove it (truncated ~3800 chars; empty → `OK`). `sudo` inside a command needs a NOPASSWD rule. - **`@quiet` prefix:** a map value starting with `@quiet ` runs the command but - does NOT reply — for commands that already send their own notification. + does NOT reply — for commands that already send their own notification + (`pos system backup` self-notifies, e.g. `/backup=@quiet pos system backup $HOME/Documents`). + `pos system health` is console-only (never sends), so map it WITHOUT `@quiet`. - **`ai …` bridge:** non-command messages starting with `ai ` are answered by `pos ai gemini` (per-room memory session; `ai /reset` clears it) — replying with the model's answer, markdown stripped. @@ -314,4 +318,4 @@ screen/file without a desktop. - Reference + config file details: [DOC/POS.md → communication](../POS.md) - Alerting contract: [DOC/DEV.md → Alerting](../DEV.md) -- Health digest (uses `--send --markdown`): [system.md](system.md) +- Health dashboard + scheduled digest: [system.md](system.md) diff --git a/DOC/howto/system.md b/DOC/howto/system.md index 26a4e6b..3ee8471 100644 --- a/DOC/howto/system.md +++ b/DOC/howto/system.md @@ -15,19 +15,17 @@ Host care: encrypted backups, firewall, and the health dashboard. Tools: ```bash pos system health # console report; exits 1 if any check FAILs -pos system health --send # also send the summary via notify platforms -pos system health --markdown # same, markdown parse mode (implies --send) ``` Checks: disk per mount (>90% = FAIL), RAM/swap, failed systemd units, backup age, fail2ban, docker containers. Header shows hostname, uptime, load, public IP. -`--help` prints the **effective** config values (env > `system.env` > default), -e.g.: +Health is a **console-only reporter — it never sends notifications**; deliver +its output with a wrapper or a scheduled job (below). `--help` prints the +**effective** config values (env > `system.env` > default), e.g.: ``` Environment (effective values): - NOTIFY_PLATFORM telegram HEALTH_BACKUP_MAX_AGE_DAYS 2 BACKUP_SERVICE_ROOTS /srv $HOME/srv ``` @@ -41,8 +39,6 @@ Environment (effective values): BACKUP_SERVICE_ROOTS=/srv $HOME/srv # roots for backup-age check + backup --service BACKUP_USB_ROOT=/mnt/usb # optional: copy finished backups to /backups/ (auto-detects a mounted USB when unset) HEALTH_BACKUP_MAX_AGE_DAYS=3 # WARN if newest backup older (default 2) -# ~/.config/linux_post_install/notify.env -NOTIFY_PLATFORM=telegram ``` ### Daily digest (automated) @@ -50,14 +46,15 @@ NOTIFY_PLATFORM=telegram Run the health report on a timer with a scheduled job (no systemd unit needed): ```bash -pos system schedule config # add a job: INTERVAL=daily, - # COMMAND=pos system health --send --markdown +pos system schedule config # add a job: INTERVAL=daily, NOTIFY=always, + # COMMAND=pos system health systemctl --user list-timers | grep pos-schedule pos system schedule run # run once now ``` -The old `pos-health.{service,timer}` systemd units are gone — a legacy install -may still have them failed/leftover; disable and remove them: +The `NOTIFY=always` policy sends the job's full output — i.e. the dashboard — +as the alert. The old `pos-health.{service,timer}` systemd units are gone — a +legacy install may still have them failed/leftover; disable and remove them: ```bash sudo systemctl disable --now pos-health.timer pos-health.service 2>/dev/null @@ -75,8 +72,6 @@ sudo rm -f /etc/systemd/system/pos-health.{service,timer} && sudo systemctl daem active; start it (`sudo systemctl enable --now fail2ban`) or ignore. - `[FAIL] services: nbd-server.service …` → a failed unit; inspect with `systemctl status `. -- `--send` prints a warn and exits 0 when no platform is configured — by design - (see [communication](communication.md)). --- diff --git a/MAINTENANCE.md b/MAINTENANCE.md new file mode 100644 index 0000000..8d57b73 --- /dev/null +++ b/MAINTENANCE.md @@ -0,0 +1,312 @@ +# MAINTENANCE — Convention Audit + +> Working report for the post-3-week-convention-drift audit. **Created first, updated +> incrementally as findings are confirmed** — never let findings live only in agent +> context. Keep the structure below; append findings as they land. +> +> Do **not** commit this file — it is a working artifact for the fix session. +> Definition of done for the fix session: every P0/P1 resolved (or explicitly +> marked won't-fix), `make gen && make check` green, `scripts/lint-conventions.sh` +> green, smoke tests pass. + +## Phase 0 — authority & conflict resolution + +Order of authority when docs disagree: + +1. **Templates** (`templates/pos-tool.sh`, `feature.sh`, `app.sh`) — the codified *current* convention for new files. If an old tool deviates from its template, that's drift (unless the tool is intentionally category-less/nested). +2. **DEV.md** — full convention detail, checklists, best practices (env seams, systemd units, alerting, managed blocks). +3. **AGENTS.md Quick facts** — operational facts (make gen/check gate, header format, INTERACTIVE_CMDS gotcha, deps-guard-before-help). If it conflicts with DEV.md, DEV.md wins on *detail*, AGENTS.md wins on *process*. +4. **Code (`# POS:` headers, runtime behavior)** — ground truth for what a tool *does* and for all `GEN:`-derived docs (make gen regenerates from code). +5. **POS.md / HOWTO / README / SCRIPTS / SYSTEMD / APPS** — hand-written references; drift against code = doc bug (fix the doc) unless the doc describes a feature the code never shipped (phantom feature — fix the doc too). +6. **AGENT_Context_Project.md** — generated blocks follow code; hand-maintained rows (§14 Common Tasks, non-`pos-*` line-count rows, "How to modify" table) must match reality. + +**Classification rule:** runtime behavior = what the tool *does*; conventions = what it *should* do. Findings are classified bug (behavior wrong), convention-violation (behavior right but against current convention), doc-drift (reference wrong), standardization (two tools same thing differently). + +## Baseline + +- `make gen && make check`: green (doc-sync, exec bits, syntax, dispatch smoke all pass). +- `scripts/lint-conventions.sh` (new, this audit): automated convention gate — see results below. +- AGENT_TODO: `Now` empty (Tier 1 shipped). Audit itself is the current work. +- Scope order (user-confirmed): bin/ + lib/ first, then install scripts + systemd, then features/entertainment/apps, then docs. All areas audited; this is report priority. +- Pickiness: everything flagged, tagged severity + confidence. P3 = intentional/legacy no-action list. +- Deliverables: `MAINTENANCE.md` (uncommitted) + `scripts/lint-conventions.sh` (to be committed as the reusable gate; `make lint` target still needs wiring in the Makefile). + +## Checklist (convention matrix — from AGENTS.md / AGENT_Context §11/§12 / DEV.md) + +- [ ] Shebang `#!/usr/bin/env bash` + `set -euo pipefail` (libs exempt — sourced) +- [ ] `# POS:` header right after shebang, ` — ` em-dash format; `# POS_FLAGS:` / `# POS_SUBCMDS:` / `# POS_CONFIG:` as needed +- [ ] Dep-guards (`command -v … || err`) **before** `-h|--help` +- [ ] stdin readers in `INTERACTIVE_CMDS` (bin/pos); no stale entries +- [ ] No `local` outside functions +- [ ] `run`/`spawn` respect `$DRY_RUN`; writes idempotent +- [ ] common.sh sourced via `$(dirname "$0")/../lib/…` fallback chain (standalone from /usr/local/bin) +- [ ] Entertainment plugins: no common.sh, `# POS_PLUGIN:`/`# POS_KEYS:` +- [ ] Apps: `uninstall_()` + `uninstall` dispatch +- [ ] systemd units: `TimeoutStopSec=5s`, `[Install]`, `KillMode=` on daemons +- [ ] Env seams: every system path write has `VAR="${VAR:-path}"` guard +- [ ] No hardcoded secrets; chmod 600 on creds/tokens +- [ ] Docs: POS.md / README / HOWTO / AGENT_Context hand-maintained spots match behavior + +## Lint results (`scripts/lint-conventions.sh`) + +_Automated gate — FAIL = definite violation, WARN = manual review needed. All 18 FAIL/WARN classes below are **verified real** against source (heuristics were iterated until zero false positives; heredocs, `${...}` brace-counting, `while`-loop stdin, `/dev/tty` reads, `command -v` fallbacks and env-guard secret patterns are all excluded)._ + +### FAIL — 12 (maps to tickets M-001, M-003..M-015) — ALL RESOLVED in the fix session (see ticket statuses) +- `features/autostart.sh` — missing `set -euo pipefail` (→ M-001) +- `bin/pos-system-firewall` — missing `-h|--help` handling (→ M-015) +- `bin/pos-docker-compose` — reads stdin (read `-rp` :204, `confirm` :210) not in `INTERACTIVE_CMDS` (→ M-002) +- `bin/pos-docker-vbox` — reads stdin (`confirm` :105) not in `INTERACTIVE_CMDS` (→ M-003) +- `bin/pos-network-hotspot` — reads stdin (`read -rp` :53) not in `INTERACTIVE_CMDS` (→ M-004) +- `bin/pos-docker-health` (:21/:24), `bin/pos-docker-ps` (:17/:20), `bin/pos-media-mp3` (:38/:58), `bin/pos-media-mp4` (:43/:71), `bin/pos-network-scan` (:28/:66), `bin/pos-share-usb-server` (:191/:199), `bin/pos-system-health` (:39/:119) — `-h|--help` dispatched before deps guards (→ M-008..M-014) + +### WARN — 6 (manual review done, all real docs-coverage gaps → M-023) — ALL RESOLVED +- `bin/pos-config`, `bin/pos-tree`, `bin/pos-entertainment-config`, `-enable`, `-disable`, `-status` — not referenced anywhere in DOC/POS.md + +_Fix-session note: lint now reports 0 FAIL / 0 WARN. Two precision fixes were made to the lint itself (documented, not weakenings): `first_guard_line()` only treats real deps guards (`command -v … ||`, `if ! command -v`, `command -v … \`) as guards so graceful-degradation probes (system-health) don't trip the guard-before-help rule; `first_line()` skips comment lines so a comment containing `-h|--help` isn't mistaken for the dispatch._ + +_Heuristics that passed clean (reviewed, no findings): no secrets committed (all `TOKEN/SECRET/RPC_SECRET` assignments are env guards / config reads / runtime generation), all libs' system writes carry `command -v` fallbacks, legacy wrappers are thin, no plugin sources common.sh, systemd units carry `TimeoutStopSec=`/`WantedBy=`._ + +## Findings + +Findings are tickets. Every ticket gets a unique `M-###` ID; the fix session works +through them in order (P0/HIGH first). Keep this exact field set: + +``` +### M-000 +Status: OPEN +Severity: HIGH | MEDIUM | LOW +Category: bug | convention | dead-code | docs | UX | standardization | security +Files: +Evidence: +Expected: +Recommended fix: +Verification: +``` + +### P0 — bugs / security + +### M-002 +Status: VERIFIED +Severity: HIGH +Category: bug +Files: bin/pos-docker-compose:204,210; bin/pos:259 +Evidence: `pos docker compose config`/startup runs `read -rp "Enter your Tailscale auth key…"` (:204) and `confirm "Edit .env before starting?"` (:210) — both read stdin. `bin/pos` logging tee would swallow/hang these prompts because `docker-compose` is not in `INTERACTIVE_CMDS` (line 259). Same class of bug as the just-fixed smb-client prompt swallow. +Expected: every tool that reads stdin is in `INTERACTIVE_CMDS`. +Recommended fix: add `docker-compose` to `INTERACTIVE_CMDS`. +Verification: `pos docker compose config` via `pos` (logged) still prompts. +Fix (2026-08-14): added `docker-compose` to `INTERACTIVE_CMDS` in bin/pos:259. Verified: lint stdin-reader FAIL cleared for all three tools (M-002..M-004). + +### M-003 +Status: VERIFIED +Severity: HIGH +Category: bug +Files: bin/pos-docker-vbox:105; bin/pos:259 +Evidence: `pos docker vbox start` calls `confirm "Enter now?"` (stdin read via lib/common.sh `confirm()` → `read -rp`) at line 105. Not in `INTERACTIVE_CMDS` → prompt swallow/hang under the logging pipe. +Expected: every stdin reader in `INTERACTIVE_CMDS`. +Recommended fix: add `docker-vbox` to `INTERACTIVE_CMDS`. +Verification: `pos docker vbox start` prompts correctly through the dispatcher. +Fix (2026-08-14): added `docker-vbox` to `INTERACTIVE_CMDS` in bin/pos:259; lint stdin FAIL cleared. + +### M-004 +Status: VERIFIED +Severity: HIGH +Category: bug +Files: bin/pos-network-hotspot:53; bin/pos:259 +Evidence: `read -rp "Run in background? [y/N]: " bg` at line 53 (non-`--foreground` path). Not in `INTERACTIVE_CMDS` → prompt swallow/hang. +Expected: every stdin reader in `INTERACTIVE_CMDS`. +Recommended fix: add `network-hotspot` to `INTERACTIVE_CMDS`. +Verification: `pos network hotspot start ` prompts through the dispatcher. +Fix (2026-08-14): added `network-hotspot` to `INTERACTIVE_CMDS` in bin/pos:259; lint stdin FAIL cleared. + +### M-005 +Status: VERIFIED +Severity: HIGH +Category: bug +Files: install.sh:38,84-99 +Evidence: `install.sh --steps` documents format "`1,3,4 or 1-3`" (line 38) but the filter is `[[ ",$STEPS_SPEC," != *",$phase_num,"* ]]` (line 86) — only comma-separated matches. `--steps 1-3` matches nothing → **zero phases run**, silently. +Expected: both documented syntaxes work (comma list and `N-M`/`N-M,K` range). +Recommended fix: expand the range spec into the explicit phase set before filtering (e.g. `1-3` → `1,2,3`). +Verification: `install.sh --dry-run --steps 1-3` runs phases 1,2,3; `--steps 1,3` runs 1,3. +Fix (2026-08-14): added `normalize_steps_spec()` to install.sh (placed after variable init, before arg loop) — expands `1-3` → `1,2,3`, validates single/range/list syntax, errors on garbage and end/dev/null || echo "…"` — when sourced standalone (notify.sh is designed to be sourceable without common.sh, line 3) and `warn` is absent, the `|| echo` fallback writes the message to **stdout**. notify.sh is meant to be a silent helper (per-file note line 3: "so it can be sourced by tools that define their own log/warn/err"); stdout here contaminates wrappers/plugins (e.g. entertainment send). +Expected: notify.sh must never write to stdout. +Recommended fix: route fallbacks to stderr (`warn() { printf … >&2; }` or `>&2` on the echoes). +Verification: `lib/notify.sh` sourced alone prints nothing to stdout on a failed send. +Fix (2026-08-14): only line 57 actually leaked stdout (`|| echo "…"`); lines 73/79/83 already end in `|| true` (silent). Routed line 57's fallback to stderr (`>…` on the echo). Verified: standalone `source lib/notify.sh; notify_send ""` → stdout empty, message on stderr. + +### P1 — clear convention violations + +### M-001 +Status: VERIFIED +Severity: LOW +Category: convention +Files: features/autostart.sh +Evidence: Lines 1-2: shebang then `LOG="${HOME:-/root}/.autostart.log"` directly — no `set -euo pipefail`. Template `templates/feature.sh:2` requires it; sibling `features/usb-automount.sh:2` has it. +Expected: `set -euo pipefail` as line 2 (all scripts, libs exempt). +Recommended fix: insert `set -euo pipefail` after the shebang (script already defensively uses `|| true`). +Verification: `./scripts/lint-conventions.sh` clean; `bash -n features/autostart.sh`. +Fix (2026-08-14): rewrote autostart.sh with the full feature-template preamble (`set -euo pipefail`, flags.sh load, usage()/`-h|--help` — folded into M-017). Verified: `bash -n` clean; `-h` prints usage; a real run appends the 3 log lines; lint clean. + +### M-008..M-014 — `-h|--help` before deps guards (standardization) +Status: VERIFIED +Severity: MEDIUM +Category: standardization +Files: bin/pos-docker-health:21/24, bin/pos-docker-ps:17/20, bin/pos-media-mp3:38/58, bin/pos-media-mp4:43/71, bin/pos-network-scan:28/66, bin/pos-share-usb-server:191/199, bin/pos-system-health:39/119 +Evidence: AGENTS.md/DEV.md convention — `command -v` deps guards sit **before** the `-h|--help` dispatch so help also errors on a box missing the dependency. These 7 tools dispatch help first. For docker-health/docker-ps the guard is only ~3 lines late (near-miss). For system-health there is **no top-level guard at all** (all `command -v` are per-check runtime probes at 119/166/182) — it degrades gracefully instead. +Expected: uniform guard-before-help, or an explicitly documented exception for graceful-degradation tools. +Recommended fix: move guards above help in the 6 hard-dep tools; for system-health either add a minimal guard (docker/fail2ban/systemctl are optional by design) or document it as the sanctioned no-guard pattern in DEV.md. +Verification: `make check` green; `./scripts/lint-conventions.sh` shows no dep-guard FAILs; each tool's `--help` still works without deps installed. +Fix (2026-08-14): 6 hard-dep tools now guard before help — docker-health, docker-ps (converted to documented `command -v X || err` form), network-scan (moved up, kept its echo/exit style), share-usb-server (moved up), media-mp3/mp4 (guards moved before help with a `--dry-run` pre-scan preserving the documented dry-run-without-deps behavior; duplicate `DRY_RUN=0` and post-loop guard blocks removed). system-health documented as the sanctioned no-guard pattern in DEV.md (graceful degradation). Lint refined: `first_guard_line()` only matches real guards (`command -v … ||`, `if ! command -v`, `command -v … \`) and `first_line()` skips comment lines. Verified: lint shows no dep-guard FAILs; restricted-PATH tests — `pos media mp3 -h` errors without deps, `--dry-run` still previews; `pos share usb-server -h` errors (usbsrv absent); mp4 mutual-exclusion checks intact. + +### M-015 +Status: VERIFIED +Severity: MEDIUM +Category: convention +Files: bin/pos-system-firewall +Evidence: `pos system firewall` has no `usage()` and no `-h|--help` case at all — first thing is a root check, then `read -rp "Execute this command?…"`. Violates the universal tool contract (templates/pos-tool.sh). Lint FAIL confirms. +Expected: `-h|--help` shows a usage synopsis. +Recommended fix: add `usage()` + `-h|--help` case (per templates/pos-tool.sh), keeping the root/deps checks ahead of it. +Verification: `pos system firewall --help` prints usage without prompting. +Fix (2026-08-14): added `usage()` + `-h|--help` case after the root check and notify.sh source (root check stays first — tool is root-only by design). Verified as root: `sudo pos-system-firewall -h` prints usage, exit 0. + +### M-016 +Status: VERIFIED +Severity: MEDIUM +Category: convention +Files: preinstall.sh:28-41 (PACKAGES); bin/pos-media-mp3:59, bin/pos-media-mp4:72 +Evidence: both media tools `command -v ffmpeg || err …` — ffmpeg is an apt package, so per DEV.md "apt packages → PACKAGES array in preinstall.sh" it belongs there. It's absent (only yt-dlp is handled, via manual curl). +Expected: ffmpeg in `PACKAGES`; tools keep the guard as a safety net for non-install.sh installs. +Recommended fix: add `ffmpeg` to PACKAGES. +Verification: `install.sh` installs ffmpeg; mp3/mp4 guards remain as fallback. +Fix (2026-08-14): added `ffmpeg` to the PACKAGES array in preinstall.sh. `bash -n` clean; mp3/mp4 `command -v ffmpeg || err` guards untouched. + +### M-017 +Status: VERIFIED +Severity: LOW +Category: convention +Files: features/autostart.sh +Evidence: template `templates/feature.sh:24-26` requires sourcing `lib/flags.sh` (`# POS_FLAGS:` support), and the tool contract requires `-h|--help`. autostart.sh has neither — it's a bare boot script. Also missing `set -euo pipefail` (M-001). +Expected: feature template contract (set -euo pipefail, flags.sh load, -h|--help). +Recommended fix: add the template preamble (flags.sh load + usage/-h); no functional change to boot logic. +Verification: `bash -n` clean; `-h` prints usage; lint clean. +Fix (2026-08-14): full template preamble added (flags.sh load via the repo/usr-local fallback chain, FEATURE_NAME, usage() with Flag/Log lines, `-h|--help` case). Boot logic unchanged. Verified: `bash -n`, `-h` usage, live run logs 3 lines, lint clean. + +### M-018 +Status: VERIFIED +Severity: LOW +Category: convention +Files: features/usb-automount.sh +Evidence: same as M-017 — feature template `flags.sh` load (templates/feature.sh:24-26) absent. (Has `set -euo pipefail`, so only flags.sh + usage are missing.) +Recommended fix: add flags.sh load + usage/-h per template. +Verification: lint clean; `-h` works. +Fix (2026-08-14): added the flags.sh load (repo/usr-local fallback) right after `set -euo pipefail`. usage()/`-h|--help` already present. Verified: `bash -n`, lint clean. + +### M-019 +Status: VERIFIED +Severity: LOW +Category: standardization +Files: apps/media/scrcpy.sh +Evidence: 0644 (`-rw-rw-r--`) while every other `apps/*/*.sh` is 0755. Consistent exec-bit convention violated. +Recommended fix: `chmod +x apps/media/scrcpy.sh`. +Verification: `ls -l apps/media/scrcpy.sh` shows 0755; `make check` exec-bit gate green. +Fix (2026-08-14): `chmod +x apps/media/scrcpy.sh` → 0755. `make check` exec-bit gate will confirm at final run. + +### M-020 +Status: VERIFIED +Severity: LOW +Category: standardization +Files: bin/pos-docker-compose:8,9 +Evidence: `SCALE_DIR="/usr/local/share/linux_post_install/scale-tail/services"` and `CONFIG_ENV="${HOME}/.config/linux_post_install/compose.env"` hardcoded — no `${VAR:-…}` env seam, unlike the documented `SERVICES_BASE` default (/srv). DEV.md seam rule says every system path write has a `VAR="${VAR:-path}"` guard. +Expected: `SCALE_DIR="${SCALE_DIR:-/usr/local/share/…}"` etc. +Recommended fix: add `:-` seams for SCALE_DIR and CONFIG_ENV. +Verification: env override respected in a dry-run. +Fix (2026-08-14): added `:-` seams for both. Verified: `CONFIG_ENV=/tmp/... config` prints the override; `SCALE_DIR=/tmp/... ls` errors citing the override. +Follow-on found while verifying: `pos docker compose config` crashed with `DIM: unbound variable` (common.sh color block had no DIM; pre-existing on HEAD). Fixed by adding `DIM=$(tput dim)` / empty-fallback to the common.sh color block. `config` now prints correctly. + +### P2 — doc drift / consistency / style + +### M-021 +Status: VERIFIED +Severity: LOW +Category: standardization +Files: lib/notify.sh:27, lib/entertainment-lib.sh:5, lib/config-ui.sh:22, bin/pos-network-download:16, bin/pos-communication-{matrix,telegram}-{listener,sender} +Evidence: `CONFIG_DIR` redefined per-file with inconsistent semantics: notify.sh is XDG-aware (`${XDG_CONFIG_HOME:-$HOME/.config}`), the rest use plain `$HOME/.config/linux_post_install`, network-download uses the `${CONFIG_DIR:-…}` seam form. Same global name, three conventions. +Expected: one definition (in lib/common.sh) + seam form everywhere. +Recommended fix: define `CONFIG_DIR` once in common.sh; drop per-file definitions (keep the `:-` seam in network-download). +Verification: all tools still resolve config after refactor. +Fix (2026-08-14): canonical `CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}"` defined in common.sh; dropped the duplicate in entertainment-lib.sh (loads common.sh) and network-download (loads common.sh). Standalone-sourced files (notify.sh, config-ui.sh, the matrix/telegram listener/sender — none source common.sh) keep an identical guarded seam line, mirroring the "no shared lib? inline fallbacks" convention; noted in common.sh. Verified: notify.sh standalone resolves default/XDG/CONFIG_DIR overrides correctly; pos-config (common.sh + config-ui) works; all touched files bash -n clean. + +### M-022 +Status: VERIFIED +Severity: LOW +Category: standardization +Files: lib/entertainment-lib.sh (plugin_dir/plugin_exists/plugin_keys/plugin_marker), lib/entertainment-plugin-lib.sh (plugin_config_file/plugin_err/plugin_have/plugin_http_json/plugin_load_config/plugin_require) +Evidence: two libs share the `plugin_*` prefix for unrelated jobs (marker/registry helpers vs plugin runtime helpers). Collision-prone and unclear at call sites. +Expected: distinct prefixes per concern. +Recommended fix: rename the plugin-lib helpers (e.g. `plt_*`/`plugin_rt_*`) or the marker helpers; keep one prefix per lib. +Verification: grep for `plugin_` shows no cross-lib ambiguity; `make gen && make check` green. +Fix (2026-08-14): kept `plugin_*` for the documented plugin-authoring API (`plugin_load_config`/`plugin_have`/`plugin_require`/`plugin_http_json`/`plugin_err`/`plugin_config_file` — referenced in DOC/DEV.md and DOC/POS.md; user plugins depend on it). Renamed the internal registry helpers in lib/entertainment-lib.sh to `ent_plugin_dir`/`ent_plugin_marker`/`ent_plugin_exists`/`ent_plugin_keys` and updated callers (pos-entertainment-{status,enable,config,send}, lib/config-ui.sh). Verified: no bare `plugin_` registry helpers remain; plugin runtime helpers untouched; entertainment tools + pos-config smoke-tested; bash -n clean. + +### M-023 +Status: VERIFIED +Severity: LOW +Category: docs +Files: DOC/POS.md; bin/pos-config, bin/pos-tree, bin/pos-entertainment-{config,enable,disable,status} +Evidence: 6 shipped tools are absent from the POS.md command reference (31 `bin/pos-*` refs exist but not these). Lint WARN confirms. +Expected: every tool documented in POS.md. +Recommended fix: add rows to the POS.md command table (and cross-check HOWTO for the entertainment group). +Verification: lint WARNs gone; `grep` shows each tool in POS.md. +Fix (2026-08-14): the tools were documented by command name but not by filename (the lint references basenames). Added `**File:** bin/pos-config` (config section), `**File:** bin/pos-tree` (tree section), and a file list on the entertainment section header covering `bin/pos-entertainment-{config,enable,disable,status}`. Verified: lint 0 WARN. HOWTO already covers the entertainment group via `pos entertainment *` command forms. + +### P3 — intentional / legacy (no action) +- install.sh:123,135,155,185 — installer writes to /usr/local/bin are its purpose; no seam needed (lint excludes install scripts). +- network-download RPC_SECRET at :150 — generated at runtime (`/dev/urandom`), not a committed secret. +- system-health graceful degradation — candidate for the sanctioned no-guard pattern (see M-014); decide in fix session whether it becomes a P1 or a P3. Commit fe7708f's removal of the old `pos-health.{service,timer}` units is correct (superseded by scheduler); the only leftover is the stale docs (M-006). + +## Semantic deep-dive notes (the detective pass) + +- `lib/notify.sh` — stdout-leak via `warn … || echo` fallbacks (M-007); rest of helper logic (platform routing, silent-fail contract) correct. +- `lib/common.sh` — `confirm()` reads stdin via `read -rp` → it makes any caller an INTERACTIVE_CMDS candidate (this is how M-003/M-002 were caught). Deps guards, `run`/`spawn`, `$DRY_RUN` semantics all match convention. +- `bin/pos` — INTERACTIVE_CMDS list (259) was complete except the 3 new stdin readers (M-002..M-004); no stale entries (lint reverse-check green). Dispatcher longest-prefix logic unchanged from prior fix session. +- `bin/pos-system-firewall` — entirely interactive; root-check-first is correct, but no usage/-h at all (M-015). +- `bin/pos-system-health` — pure passive reporter; per-check `command -v` probes are correct for graceful degradation, but leave the no-top-level-guard pattern undocumented (M-014). Prints to stdout by design (it IS the report) — the notify path is what the docs claim (M-006) but was removed in fe7708f. +- `install.sh` — `--steps` comma-only parsing (M-005); the phase functions themselves and step numbering are consistent with the header. Legacy `x-systemd.automount`-style remnants: none — the fstab/automount handling in share tools uses correct systemd units now (verified in prior fix session, commits 1724096/93fb6b6). +- `pos-docker-compose` — hardcoded template dir + config path (M-020); `SERVICES_BASE` documented in `# POS_CONFIG:` and used at runtime for deployments, but not for SCALE_DIR. +- No `x-systemd.automount` in any unit `Options=` (grep clean). No `interact`/`getconf`-style stale-format headers anywhere in `bin/`. +- Entertainment plugins: none source common.sh; all carry `# POS_PLUGIN:` + `# POS_KEYS:` (lint green). +- systemd units: `TimeoutStopSec=` + `[Install] WantedBy=` present everywhere (lint green); no leftover `pos-health.{service,timer}` (removed in fe7708f, correct). + +## Next-session brief + +1. Fix P0 → P1 → P2 in order (HIGH first: M-002..M-006, then M-007, then the rest). +2. M-006 needs a product decision first: restore `--send`/`--markdown` (notify path) or strip the docs. +3. Re-run `make gen && make check && scripts/lint-conventions.sh` + smoke each changed tool. +4. Commit `scripts/lint-conventions.sh` + add the `make lint` target (this was on the deliverable list but is not yet wired into the Makefile). +5. Move audit tasks to AGENT_TODO Done (dated) on completion. + +## Checked & clean + +- lib/common.sh (deps/run/spawn/confirm — semantics verified) +- lib/notify.sh (logic — only the stdout-fallback leak, M-007) +- legacy wrappers bin/wr-*, bin/mp3, bin/mp4, bin/vbox, bin/ssh-load-all (thin, forward to pos) +- systemd/*.service (TimeoutStopSec + WantedBy everywhere) +- entertainment/*.sh (no common.sh, POS_PLUGIN/POS_KEYS present) +- secrets: no committed credentials anywhere (grep for TOKEN/SECRET/AUTHKEY/RPC_SECRET literals clean — all guards/config-reads/runtime-gen) +- stale-format conventions: no `x-systemd.automount`, no old header formats, no leftover pos-health units diff --git a/Makefile b/Makefile index 2d3853a..6f95c13 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: check gen hook +.PHONY: check gen hook lint ## check — verify repo self-consistency (syntax, exec bits, doc/code sync, smoke) check: @@ -8,6 +8,10 @@ check: gen: ./scripts/gen-docs.sh +## lint — convention gate (shebang/pipefail, headers, deps-guard ordering, stdin, secrets, docs) +lint: + ./scripts/lint-conventions.sh + ## hook — install the opt-in pre-commit hook (runs `make check`) hook: ./scripts/install-hooks.sh diff --git a/apps/media/scrcpy.sh b/apps/media/scrcpy.sh old mode 100644 new mode 100755 diff --git a/bin/pos b/bin/pos index acb1d50..84ee27a 100755 --- a/bin/pos +++ b/bin/pos @@ -256,7 +256,7 @@ MAIN_LOG="$LOG_DIR/pos.log" log_cmd() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $* → exit $2" >> "$MAIN_LOG"; } # Commands that read from stdin interactively — only log invocation -INTERACTIVE_CMDS="system-firewall media-mp4 system-backup share-usb-server share-smb-server share-smb-client communication-telegram-listener communication-matrix-listener ai-gemini system-schedule entertainment-config config" +INTERACTIVE_CMDS="docker-compose docker-vbox network-hotspot system-firewall media-mp4 system-backup share-usb-server share-smb-server share-smb-client communication-telegram-listener communication-matrix-listener ai-gemini system-schedule entertainment-config config" for ((i=n-1; i>=0; i--)); do cmd="pos" diff --git a/bin/pos-communication-matrix-listener b/bin/pos-communication-matrix-listener index d91f1bd..70b5532 100755 --- a/bin/pos-communication-matrix-listener +++ b/bin/pos-communication-matrix-listener @@ -3,7 +3,7 @@ set -euo pipefail # POS: communication matrix-listener — Matrix listener: map /command → bash, run them on room messages # POS_FLAGS: --enable --disable --status --run -CONFIG_DIR="$HOME/.config/linux_post_install" +CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}" CONFIG_FILE="$CONFIG_DIR/matrix.env" MAP_FILE="$CONFIG_DIR/matrix_commands.env" SERVICE="pos-matrix-listener.service" diff --git a/bin/pos-communication-matrix-sender b/bin/pos-communication-matrix-sender index e16c592..acdbe25 100755 --- a/bin/pos-communication-matrix-sender +++ b/bin/pos-communication-matrix-sender @@ -4,7 +4,7 @@ set -euo pipefail # POS_SUBCMDS: send test login # POS_CONFIG: matrix | matrix.env | MATRIX_HOMESERVER=:Homeserver URL (https://matrix.example.org)::https://matrix.example.org | MATRIX_ACCESS_TOKEN=secret:Access token (from 'pos communication matrix sender login' or a Matrix client) | MATRIX_USER_ID=:Your Matrix user id (set by login)::@you:example.org | MATRIX_ROOM_ID=:Room id or alias::#pos:example.org -CONFIG_DIR="$HOME/.config/linux_post_install" +CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}" CONFIG_FILE="$CONFIG_DIR/matrix.env" usage() { diff --git a/bin/pos-communication-telegram-listener b/bin/pos-communication-telegram-listener index 0f3336e..36ae1b3 100755 --- a/bin/pos-communication-telegram-listener +++ b/bin/pos-communication-telegram-listener @@ -3,7 +3,7 @@ set -euo pipefail # POS: communication telegram-listener — Telegram bot listener: map /command → bash, run them on chat messages # POS_FLAGS: --enable --disable --status --sync-commands --run -CONFIG_DIR="$HOME/.config/linux_post_install" +CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}" CONFIG_FILE="$CONFIG_DIR/telegram.env" MAP_FILE="$CONFIG_DIR/telegram_commands.env" API="https://api.telegram.org" diff --git a/bin/pos-communication-telegram-sender b/bin/pos-communication-telegram-sender index 83d1284..892b25d 100755 --- a/bin/pos-communication-telegram-sender +++ b/bin/pos-communication-telegram-sender @@ -5,7 +5,7 @@ set -euo pipefail # POS_SUBCMDS: send test # POS_CONFIG: telegram | telegram.env | TELEGRAM_BOT_TOKEN=secret:Bot token from @BotFather | TELEGRAM_CHAT_ID=digits:Numeric chat id from @userinfobot -CONFIG_DIR="$HOME/.config/linux_post_install" +CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}" CONFIG_FILE="$CONFIG_DIR/telegram.env" API="https://api.telegram.org" diff --git a/bin/pos-docker-compose b/bin/pos-docker-compose index ab9e009..1275bed 100755 --- a/bin/pos-docker-compose +++ b/bin/pos-docker-compose @@ -5,8 +5,8 @@ set -euo pipefail # POS_CONFIG: compose | compose.env | TS_AUTHKEY=secret:Tailscale auth key for the sidecar | TZ=:Service timezone (default Europe/Amsterdam) | DNS_SERVER=:Custom DNS server (default 9.9.9.9) | SERVICES_BASE=:Deployment root (default /srv) source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" -SCALE_DIR="/usr/local/share/linux_post_install/scale-tail/services" -CONFIG_ENV="${HOME}/.config/linux_post_install/compose.env" +SCALE_DIR="${SCALE_DIR:-/usr/local/share/linux_post_install/scale-tail/services}" +CONFIG_ENV="${CONFIG_ENV:-${HOME}/.config/linux_post_install/compose.env}" usage() { cat </dev/null || err "docker not found — install it with: sudo apt install docker.io" + case "${1:-}" in -h|--help) usage ;; esac -if ! command -v docker &>/dev/null; then - echo "docker not found" - exit 1 -fi - if ! container_ids=$(docker ps -a -q 2>/dev/null) || [[ -z "$container_ids" ]]; then echo "No containers." exit 0 diff --git a/bin/pos-docker-ps b/bin/pos-docker-ps index 5519369..34708ae 100755 --- a/bin/pos-docker-ps +++ b/bin/pos-docker-ps @@ -13,15 +13,12 @@ EOF exit 0 } +command -v docker &>/dev/null || err "docker not found — install it with: sudo apt install docker.io" + case "${1:-}" in -h|--help) usage ;; esac -if ! command -v docker &>/dev/null; then - echo "docker not found" - exit 1 -fi - if ! container_ids=$(docker ps -q 2>/dev/null) || [[ -z "$container_ids" ]]; then echo "No running containers." exit 0 diff --git a/bin/pos-entertainment-config b/bin/pos-entertainment-config index b2e5001..8537d0d 100755 --- a/bin/pos-entertainment-config +++ b/bin/pos-entertainment-config @@ -43,7 +43,7 @@ After 'set'/'unset'/'edit', auto-trigger timers are re-synced from ENABLED. Config file: $CONFIG_FILE Keys (declared by the installed plugins' # POS_KEYS: headers): -$(keys_section "$(plugin_dir)") +$(keys_section "$(ent_plugin_dir)") Intervals: 5m 10m 15m 30m 45m hourly 2h 6h 12h daily weekly, or OnCalendar=... @@ -60,7 +60,7 @@ EOF } key_known() { - config_key_known "$(plugin_dir)" "$1" + config_key_known "$(ent_plugin_dir)" "$1" } case "${1:-}" in @@ -84,14 +84,14 @@ case "${1:-}" in while IFS= read -r line; do IFS='|' read -r _plugin key _desc _req <<<"$line" [ ${#key} -gt "$maxw" ] && maxw=${#key} - done <<< "$(config_keys "$(plugin_dir)")" + done <<< "$(config_keys "$(ent_plugin_dir)")" printf ' %-*s %s\n' "$maxw" "ENABLED" "\"$(config_value ENABLED)\"" while IFS= read -r line; do [ -n "$line" ] || continue IFS='|' read -r plugin key desc req <<<"$line" v="$(config_value "$key")" printf ' %-*s %s [%s] %s (%s)\n' "$maxw" "$key" "${v:-}" "$plugin" "$desc" "$req" - done <<< "$(config_keys "$(plugin_dir)")" + done <<< "$(config_keys "$(ent_plugin_dir)")" ;; edit) shift @@ -138,6 +138,6 @@ case "${1:-}" in echo "Intervals: 5m 10m 15m 30m 45m hourly 2h 6h 12h daily weekly, or OnCalendar=..." echo echo "Plugin keys (declared in each plugin's # POS_KEYS: header):" - keys_section "$(plugin_dir)" + keys_section "$(ent_plugin_dir)" ;; esac diff --git a/bin/pos-entertainment-enable b/bin/pos-entertainment-enable index 3d6bbbb..f9f481e 100755 --- a/bin/pos-entertainment-enable +++ b/bin/pos-entertainment-enable @@ -15,7 +15,7 @@ and schedules 'pos entertainment send ' to run as a systemd user timer. Re-running without an interval keeps the plugin's current one. Available plugins: -$(list_plugins "$(plugin_dir)" | sed 's/^/ /') +$(list_plugins "$(ent_plugin_dir)" | sed 's/^/ /') Intervals: 5m 10m 15m 30m 45m hourly 2h 6h 12h daily weekly, or OnCalendar=... Default: $DEFAULT_INTERVAL @@ -38,7 +38,7 @@ esac plugin="$1" interval="${2:-}" -resolve_plugin "$(plugin_dir)" "$plugin" >/dev/null +resolve_plugin "$(ent_plugin_dir)" "$plugin" >/dev/null if [ -n "$interval" ]; then ut_interval_to_oncalendar "$interval" >/dev/null || \ err "Invalid interval '$interval' (allowed: 5m 10m 15m 30m 45m hourly 2h 6h 12h daily weekly, or OnCalendar=...)" diff --git a/bin/pos-entertainment-send b/bin/pos-entertainment-send index da6bd91..217ea1f 100755 --- a/bin/pos-entertainment-send +++ b/bin/pos-entertainment-send @@ -9,7 +9,7 @@ source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0") usage() { local dir plugins - dir="$(plugin_dir 2>/dev/null)" || dir="" + dir="$(ent_plugin_dir 2>/dev/null)" || dir="" if [ -n "$dir" ]; then plugins="$(list_plugins "$dir" | sed 's/^/ /')" else @@ -67,7 +67,7 @@ plugin="${plugin_args[0]}" unset 'plugin_args[0]' # ── Run the plugin ────────────────────────────────────────────── -dir="$(plugin_dir)" +dir="$(ent_plugin_dir)" script="$(resolve_plugin "$dir" "$plugin")" rc=0 diff --git a/bin/pos-entertainment-status b/bin/pos-entertainment-status index 56b827d..a19503e 100755 --- a/bin/pos-entertainment-status +++ b/bin/pos-entertainment-status @@ -36,7 +36,7 @@ for entry in "${ENABLED_ENTRIES[@]}"; do done not_enabled=() -for name in $(list_plugins "$(plugin_dir)"); do +for name in $(list_plugins "$(ent_plugin_dir)"); do found=0 for entry in "${ENABLED_ENTRIES[@]}"; do [ "${entry%%,*}" = "$name" ] && found=1 && break diff --git a/bin/pos-media-mp3 b/bin/pos-media-mp3 index 509441b..7e8830e 100755 --- a/bin/pos-media-mp3 +++ b/bin/pos-media-mp3 @@ -5,6 +5,19 @@ set -euo pipefail source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" +# --dry-run is parsed before the deps guards (below): it previews the yt-dlp +# command without requiring yt-dlp/ffmpeg to be installed. +DRY_RUN=0 +for arg in "$@"; do + [ "$arg" = "--dry-run" ] && DRY_RUN=1 +done + +# Deps guards sit before -h|--help (help also errors on a box missing the deps). +if [ "$DRY_RUN" -eq 0 ]; then + command -v yt-dlp &>/dev/null || err "yt-dlp not found — install it with: sudo apt install yt-dlp" + command -v ffmpeg &>/dev/null || err "ffmpeg not found (needed for MP3 conversion) — install it with: sudo apt install ffmpeg" +fi + OUT_DIR="$HOME/Music" BY_ARTIST=0 EXTRA_ARGS=() @@ -31,7 +44,6 @@ EOF exit 0 } -DRY_RUN=0 URL="" while [[ $# -gt 0 ]]; do case "$1" in @@ -53,12 +65,6 @@ while [[ $# -gt 0 ]]; do done [ -n "$URL" ] || usage -# Dependency checks (skipped under --dry-run: the command is only previewed). -if [ "$DRY_RUN" -eq 0 ]; then - command -v yt-dlp &>/dev/null || err "yt-dlp not found — install it with: sudo apt install yt-dlp" - command -v ffmpeg &>/dev/null || err "ffmpeg not found (needed for MP3 conversion) — install it with: sudo apt install ffmpeg" -fi - if [ "$BY_ARTIST" -eq 1 ]; then OUT_TEMPLATE="$OUT_DIR/%(artist,uploader)s/%(title)s.%(ext)s" else diff --git a/bin/pos-media-mp4 b/bin/pos-media-mp4 index 520dadd..92be17f 100755 --- a/bin/pos-media-mp4 +++ b/bin/pos-media-mp4 @@ -5,6 +5,19 @@ set -euo pipefail source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" +# --dry-run is parsed before the deps guards (below): it previews the yt-dlp +# command without requiring yt-dlp/ffmpeg to be installed. +DRY_RUN=0 +for arg in "$@"; do + [ "$arg" = "--dry-run" ] && DRY_RUN=1 +done + +# Deps guards sit before -h|--help (help also errors on a box missing the deps). +if [ "$DRY_RUN" -eq 0 ]; then + command -v yt-dlp &>/dev/null || err "yt-dlp not found — install it with: sudo apt install yt-dlp" + command -v ffmpeg &>/dev/null || err "ffmpeg not found (needed for MP4 merge) — install it with: sudo apt install ffmpeg" +fi + OUT_DIR="$HOME/Videos" FORMAT="" BEST=0 @@ -36,7 +49,6 @@ EOF exit 0 } -DRY_RUN=0 URL="" while [[ $# -gt 0 ]]; do case "$1" in @@ -66,12 +78,6 @@ done && err "--format and --best/--worst are mutually exclusive" [ "$BEST" -eq 1 ] && [ "$WORST" -eq 1 ] && err "--best and --worst are mutually exclusive" -# Dependency checks (skipped under --dry-run: the command is only previewed). -if [ "$DRY_RUN" -eq 0 ]; then - command -v yt-dlp &>/dev/null || err "yt-dlp not found — install it with: sudo apt install yt-dlp" - command -v ffmpeg &>/dev/null || err "ffmpeg not found (needed for MP4 merge) — install it with: sudo apt install ffmpeg" -fi - # Resolve the format selector: explicit flag → smart preset → interactive. # The curated table goes to stderr; stdout carries ONLY the chosen format id # (same ui_pick lesson — a $(...) capture must not swallow the menu). diff --git a/bin/pos-network-download b/bin/pos-network-download index 42d2a74..54f1e22 100755 --- a/bin/pos-network-download +++ b/bin/pos-network-download @@ -13,7 +13,6 @@ command -v curl &>/dev/null || err "curl not found (install curl)" # ── Config / seams (env overrides for tests) ─────────────────── RPC_PORT="${RPC_PORT:-6800}" RPC_URL="http://127.0.0.1:$RPC_PORT/jsonrpc" -CONFIG_DIR="${CONFIG_DIR:-$HOME/.config/linux_post_install}" CONFIG_FILE="$CONFIG_DIR/download.env" DOWNLOAD_DIR="${DOWNLOAD_DIR:-$HOME/Downloads}" USER_SYSTEMD_DIR="${USER_SYSTEMD_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user}" diff --git a/bin/pos-network-scan b/bin/pos-network-scan index a210881..10a5e89 100755 --- a/bin/pos-network-scan +++ b/bin/pos-network-scan @@ -24,6 +24,12 @@ EOF exit 0 } +# Deps guard before -h|--help (help also errors without nmap). +if ! command -v nmap &>/dev/null; then + echo "ERROR: nmap is required. Install with: sudo apt install nmap" + exit 1 +fi + case "${1:-}" in -h|--help|"") usage ;; esac @@ -63,11 +69,6 @@ else exit 1 fi -if ! command -v nmap &>/dev/null; then - echo "ERROR: nmap is required. Install with: sudo apt install nmap" - exit 1 -fi - # ── Estimate host count ──────────────────────────────────────── cidr_bits="${net##*/}" if [[ "$cidr_bits" -ge 24 ]]; then diff --git a/bin/pos-share-usb-server b/bin/pos-share-usb-server index d9bfd6e..7aba683 100755 --- a/bin/pos-share-usb-server +++ b/bin/pos-share-usb-server @@ -185,6 +185,9 @@ cmd_port() { warn "Restart your server for port changes to take effect." } +command -v usbsrv &>/dev/null \ + || err "usbsrv not found — install the USB Redirector server (https://www.incentivespro.com/usb-server.html)" + cmd="${1:-}" case "$cmd" in @@ -196,9 +199,6 @@ case "$cmd" in *) err "Unknown flag '$cmd'" ;; esac -command -v usbsrv &>/dev/null \ - || err "usbsrv not found — install the USB Redirector server (https://www.incentivespro.com/usb-server.html)" - case "$cmd" in --ls) cmd_ls ;; --ls-shared) cmd_ls_shared ;; diff --git a/bin/pos-system-firewall b/bin/pos-system-firewall index 27a4b6d..b7f1ecf 100755 --- a/bin/pos-system-firewall +++ b/bin/pos-system-firewall @@ -14,6 +14,23 @@ source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0") HISTORY=() DRY_RUN=0 +usage() { + cat <<'EOF' +Usage: sudo pos system firewall [--dry-run] + +Interactive UFW management menu. + +Options: + --dry-run Preview commands without executing them + -h, --help Show this help +EOF + exit 0 +} + +case "${1:-}" in + -h|--help) usage ;; +esac + if [[ "${1:-}" == "--dry-run" ]]; then DRY_RUN=1 fi diff --git a/features/autostart.sh b/features/autostart.sh index 79f409a..8629e6e 100755 --- a/features/autostart.sh +++ b/features/autostart.sh @@ -1,4 +1,40 @@ #!/usr/bin/env bash +set -euo pipefail + +# ──────────────────────────────────────────────────────────────── +# autostart feature — tiny boot-time log marker. +# +# Installed on demand with: ./install.sh --feature +# → copied to /usr/local/bin/autostart.sh (chmod 755) +# → flag "autostart" is set +# ──────────────────────────────────────────────────────────────── + +# Robust flags.sh load — works from the repo checkout AND from +# /usr/local/bin after install.sh (which copies lib/flags.sh there). +source "$(dirname "$0")/../lib/flags.sh" 2>/dev/null || source "$(dirname "$0")/flags.sh" + +# Self-name → matches the flag install.sh sets for this feature. +FEATURE_NAME="$(basename "$0")" +FEATURE_NAME="${FEATURE_NAME%.sh}" + +usage() { + cat </dev/null || source "$(dirname "$0")/flags.sh" + # ──────────────────────────────────────────────────────────────── # USB automount feature — auto-mounts removable USB storage. # diff --git a/install.sh b/install.sh index 256ce2f..3877386 100755 --- a/install.sh +++ b/install.sh @@ -22,6 +22,31 @@ RUN_FEATURES=0 SKIP_PHASES="" STEPS_SPEC="" +# Expand range syntax in a --steps spec ("1-3" → "1,2,3"; also "1,3-4,6"). +normalize_steps_spec() { + local spec="$1" out="" part from to p + local oldIFS="$IFS" + IFS=',' + for part in $spec; do + if [[ "$part" =~ ^([0-9]+)-([0-9]+)$ ]]; then + from="${BASH_REMATCH[1]}" + to="${BASH_REMATCH[2]}" + if [ "$to" -lt "$from" ]; then + err "Invalid --steps range '$part' (end < start)" + fi + for ((p = from; p <= to; p++)); do + out="${out:+$out,}$p" + done + elif [[ "$part" =~ ^[0-9]+$ ]]; then + out="${out:+$out,}$part" + else + err "Invalid --steps value '$part' (expected 1-4, comma list, or N-M range)" + fi + done + IFS="$oldIFS" + printf '%s' "$out" +} + usage() { cat < # interactive numbered-menu editor for one scope -CONFIG_DIR="$HOME/.config/linux_post_install" +CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}" # common.sh helpers (guarded so the lib is safe if common.sh wasn't loaded) declare -F log >/dev/null || log() { echo "[+] $*"; } @@ -124,7 +124,7 @@ _cfg_plugin_keys() { } declare -F config_keys >/dev/null 2>&1 || return 0 local pdir line plugin key desc req - pdir="$(plugin_dir)" + pdir="$(ent_plugin_dir)" while IFS= read -r line; do [ -n "$line" ] || continue IFS='|' read -r plugin key desc req <<<"$line" diff --git a/lib/entertainment-lib.sh b/lib/entertainment-lib.sh index 4600180..cf898b3 100644 --- a/lib/entertainment-lib.sh +++ b/lib/entertainment-lib.sh @@ -1,8 +1,8 @@ # Shared library for the entertainment module (pos entertainment *). -# Sourced by the bin/pos-entertainment-* tools AFTER lib/common.sh. -# NOTE: plugins themselves must NOT source this — their stdout is the message. +# Sourced by the bin/pos-entertainment-* tools AFTER lib/common.sh +# (which defines CONFIG_DIR). NOTE: plugins themselves must NOT source +# this — their stdout is the message. -CONFIG_DIR="$HOME/.config/linux_post_install" CONFIG_FILE="$CONFIG_DIR/entertainment.env" TIMER_PREFIX="pos-entertainment" DEFAULT_INTERVAL="daily" @@ -54,7 +54,7 @@ write_config_key() { } # ── Plugin lookup ────────────────────────────────────────────────── -plugin_dir() { +ent_plugin_dir() { if [ -n "${ENTERTAINMENT_DIR:-}" ]; then echo "$ENTERTAINMENT_DIR" elif [ -d "$(dirname "$0")/../entertainment" ]; then @@ -64,7 +64,7 @@ plugin_dir() { fi } -plugin_marker() { +ent_plugin_marker() { grep -m1 '^# POS_PLUGIN:' "$1" 2>/dev/null | sed 's/^# POS_PLUGIN:[[:space:]]*//;s/[[:space:]]*$//' || true } @@ -72,15 +72,15 @@ list_plugins() { local dir="$1" f name for f in "$dir"/*.sh; do [ -f "$f" ] || continue - name="$(plugin_marker "$f")" + name="$(ent_plugin_marker "$f")" [ -n "$name" ] && echo "$name" done } -plugin_exists() { +ent_plugin_exists() { local dir="$1" name="$2" f for f in "$dir/$name" "$dir/$name.sh"; do - [ -f "$f" ] && [ -x "$f" ] && [ -n "$(plugin_marker "$f")" ] && return 0 + [ -f "$f" ] && [ -x "$f" ] && [ -n "$(ent_plugin_marker "$f")" ] && return 0 done return 1 } @@ -90,7 +90,7 @@ resolve_plugin() { [ -n "$name" ] || err "No plugin given" for f in "$dir/$name" "$dir/$name.sh"; do if [ -f "$f" ] && [ -x "$f" ]; then - [ -n "$(plugin_marker "$f")" ] || err "'$f' is not an entertainment plugin (missing '# POS_PLUGIN:' header)" + [ -n "$(ent_plugin_marker "$f")" ] || err "'$f' is not an entertainment plugin (missing '# POS_PLUGIN:' header)" echo "$f" return 0 fi @@ -105,8 +105,8 @@ resolve_plugin() { # config' prints them in its Keys section; config set warns when a key is # not declared by any installed plugin. -plugin_keys() { - # Usage: plugin_keys → "KEY|description|required|optional" +ent_plugin_keys() { + # Usage: ent_plugin_keys → "KEY|description|required|optional" local file="$1" line key desc req grep '^# POS_KEYS:' "$file" 2>/dev/null | sed 's/^# POS_KEYS:[[:space:]]*//' | while IFS= read -r line; do key="${line%% *}" @@ -125,12 +125,12 @@ config_keys() { local dir="$1" f name line for f in "$dir"/*.sh; do [ -f "$f" ] || continue - name="$(plugin_marker "$f")" + name="$(ent_plugin_marker "$f")" [ -n "$name" ] || continue while IFS= read -r line; do [ -n "$line" ] || continue printf '%s|%s\n' "$name" "$line" - done <<< "$(plugin_keys "$f")" + done <<< "$(ent_plugin_keys "$f")" done } @@ -160,7 +160,7 @@ parse_enabled() { IFS=' ' read -ra toks <<<"$(printf '%s' "$raw" | tr ',' ' ')" for token in "${toks[@]}"; do [ -n "$token" ] || continue - if plugin_exists "$(plugin_dir)" "$token"; then + if ent_plugin_exists "$(ent_plugin_dir)" "$token"; then [ -n "$cur" ] && ENABLED_ENTRIES+=("$cur") cur="$token" elif [ -n "$cur" ]; then @@ -240,7 +240,7 @@ sync_systemd() { for entry in "${ENABLED_ENTRIES[@]}"; do plugin="${entry%%,*}"; interval="${entry##*,}" [ "$interval" = "$plugin" ] && interval="$DEFAULT_INTERVAL" - if ! plugin_exists "$(plugin_dir)" "$plugin"; then + if ! ent_plugin_exists "$(ent_plugin_dir)" "$plugin"; then warn "plugin '$plugin' not installed — skipping" continue fi diff --git a/lib/notify.sh b/lib/notify.sh index 3efe1de..15622f3 100644 --- a/lib/notify.sh +++ b/lib/notify.sh @@ -24,7 +24,7 @@ # Silent-fails per platform: a missing sender or a failed send only warns and # never changes the caller's exit code. -CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install" +CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}" # Platform key → sender tool name (bin/pos-communication-). notify_sender_name() { @@ -54,7 +54,7 @@ notify_send() { done if [ -z "$msg" ]; then - warn "notify_send: empty message, skipped" 2>/dev/null || echo "notify_send: empty message, skipped" + warn "notify_send: empty message, skipped" 2>/dev/null || echo "notify_send: empty message, skipped" >&2 return 0 fi diff --git a/preinstall.sh b/preinstall.sh index ae15b63..cacbf43 100755 --- a/preinstall.sh +++ b/preinstall.sh @@ -37,6 +37,7 @@ PACKAGES=( lm-sensors smartmontools nvme-cli hdparm sysstat iotop atop vnstat python3 python3-pip rclone + ffmpeg libqrencode4 libgtk-3-0 adb ) diff --git a/scripts/lint-conventions.sh b/scripts/lint-conventions.sh new file mode 100755 index 0000000..053f2bf --- /dev/null +++ b/scripts/lint-conventions.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +fails=0 +warns=0 + +fail() { fails=$((fails + 1)); printf 'FAIL %s\n' "$1"; } +warn_() { warns=$((warns + 1)); printf 'WARN %s\n' "$1"; } + +shell_files() { + printf '%s\n' bin/pos bin/pos-* lib/*.sh features/*.sh entertainment/*.sh \ + apps/*/*.sh templates/*.sh scripts/*.sh install.sh preinstall.sh postinstall.sh \ + 2>/dev/null +} + +executable_files() { + printf '%s\n' bin/pos-* entertainment/*.sh 2>/dev/null +} + +has_regex() { + local file="$1" re="$2" + grep -qE "$re" "$file" && return 0 + return 1 +} + +first_line() { + local file="$1" re="$2" + grep -nE "$re" "$file" 2>/dev/null | while IFS=: read -r ln rest; do + [ -z "$ln" ] && continue + [[ "$rest" =~ ^[[:space:]]*# ]] && continue + printf '%s' "$ln" + break + done +} + +last_line() { + local file="$1" re="$2" + grep -nE "$re" "$file" 2>/dev/null | tail -1 | cut -d: -f1 || true +} + +# First deps guard: a `command -v` line that hard-fails the tool when the binary +# is absent — i.e. `command -v X … || err`, `if ! command -v X …`, or a +# `command -v X … \` multi-line continuation. Runtime capability probes like +# `if command -v X; then` (graceful degradation, e.g. system-health) are NOT +# guards and must not trigger the guard-before-help rule. +first_guard_line() { + local file="$1" ln=0 + while IFS= read -r line || [ -n "$line" ]; do + ln=$((ln + 1)) + [[ "$line" == *"command -v"* ]] || continue + [[ "$line" == *"||"* || "$line" == *"if !"* || "$line" == *\\ ]] && { printf '%s' "$ln"; return; } + done < "$file" + printf '%s' "" +} + +uses_stdin() { + local file="$1" line heredoc="" + while IFS= read -r line; do + if [ -n "$heredoc" ]; then + [ "$line" = "$heredoc" ] && heredoc="" + continue + fi + local delim + delim="$(printf '%s\n' "$line" | sed -nE 's/.*<<-?[[:space:]]*([A-Za-z0-9_]+).*/\1/p' | tail -1)" + [ -n "$delim" ] && { heredoc="$delim"; continue; } + case "$line" in + *'read -'*|*'read '*|*'select '*|*'confirm '*|*'confirm('*) ;; + *) continue ;; + esac + [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ "$line" == *"/dev/tty"* ]] && continue + [[ "$line" =~ (while|until)[[:space:]].*read ]] && continue + [[ "$line" =~ [[:space:]]\< ]] && continue + return 0 + done < "$file" + return 1 +} + +INTERACTIVE_CMDS="" +if [ -f bin/pos ]; then + INTERACTIVE_CMDS="$(sed -n 's/^INTERACTIVE_CMDS="\(.*\)"$/\1/p' bin/pos | head -1)" +fi + +for f in $(shell_files); do + [ -f "$f" ] || continue + case "$f" in + lib/*.sh) continue ;; # libraries are sourced, never executed + esac + if ! head -1 "$f" | grep -q '^#!/usr/bin/env bash'; then + fail "$f: missing '#!/usr/bin/env bash' shebang" + fi + if ! has_regex "$f" '^set -euo pipefail'; then + fail "$f: missing 'set -euo pipefail'" + fi +done + +for f in $(executable_files); do + [ -f "$f" ] || continue + if [ ! -x "$f" ]; then + fail "$f: not executable (needs chmod +x, committed as 100755)" + fi +done + +for f in bin/pos-*; do + [ -f "$f" ] || continue + + headline="$(sed -n '/^# POS: /{s/^# POS: //;p;q}' "$f" 2>/dev/null)" + if [ -z "$headline" ]; then + fail "$f: missing '# POS:' header" + continue + fi + if ! grep -q ' — ' <<<"$headline"; then + fail "$f: '# POS:' header missing em-dash ' — ' (format: '# POS: ')" + fi + posline="$(grep -nE '^# POS: ' "$f" | head -1 | cut -d: -f1 || true)" + if [ "${posline:-99}" -gt 6 ]; then + warn_ "$f: '# POS:' header on line $posline (convention: right after shebang/strict-mode)" + fi + + if ! has_regex "$f" '\-h\|\-\-help'; then + fail "$f: missing -h|--help handling" + fi + + guard="$(first_guard_line "$f")" + help_line="$(first_line "$f" '\-h\|\-\-help')" + if [ -n "$guard" ] && [ -n "$help_line" ] && [ "$help_line" -lt "$guard" ]; then + fail "$f: -h|--help (line $help_line) dispatched before deps guards (line $guard) — help must error on missing deps" + fi + + local_line="" + lineno=0 + depth=0 + heredoc="" + while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno + 1)) + if [ -n "$heredoc" ]; then + [ "$line" = "$heredoc" ] && heredoc="" + continue + fi + delim="$(printf '%s\n' "$line" | sed -nE 's/.*<<-?[[:space:]]*([A-Za-z0-9_]+).*/\1/p' | tail -1)" + if [ -n "$delim" ]; then + heredoc="$delim" + continue + fi + if [[ "$line" =~ ^[[:space:]]*local[[:space:]] ]]; then + if [ "$depth" -eq 0 ]; then + [ -n "$local_line" ] || local_line="$lineno" + fi + fi + opens="${line//[^{]/}" + closes="${line//[^\}]/}" + depth=$((depth + ${#opens} - ${#closes})) + [ "$depth" -lt 0 ] && depth=0 + done < "$f" + if [ -n "$local_line" ]; then + warn_ "$f: '$local_line': 'local' at top-level brace depth (invalid in bash outside a function)" + fi + + if uses_stdin "$f"; then + name="${f#bin/pos-}" + if ! [[ " $INTERACTIVE_CMDS " == *" $name "* ]]; then + fail "$f: reads stdin but NOT in INTERACTIVE_CMDS in bin/pos (log tee will swallow/hang prompts)" + fi + fi + + if ! grep -q "$(basename "$f")" DOC/POS.md 2>/dev/null; then + warn_ "$f: file not referenced in DOC/POS.md" + fi +done + +if [ -n "$INTERACTIVE_CMDS" ]; then + for entry in $INTERACTIVE_CMDS; do + if [ ! -x "bin/pos-$entry" ]; then + fail "bin/pos: INTERACTIVE_CMDS entry '$entry' has no matching bin/pos-$entry tool" + fi + done +fi + +for f in entertainment/*.sh; do + [ -f "$f" ] || continue + if has_regex "$f" 'common\.sh'; then + fail "$f: entertainment plugin must NOT source lib/common.sh (stdout is the Telegram message)" + fi + if ! has_regex "$f" '^# POS_PLUGIN: '; then + fail "$f: missing '# POS_PLUGIN:' marker" + fi +done + +for f in apps/*/*.sh; do + [ -f "$f" ] || continue + app="${f##*/}" + app="${app%.sh}" + if ! has_regex "$f" "uninstall_${app//-/_}\(\).*\{"; then + if ! has_regex "$f" "uninstall_${app}\(\).*\{"; then + fail "$f: missing 'uninstall_${app}()' function" + fi + fi + if ! has_regex "$f" 'uninstall\)'; then + fail "$f: missing 'uninstall' dispatch case" + fi +done + +for f in systemd/*.service; do + [ -f "$f" ] || continue + if ! has_regex "$f" '^TimeoutStopSec='; then + warn_ "$f: missing 'TimeoutStopSec=5s' (convention: a stuck process must not stall reboot 90s)" + fi + if ! has_regex "$f" '^WantedBy='; then + warn_ "$f: missing '[Install] WantedBy='" + fi +done + +for f in bin/wr-* bin/mp3 bin/mp4 bin/vbox bin/ssh-load-all; do + [ -f "$f" ] || continue + if ! has_regex "$f" '\bpos\b'; then + fail "$f: legacy wrapper does not forward to 'pos'" + fi + if [ "$(wc -l < "$f")" -gt 12 ]; then + warn_ "$f: legacy wrapper has $(wc -l < "$f") lines (convention: thin forwarder only)" + fi + if has_regex "$f" '^[[:space:]]*case '; then + warn_ "$f: legacy wrapper contains a case statement (should be a thin forwarder)" + fi +done + +while IFS= read -r f; do + [ -f "$f" ] || continue + while IFS= read -r line; do + [[ "$line" =~ ^[0-9]+: ]] || continue + num="${line%%:*}" + body="${line#*:}" + if grep -qE '(TOKEN|PASSWORD|PASSWD|SECRET|API[_-]?KEY|ACCESS[_-]?TOKEN|AUTH[_-]?KEY)=' <<<"$body"; then + val="${body#*=}" + case "$val" in + ""|*'$'*) ;; + *) warn_ "$f:$num: secret-like literal assignment (manual review for hardcoded credentials)" ;; + esac + fi + done < <(grep -nE '^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*(TOKEN|PASSWORD|PASSWD|SECRET|API[_-]?KEY|ACCESS[_-]?TOKEN|AUTH[_-]?KEY)=' "$f" 2>/dev/null || true) +done < <(printf '%s\n' bin/pos bin/pos-* lib/*.sh features/*.sh entertainment/*.sh install.sh preinstall.sh postinstall.sh) + +while IFS= read -r f; do + [ -f "$f" ] || continue + while IFS= read -r line; do + [[ "$line" =~ ^[0-9]+: ]] || continue + num="${line%%:*}" + body="${line#*:}" + if grep -qE '(>|>>|tee )' <<<"$body" && grep -qE '(/etc/|\$HOME|/usr/local)' <<<"$body"; then + case "$body" in + *'command -v'*|*'|| echo'*) ;; + *) warn_ "$f:$num: writes to a system path (verify a VAR=\"\${VAR:-path}\" test seam exists)" ;; + esac + fi + done < <(grep -nE '(\btee\b|>>?)[^#]*?(/etc/|\$HOME|/usr/local)' "$f" 2>/dev/null || true) +done < <(printf '%s\n' bin/pos-* lib/*.sh features/*.sh entertainment/*.sh) + +printf '\n%d FAIL, %d WARN (convention lint)\n' "$fails" "$warns" +[ "$fails" -eq 0 ] || exit 1