From 2c77e73799fac0856dee4ee13256cc2874a133f9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 11 Sep 2026 12:47:42 -0400 Subject: [PATCH] . --- AGENT_TODO.md | 2 + DOC/AGENT_Context_Project.md | 31 ++- DOC/POS.md | 14 + DOC/SYSTEMD.md | 9 +- DOC/howto/system.md | 122 ++++++++- bin/pos | 2 +- bin/pos-system-alias | 488 +++++++++++++++++++++++++++++++++++ completions/pos.bash | 1 + postinstall.sh | 5 + 9 files changed, 655 insertions(+), 19 deletions(-) create mode 100755 bin/pos-system-alias diff --git a/AGENT_TODO.md b/AGENT_TODO.md index eb06119..cf8fcd2 100644 --- a/AGENT_TODO.md +++ b/AGENT_TODO.md @@ -42,6 +42,8 @@ summary (newest last). ## Done +- **2026-09-09** — New `pos system alias` tool (Architect→Builder→Reviewer→Writer): persistent command aliases via wrapper scripts in `~/.local/bin/`. Interactive menu (create/edit/remove/list/show), storage at `~/.config/linux_post_install/aliases.env` (pipe-delimited `name|command`), wrapper sync on every invocation, name validation (`^[a-zA-Z][a-zA-Z0-9_-]*$`), ownership markers, collision checks. Docs: POS.md system category + detail block, howto/system.md recipes section. Verified: `bash -n`, `make gen` byte-idempotent, `make check` OK, `make lint` 0 FAIL / 0 WARN. + - **2026-09-09** — Telegram listener single-instance guard (Toolsmith): `bin/pos-communication-telegram-listener --run` now takes a `flock(1)` on `${XDG_RUNTIME_DIR:-/tmp}/pos-telegram-listener.lock` inside `run_daemon()` (before config load/sync/poll loop) — a second `--run` on the same token fails fast (exit 1, `ERROR: listener already running (single instance) — check: systemctl --user status pos-telegram-listener`), never racing getUpdates (Telegram 409/command stealing). Kernel auto-release → no stale-lock bookkeeping, systemd `Restart=always` restarts clean. `--status` first line now reports `listener: running (single instance lock held)` / `listener: not running` via the same `lock_held()` probe. `flock` dep guard added (`util-linux`). New regression `tests/t-telegram-listener-singleton.sh` (7 checks: first acquires+loops, second exits 1 with exact message, lock releases → third starts clean, status reports both states; stubbed curl/systemctl, sandboxed XDG_RUNTIME_DIR — hermetic, no network). Verified: `bash -n`, `make gen` ×2 byte-idempotent, `make check` OK, `make lint` 0 FAIL / 0 WARN, `make test` green, `git diff --check` clean. - **2026-09-09** — Unified YouTube tools into `pos media yt` + new `subtitles` (Architect POS--9). New `bin/pos-media-yt` dispatcher (mp3/mp4/grab/ytsync/subtitles) + `bin/pos-media-yt-{mp3,mp4,grab,subtitles,ytsync}`; the ytsync file is a forwarder to the existing `pos media ytsync`; legacy `bin/pos-media-{mp3,mp4,grab}` became thin forwarders to the `yt` forms. New `lib/yt-lib.sh` (deps/URL-validation/echo/classify helpers; `classify_url` migrated from grab, `yt_validate_url` is a return-1 checker — never exits, so callers can prefix errors). `bin/pos` INTERACTIVE_CMDS += `media-yt-mp4` (interactive format pick reads stdin). `pos-media-yt-subtitles` extracts captions via `--write-subs --write-auto-subs --sub-langs best`, `--lang en,ar` (one `--sub-langs` arg), `srt|vtt|txt` (txt = srt→txt conversion stripping timestamps/HTML), `--auto-only`, `--list-subs` probe, `--output`, no-ffmpeg dep (yt-dlp only; dry-run skips deps entirely). Docs: DOC/POS.md media section rewritten (yt group + forwarder rows), DOC/howto/media.md `yt` commands + subtitles section, AGENT_Context hand-maintained `lib/yt-lib.sh` row, tests/README row. New `tests/t-pos-media-yt.sh` (72 checks: dispatcher/forwarder resolution, full `pos media mp3` dispatch chain, yt-lib helpers, per-tool flags/dry-run/`YT_OUT_DIR` seam/`GRAB_DEFAULT` config, 3 mandated negative controls — unsafe-URL no-expansion, `--lang en,ar` single arg, txt timestamp-stripping, unavailable-subs detection). `tests/t-config-precedence.sh` Part D config-consumer list updated `pos-media-grab` → `pos-media-yt-grab`. Verified: `bash -n` all; `make gen` ×2 byte-idempotent; `make check` OK; `make lint` 0 FAIL / 0 WARN; `make test` 21 files / 533 checks / 0 fail / 0 skip; `git diff --check` clean; smokes — `pos media yt --help`, `yt mp3/mp4/subtitles --help`, `yt ytsync --help` (reaches `pos media ytsync`), `pos media mp3 --help` forwarder, `pos tree` shows the `yt` subtree (with repo-first PATH; system `/usr/local/bin` has a stale pre-POS--9 install that shadows it otherwise). diff --git a/DOC/AGENT_Context_Project.md b/DOC/AGENT_Context_Project.md index c5b79bd..327380b 100644 --- a/DOC/AGENT_Context_Project.md +++ b/DOC/AGENT_Context_Project.md @@ -10,19 +10,19 @@ | ## 1. Project Overview | 28–43 | -| ## 2. Directory Structure | 44–216 | -| ## 3. Installation Flow | 217–275 | -| ## 4. The `pos` CLI System | 276–363 | -| ## 5. Shared Library — `lib/common.sh` | 364–395 | -| ## 6. Docker Compose / ScaleTail | 396–438 | -| ## 7. Optional Apps (`apps/`) | 439–468 | -| ## 8. Entertainment Module | 469–482 | -| ## 9. Systemd Services | 483–494 | -| ## 10. Configuration Files | 495–521 | -| ## 11. Coding Conventions | 522–554 | -| ## 12. Development Workflow | 555–607 | -| ## 13. Key File Quick Reference | 608–690 | -| ## 14. Common Tasks for Agents | 691–724 | +| ## 2. Directory Structure | 44–217 | +| ## 3. Installation Flow | 218–276 | +| ## 4. The `pos` CLI System | 277–365 | +| ## 5. Shared Library — `lib/common.sh` | 366–397 | +| ## 6. Docker Compose / ScaleTail | 398–440 | +| ## 7. Optional Apps (`apps/`) | 441–470 | +| ## 8. Entertainment Module | 471–484 | +| ## 9. Systemd Services | 485–496 | +| ## 10. Configuration Files | 497–523 | +| ## 11. Coding Conventions | 524–556 | +| ## 12. Development Workflow | 557–609 | +| ## 13. Key File Quick Reference | 610–693 | +| ## 14. Common Tasks for Agents | 694–727 | ## 1. Project Overview @@ -109,6 +109,7 @@ Linux_post_install/ │ ├── pos-share-smb-server # Manage the Samba server (status, share/unshare exports, users, enable/disable) │ ├── pos-share-usb-server # USB Redirector server control (--ls, --share; prompts when args omitted) │ ├── pos-ssh-load-keys # Load all SSH keys into the agent +│ ├── pos-system-alias # Manage persistent command aliases (wrapper scripts in ~/.local/bin/) │ ├── pos-system-backup # Encrypted (AES-256) folder snapshots (tar + gpg) │ │ [deps: tar] │ ├── pos-system-firewall # Interactive UFW management @@ -335,6 +336,7 @@ All non-interactive `pos` commands log output to `~/.local/share/linux_post_inst | share | smb-server | `pos-share-smb-server` | Manage the Samba server (status, share/unshare exports, users, enable/disable) | | | | share | usb-server | `pos-share-usb-server` | USB Redirector server control (--ls, --share; prompts when args omitted) | | | | ssh | load-keys | `pos-ssh-load-keys` | Load all SSH keys into the agent | | | +| system | alias | `pos-system-alias` | Manage persistent command aliases (wrapper scripts in ~/.local/bin/) | | | | system | backup | `pos-system-backup` | Encrypted (AES-256) folder snapshots (tar + gpg) | tar | | | system | firewall | `pos-system-firewall` | Interactive UFW management | | | | system | health | `pos-system-health` | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL | | | @@ -674,6 +676,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | `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 | +| `bin/pos-system-alias` | 488 | Manage persistent command aliases (wrapper scripts in ~/.local/bin/) | | `bin/pos-system-backup` | 301 | Encrypted (AES-256) folder snapshots (tar + gpg) | | `bin/pos-system-firewall` | 325 | Interactive UFW management | | `bin/pos-system-health` | 209 | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL | @@ -682,7 +685,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | `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` | 316 | Dynamic bash completion | +| `completions/pos.bash` | 317 | Dynamic bash completion | | `apps/install.sh` | 171 | App install/uninstall picker/orchestrator | diff --git a/DOC/POS.md b/DOC/POS.md index 116703a..f18692a 100644 --- a/DOC/POS.md +++ b/DOC/POS.md @@ -296,9 +296,23 @@ reported as "N videos require sign-in — skipped" (escape hatch: | `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. Bare invocation on a terminal (or the `menu` subcommand) opens an interactive hub over these verbs (list, timer status, run-now, enable, disable, config editor) — a menu run-now asks y/N first and goes through the same `run ` path the systemd timers use | | `pos system uninstall` | `bin/pos-system-uninstall` | Safe, interactive uninstaller for the pos toolkit — scans and removes binaries, services, shell integration, config, and data in three tiers | Tier 1 (always): binaries in `/usr/local/bin/` (pos, pos-*, libs, ai-providers, entertainment plugins, prebuilt, features), systemd services (disable+remove) including runtime-created `~/.config/systemd/user/pos-*` user units, ScaleTail templates + feature-flag store under `/usr/local/share/linux_post_install/`, shell integration in `~/.bashrc` (PATH, completion, pos-ai-hook source), completion file. Tier 2 (`--config`): `~/.config/linux_post_install/` (.env files, schedule.d/, authorized_keys, rclone.conf). Tier 3 (`--data`): `~/.local/share/linux_post_install/` (ai sessions, logs, captured output). Flags: `--yes` (skip prompts, tier 1 only), `--config` (include tier 2), `--data` (include tier 3). Combine all three for nuclear removal. Git repo is never removed | +| `pos system alias` | `bin/pos-system-alias` | Manage persistent command aliases — create, edit, remove, list, and show named aliases that map names to shell commands via executable wrapper scripts in `~/.local/bin/` | Aliases stored in `~/.config/linux_post_install/aliases.env` (pipe-delimited: `name\|command`). Each alias materializes as a wrapper script at `~/.local/bin/` (chmod 755) that runs the mapped command with any arguments forwarded. Wrapper scripts are synced automatically on every invocation; changes are live immediately. Name validation: must start with a letter, then letters/digits/hyphens/underscores. Refuses name collisions with existing files on `~/.local/bin/` (unless pos-owned) and existing binaries on `PATH`. Requires `~/.local/bin` on `PATH` — a warning with a copy-paste fix appears when it isn't | 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). +`pos system alias` in detail: + +| Command | Behavior | +|---------|----------| +| `pos system alias` | Interactive menu: create / edit / remove / list aliases; shows the current alias table between picks | +| `pos system alias create [name]` | Interactive 2-step wizard: alias name (must start with a letter, then letters/digits/-/_; unique — collisions with existing files on `~/.local/bin/` or binaries on `PATH` are refused), command (must not contain `\|`); confirms before saving | +| `pos system alias edit [name]` | Edits an existing alias (pick from list or pass the name); shows current values, prompts for the new command (Enter keeps current); saves only if changed | +| `pos system alias remove [name]` | Removes an alias (pick from list or pass the name); confirmation defaults to **no** — removal deletes the wrapper script and cannot be undone | +| `pos system alias list` | Non-interactive: prints all aliases as a Name/Command table (commands truncated at 60 chars) | +| `pos system alias show ` | Prints one alias's details: name, command, wrapper path, and how to test it | + +Alias storage & activation: records live in `~/.config/linux_post_install/aliases.env` — one `name\|command` line per alias, chmod 600, managed by the tool (do not hand-edit). **Activation needs no shell sourcing**: every `pos system alias` invocation syncs the ENV file against executable wrapper scripts at `~/.local/bin/` (chmod 755) — missing or changed wrappers are atomically rewritten, wrappers pos owns but the ENV no longer lists are deleted, and hand-edited wrappers are healed. Wrapper scripts re-read their bytes on every run, so an edit is **live on the next invocation** (no reload), and the scripts work identically in interactive shells, scripts, cron, and non-login ssh sessions (`~/.local/bin` must stay on `PATH` — a loud warning with a copy-paste fix appears when it isn't). Create refuses name collisions: a foreign file at `~/.local/bin/` and names resolving to another binary on `PATH` are never overwritten. + ### ssh | Command | File | Purpose | Configuration | diff --git a/DOC/SYSTEMD.md b/DOC/SYSTEMD.md index 7e784db..80ae763 100644 --- a/DOC/SYSTEMD.md +++ b/DOC/SYSTEMD.md @@ -79,7 +79,7 @@ Restart=on-failure WantedBy=multi-user.target ``` -**Configuration:** socket at `/run/ssh-agent/socket` (world-readable/writable). `~/.bashrc` (set by `postinstall.sh`) exports `SSH_AUTH_SOCK` to it. Not gated on any feature flag. +**Configuration:** socket at `/run/ssh-agent/socket` (world-readable/writable). `~/.bashrc` (set by `postinstall.sh`) exports `SSH_AUTH_SOCK` to it. Gated on the `ssh-agent` feature flag — only enabled when the flag is set (`./install.sh --feature` or `flag-set ssh-agent`). --- @@ -124,7 +124,7 @@ SIGTERM. A oneshot job that happens to be running at shutdown gets SIGKILLed ## Feature-flag gating -The systemd loop in `postinstall.sh` special-cases two units: +The systemd loop in `postinstall.sh` special-cases three units: ```bash if [ "$svc_name" = "autostart.service" ] && ! flag_is_set autostart; then @@ -135,10 +135,15 @@ if [ "$svc_name" = "usb-automount.service" ] && ! flag_is_set usb-automount; the warn "usb-automount feature not installed — skipping usb-automount.service (run ./install.sh --feature)" continue fi +if [ "$svc_name" = "ssh-agent.service" ] && ! flag_is_set ssh-agent; then + warn "ssh-agent feature not installed — skipping ssh-agent.service (run ./install.sh --feature)" + continue +fi ``` - `autostart.service` is **enabled** only when the `autostart` feature flag is set (`./install.sh --feature` or `flag-set autostart`). See [SCRIPTS.md → lib/flags.sh](SCRIPTS.md#libflagssh--feature-flags). - `usb-automount.service` is **enabled** only when the `usb-automount` feature flag is set — same mechanism. +- `ssh-agent.service` is **enabled** only when the `ssh-agent` feature flag is set — same mechanism. --- diff --git a/DOC/howto/system.md b/DOC/howto/system.md index 41a22ad..ecbe93f 100644 --- a/DOC/howto/system.md +++ b/DOC/howto/system.md @@ -1,10 +1,11 @@ # How-To: `pos system` -Host care: encrypted backups, firewall, health dashboard, and uninstall. Tools: -`backup`, `firewall`, `health`, `uninstall`. +Host care: encrypted backups, firewall, health dashboard, persistent aliases, and uninstall. Tools: +`alias`, `backup`, `firewall`, `health`, `uninstall`. | Tool | What it does | |------|--------------| +| `pos system alias` | Manage persistent command aliases (wrapper scripts in `~/.local/bin/`) | | `pos system health` | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker) | | `pos system backup` | gpg-encrypted (AES-256) folder snapshots | | `pos system firewall` | Interactive UFW ("UFW POWER") management | @@ -12,6 +13,122 @@ Host care: encrypted backups, firewall, health dashboard, and uninstall. Tools: --- +## `pos system alias` — persistent command aliases + +Create named shortcuts for shell commands. Each alias becomes an executable +wrapper script in `~/.local/bin/` that runs the mapped command with any +arguments forwarded. + +### Quick start + +```bash +pos system alias # interactive menu +pos system alias list # show all aliases +pos system alias create # interactive create wizard +pos system alias show restart-dns # show one alias's details +``` + +### Examples + +**Create an alias:** + +```bash +pos system alias create restart-dns +# Step 1: Alias name → restart-dns +# Step 2: Command → sudo systemctl restart systemd-resolved +# Confirm → [y] +# Alias 'restart-dns' created. +# Test it: restart-dns +``` + +**Create more aliases:** + +```bash +pos system alias create exit-google +# Command → pkill -f chrome + +pos system alias create update-all +# Command → sudo apt update && sudo apt upgrade -y + +pos system alias create my-ip +# Command → curl -s ifconfig.me +``` + +**Use them directly** (no `pos` needed — just the alias name): + +```bash +restart-dns # runs: sudo systemctl restart systemd-resolved +exit-google # runs: pkill -f chrome +update-all # runs: sudo apt update && sudo apt upgrade -y +my-ip # runs: curl -s ifconfig.me +restart-dns 1.1.1.1 # arguments are forwarded to the command +``` + +**Edit an alias:** + +```bash +pos system alias edit restart-dns +# Shows current command, prompts for new value (Enter = keep current) +``` + +**Remove an alias:** + +```bash +pos system alias remove restart-dns +# Shows details, asks for confirmation (default: no) +``` + +**List all aliases:** + +```bash +pos system alias list +# Name Command +# ---------------- ---------------------------------------- +# restart-dns sudo systemctl restart systemd-resolved +# exit-google pkill -f chrome +``` + +### How it works + +- Aliases are stored in `~/.config/linux_post_install/aliases.env` + (pipe-delimited: `name|command`, chmod 600). +- Each alias is materialized as an executable wrapper at + `~/.local/bin/` (chmod 755). +- Wrappers are synced automatically on every `pos system alias` invocation + — edits are live on the next run. +- Name validation: must start with a letter, then letters/digits/hyphens/ + underscores. Collisions with existing files or PATH binaries are refused. + +### PATH requirement + +`~/.local/bin` must be on your `PATH` for alias scripts to resolve by name. +If it isn't, you'll see a warning with a fix: + +```bash +export PATH="$HOME/.local/bin:$PATH" +# Persist it: +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.profile +``` + +### Recipes + +- **DNS restart shortcut:** `pos system alias create restart-dns` + with command `sudo systemctl restart systemd-resolved`. +- **Quick app launcher:** `pos system alias create open-code` + with command `code ~/projects`. +- **Custom backup alias:** `pos system alias create snap-docs` + with command `pos system backup ~/Documents`. + +### Troubleshooting + +- `Alias 'X' already exists` → use `pos system alias edit X` instead. +- `File '~/.local/bin/X' already exists` → pick a different name (pos + won't overwrite non-pos-owned files). +- `~/.local/bin is not on your PATH` → add it to `~/.profile` (see above). +- Alias name autocompletes stale after removal → run `hash -r`. + +--- + ## `pos system health` — host health dashboard ```bash @@ -240,3 +357,4 @@ confirmation. The git repo is **never** removed — delete it manually if desire - Reference: [DOC/POS.md → system](../POS.md) - Notify platform config: [communication.md](communication.md) - Backup roots shared with health: `system.env` ([DOC/POS.md](../POS.md)) +- Alias storage: `~/.config/linux_post_install/aliases.env` ([DOC/POS.md → pos system alias](../POS.md#pos-system-alias-in-detail)) diff --git a/bin/pos b/bin/pos index cdaf007..072486b 100755 --- a/bin/pos +++ b/bin/pos @@ -266,7 +266,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="docker-compose docker-vbox network-hotspot system-firewall media-mp4 media-yt-mp4 media-sync system-backup system-uninstall share-usb-server share-smb-server share-smb-client share-nfs-client share-nfs-server communication-telegram-listener communication-matrix-listener ai ai-gemini ai-openrouter ai-llamacpp ai-alias system-schedule entertainment-config config" +INTERACTIVE_CMDS="docker-compose docker-vbox network-hotspot system-firewall media-mp4 media-yt-mp4 media-sync system-backup system-uninstall share-usb-server share-smb-server share-smb-client share-nfs-client share-nfs-server communication-telegram-listener communication-matrix-listener ai ai-gemini ai-openrouter ai-llamacpp ai-alias system-alias system-schedule entertainment-config config" for ((i=n-1; i>=0; i--)); do cmd="pos" diff --git a/bin/pos-system-alias b/bin/pos-system-alias new file mode 100755 index 0000000..b923ea1 --- /dev/null +++ b/bin/pos-system-alias @@ -0,0 +1,488 @@ +#!/usr/bin/env bash +set -euo pipefail +# POS: system alias — Manage persistent command aliases (wrapper scripts in ~/.local/bin/) +# POS_SUBCMDS: create edit remove list show + +source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" +source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh" + +# ── Paths & constants ────────────────────────────────────────── +ENV_FILE="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}/aliases.env" + +# ── Core helpers ─────────────────────────────────────────────── + +# Load aliases from ENV_FILE into parallel arrays. +# Loop vars use _a* prefix to avoid dynamic-scope collisions with callers. +_alias_load() { + _ALIAS_NAMES=(); _ALIAS_COMMANDS=() + [ -f "$ENV_FILE" ] || return 0 + local _an _ac + while IFS='|' read -r _an _ac; do + [[ "$_an" =~ ^[[:space:]]*# ]] && continue + [[ -z "${_an// /}" ]] && continue + _an="${_an## }"; _an="${_an%% }" + [[ "$_an" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]] || continue + _ac="${_ac## }"; _ac="${_ac%% }" + _ALIAS_NAMES+=("$_an") + _ALIAS_COMMANDS+=("$_ac") + done < <(grep -v '^[[:space:]]*#' "$ENV_FILE" | grep -v '^[[:space:]]*$' || true) +} + +# Save parallel arrays back to ENV_FILE (atomic overwrite). +_alias_save() { + mkdir -p "$(dirname "$ENV_FILE")" + { + printf '%s\n' "# System aliases — managed by pos system alias (do not hand-edit)" + printf '%s\n' "# Format: alias_name|command" + local i + for ((i = 0; i < ${#_ALIAS_NAMES[@]}; i++)); do + printf '%s|%s\n' "${_ALIAS_NAMES[$i]}" "${_ALIAS_COMMANDS[$i]}" + done + } >"$ENV_FILE" + chmod 600 "$ENV_FILE" +} + +# ── Wrapper scripts ──────────────────────────────────────────── + +_wrapper_path() { + printf '%s/.local/bin/%s' "$HOME" "$1" +} + +# Ownership test: line 2 must carry our generator marker. +_alias_owned() { + [ -f "$1" ] && sed -n '2p' "$1" 2>/dev/null | grep -q 'Managed by pos system alias' +} + +# Render one wrapper to stdout (args: name command). +_wrapper_render() { + local name="$1" command="$2" + cat <"$tmp" + if cmp -s "$tmp" "$path" 2>/dev/null; then + rm -f "$tmp" + return 0 + fi + if ! bash -n "$tmp" 2>/dev/null; then + warn "Wrapper for '$name' failed syntax check — keeping previous version" >&2 + rm -f "$tmp" + return 1 + fi + mv "$tmp" "$path" + chmod 755 "$path" +} + +# rc 0 iff ~/.local/bin is on PATH. +_alias_check_path() { + case ":$PATH:" in + *":$HOME/.local/bin:"*) return 0 ;; + *) return 1 ;; + esac +} + +# Two-way reconciliation on every invocation: +# forward: each ENV entry → render-diff-install +# reverse: owned wrappers whose name is not in ENV → deleted +# plus: PATH guidance when owned wrappers exist but ~/.local/bin is absent +_alias_sync() { + _alias_load + local bin_dir="${HOME}/.local/bin" i name f base match any=0 + mkdir -p "$bin_dir" + for ((i = 0; i < ${#_ALIAS_NAMES[@]}; i++)); do + _wrapper_install "${_ALIAS_NAMES[$i]}" "${_ALIAS_COMMANDS[$i]}" || : + done + for f in "$bin_dir"/*; do + [ -f "$f" ] || continue + _alias_owned "$f" || continue + base="${f##*/}" + match=0 + for name in ${_ALIAS_NAMES[@]+"${_ALIAS_NAMES[@]}"}; do + [ "$base" = "$name" ] && { match=1; break; } + done + [ "$match" -eq 1 ] || rm -f "$f" + done + if ! _alias_check_path; then + for f in "$bin_dir"/*; do + [ -f "$f" ] && _alias_owned "$f" && { any=1; break; } + done + if [ "$any" -eq 1 ]; then + warn "~/.local/bin is not on your PATH — alias scripts will not resolve by name." + warn " Fix now: export PATH=\"\$HOME/.local/bin:\$PATH\"" + warn " Persist it: echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.profile" + fi + fi + return 0 +} + +_alias_find() { + local name="$1" i + for ((i = 0; i < ${#_ALIAS_NAMES[@]}; i++)); do + if [ "${_ALIAS_NAMES[$i]}" = "$name" ]; then + echo "$i" + return 0 + fi + done + echo "-1" + return 0 +} + +_alias_name_valid() { + [[ "$1" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]] +} + +# Truncate a command string for display (inline pipes, redirects). +_command_truncate() { + local s="$1" max="${2:-42}" + s="${s//$'\n'/ }" + if [ ${#s} -gt "$max" ]; then + printf '%s…' "${s:0:max}" + else + printf '%s' "$s" + fi +} + +# ── Non-interactive output ───────────────────────────────────── + +_alias_table() { + local count=${#_ALIAS_NAMES[@]} i + [ "$count" -eq 0 ] && return 0 + printf ' %-16s %s\n' "Name" "Command" + printf ' %-16s %s\n' "----------------" "----------------------------------------" + for ((i = 0; i < count; i++)); do + printf ' %-16s %s\n' "${_ALIAS_NAMES[$i]}" "$(_command_truncate "${_ALIAS_COMMANDS[$i]}" 60)" + done +} + +_alias_list() { + printf 'Aliases (%d):\n' "${#_ALIAS_NAMES[@]}" + _alias_table +} + +_alias_show() { + local idx + idx="$(_alias_find "$1")" + [ "$idx" = "-1" ] && err "Alias '$1' not found" + local name="${_ALIAS_NAMES[$idx]}" command="${_ALIAS_COMMANDS[$idx]}" + printf ' %-12s %s\n' "Alias:" "$name" + printf ' %-12s %s\n' "Command:" "$command" + if _alias_check_path; then + printf ' %-12s %s\n' "Wrapper:" "$(_wrapper_path "$name")" + else + printf ' %-12s %s\n' "Wrapper:" "(not installed — ~/.local/bin not on PATH)" + fi + printf ' %-12s %s\n' "Test:" "$name" +} + +# ── Interactive: main menu ───────────────────────────────────── + +_alias_menu() { + menu_guard || return 1 + while true; do + { + _alias_load + if [ ${#_ALIAS_NAMES[@]} -eq 0 ]; then + echo "${YELLOW}[!] No aliases defined yet — create one with option 1.${RESET}" + else + _alias_table + printf ' %d alias(es)\n' "${#_ALIAS_NAMES[@]}" + fi + echo >&2 + } >&2 + local choice + choice="$(menu_run "System Aliases" "Create new alias" "Edit existing alias" \ + "Remove alias" "List aliases")" || return 0 + case "$choice" in + 1) _alias_create ;; + 2) _alias_edit ;; + 3) _alias_remove ;; + 4) : ;; # List aliases — the loop's pre-render IS the current table + esac + done +} + +# ── Interactive: create ──────────────────────────────────────── + +_alias_create() { + local preset_name="${1:-}" + section "Create System Alias" >&2 + + # Step 1: Alias name + local name="$preset_name" + while true; do + if [ -z "$name" ]; then + step 1 2 "Alias Name" >&2 + name="$(menu_ask_value "Alias name" "")" || return 0 + fi + [ -z "$name" ] && { warn "Alias name cannot be empty" >&2; name=""; continue; } + if ! _alias_name_valid "$name"; then + warn "Invalid name '$name' — use letters, digits, hyphens, underscores (start with a letter)" >&2 + name=""; continue + fi + _alias_load + local existing + existing="$(_alias_find "$name")" + if [ "$existing" != "-1" ]; then + warn "Alias '$name' already exists — use 'pos system alias edit $name' instead" >&2 + [ -n "$preset_name" ] && return 1 + name=""; continue + fi + # Collision: wrapper exists without our marker → refuse + local wpath + wpath="$(_wrapper_path "$name")" + if [ -e "$wpath" ]; then + _alias_owned "$wpath" || err "File '~/.local/bin/$name' already exists and was not created by pos system alias — pick another name" + fi + # Collision: name resolves to another binary on PATH → refuse + if command -v "$name" >/dev/null 2>&1; then + err "'$name' already exists on PATH as $(command -v "$name") — pick another name" + fi + break + done + + # Step 2: Command + local command="" + while true; do + step 2 2 "Command" >&2 + command="$(menu_ask_value "Command to execute" "")" || return 0 + [ -z "$command" ] && { warn "Command cannot be empty" >&2; continue; } + [[ "$command" == *'|'* ]] || break + warn "Command must not contain '|' characters" >&2 + command="" + done + + # Confirmation + { + echo "────────────────────────────────────────────" + printf ' Create alias '\''%s'\''?\n' "$name" + printf ' Command: %s\n' "$command" + echo "────────────────────────────────────────────" + } >&2 + if ! confirm "Create alias '$name'?" y; then + log "Aborted." >&2 + return 0 + fi + + _alias_load + _ALIAS_NAMES+=("$name") + _ALIAS_COMMANDS+=("$command") + _alias_save + _alias_sync + log "Alias '$name' created." >&2 + log "Test it: $name" >&2 + log "Available immediately: $(_wrapper_path "$name")" >&2 +} + +# ── Interactive: edit ────────────────────────────────────────── + +_alias_edit() { + local preset_name="${1:-}" + _alias_load + if [ ${#_ALIAS_NAMES[@]} -eq 0 ]; then + warn "No aliases to edit — create one first" >&2 + return 0 + fi + + local name="$preset_name" + if [ -z "$name" ]; then + section "Edit System Alias" >&2 + local display_items=() i + for ((i = 0; i < ${#_ALIAS_NAMES[@]}; i++)); do + local c="${_ALIAS_COMMANDS[$i]}" + if [ ${#c} -gt 30 ]; then + c="${c:0:30}…" + fi + display_items+=("${_ALIAS_NAMES[$i]} → ${c}") + done + local picked + picked="$(menu_pick "Pick alias to edit" "${display_items[@]}")" || return 0 + name="${_ALIAS_NAMES[$((picked - 1))]}" + fi + + local idx + idx="$(_alias_find "$name")" + if [ "$idx" = "-1" ]; then + err "Alias '$name' not found" + fi + + # Show current values + { + echo " Current values for '$name':" + printf ' Command: %s\n' "${_ALIAS_COMMANDS[$idx]}" + echo >&2 + } >&2 + + local new_command="${_ALIAS_COMMANDS[$idx]}" + local changed=0 + + # Edit command + step 1 1 "Command" >&2 + local default_display="${_ALIAS_COMMANDS[$idx]}" + [ ${#default_display} -gt 60 ] && default_display="${default_display:0:60}…" + local tmp_command + tmp_command="$(menu_ask_value "Command to execute" "$default_display")" || return 0 + if [ -n "$tmp_command" ]; then + if [[ "$tmp_command" == *'|'* ]]; then + warn "Command must not contain '|' characters" >&2 + return 0 + fi + if [ "$tmp_command" != "${_ALIAS_COMMANDS[$idx]}" ]; then + new_command="$tmp_command" + changed=1 + fi + fi + + # No changes? + if [ "$changed" -eq 0 ]; then + log "No changes — nothing to save." >&2 + return 0 + fi + + # Show diff summary + { + echo "────────────────────────────────────────────" + printf ' Save changes to '\''%s'\''?\n' "$name" + local tag_c + [ "$new_command" = "${_ALIAS_COMMANDS[$idx]}" ] && tag_c="(unchanged)" || tag_c="(changed)" + printf ' Command: %s %s\n' "$new_command" "$tag_c" + echo "────────────────────────────────────────────" + } >&2 + if ! confirm "Save changes to '$name'?" y; then + log "Discarded." >&2 + return 0 + fi + + _alias_load + _ALIAS_COMMANDS[$idx]="$new_command" + _alias_save + _alias_sync + log "Alias '$name' updated — the change is live on next invocation." >&2 +} + +# ── Interactive: remove ──────────────────────────────────────── + +_alias_remove() { + local preset_name="${1:-}" + _alias_load + if [ ${#_ALIAS_NAMES[@]} -eq 0 ]; then + warn "No aliases to remove" >&2 + return 0 + fi + + local name="$preset_name" + if [ -z "$name" ]; then + section "Remove System Alias" >&2 + local display_items=() i + for ((i = 0; i < ${#_ALIAS_NAMES[@]}; i++)); do + local c="${_ALIAS_COMMANDS[$i]}" + if [ ${#c} -gt 30 ]; then + c="${c:0:30}…" + fi + display_items+=("${_ALIAS_NAMES[$i]} → ${c}") + done + local picked + picked="$(menu_pick "Pick alias to remove" "${display_items[@]}")" || return 0 + name="${_ALIAS_NAMES[$((picked - 1))]}" + fi + + local idx + idx="$(_alias_find "$name")" + if [ "$idx" = "-1" ]; then + err "Alias '$name' not found" + fi + + # Show alias detail + { + echo " Alias: $name" + printf ' Command: %s\n' "${_ALIAS_COMMANDS[$idx]}" + echo >&2 + } >&2 + + if ! confirm "Remove alias '$name'? This cannot be undone." n; then + log "Cancelled." >&2 + return 0 + fi + + _alias_load + local new_names=() new_commands=() i + for ((i = 0; i < ${#_ALIAS_NAMES[@]}; i++)); do + if [ "${_ALIAS_NAMES[$i]}" != "$name" ]; then + new_names+=("${_ALIAS_NAMES[$i]}") + new_commands+=("${_ALIAS_COMMANDS[$i]}") + fi + done + _ALIAS_NAMES=("${new_names[@]+"${new_names[@]}"}") + _ALIAS_COMMANDS=("${new_commands[@]+"${new_commands[@]}"}") + _alias_save + _alias_sync + log "Alias '$name' removed — script deleted from $(_wrapper_path "$name")." >&2 + log "If the name still autocompletes stale in this shell, run: hash -r" >&2 +} + +# ── Usage ────────────────────────────────────────────────────── + +usage() { + cat <<'EOF' +Usage: pos system alias [subcommand] [args] + +Manage persistent command aliases — create, edit, remove, list, and show +named aliases. Each alias maps a name to a shell command, materialized as +an executable wrapper script in ~/.local/bin/. + +Subcommands: + (no args) Interactive menu + create [name] Create a new alias (interactive prompts) + edit [name] Edit an existing alias (interactive, Enter = keep) + remove [name] Remove an alias (with confirmation) + list List all aliases (non-interactive, machine-readable) + show Show one alias's details + +Activation: every alias is materialized as an executable script at +~/.local/bin/, synced automatically on every invocation. Changes are +live on the next invocation. + +Options: + -h|--help Show this help. + +Examples: + pos system alias # interactive menu + pos system alias list # show all aliases + pos system alias create # interactive create + pos system alias create restart-dns # create 'restart-dns' alias + pos system alias edit restart-dns # edit the 'restart-dns' alias + pos system alias remove restart-dns # remove 'restart-dns' (with confirm) + pos system alias show restart-dns # show alias details +EOF + exit 0 +} + +# ── Main dispatch ────────────────────────────────────────────── +# Every subcommand syncs first: artifacts always equal ENV truth before any +# subcommand logic runs. + +case "${1:-}" in + -h|--help) usage ;; + create) shift; _alias_sync; _alias_create "${1:-}" ;; + edit) shift; _alias_sync; _alias_edit "${1:-}" ;; + remove) shift; _alias_sync; _alias_remove "${1:-}" ;; + list) _alias_sync; _alias_list ;; + show) + [ -n "${2:-}" ] || err "Usage: pos system alias show " + _alias_sync + _alias_show "$2" + ;; + "") _alias_sync; _alias_menu ;; + *) err "Unknown subcommand '$1' (use -h for help)" ;; +esac diff --git a/completions/pos.bash b/completions/pos.bash index a305863..0160284 100644 --- a/completions/pos.bash +++ b/completions/pos.bash @@ -49,6 +49,7 @@ _pos_subcmds[share-nfs-client]="mount unmount list persist unpersist menu" _pos_subcmds[share-nfs-server]="status share unshare list reload enable disable menu" _pos_subcmds[share-smb-client]="mount unmount list persist unpersist menu" _pos_subcmds[share-smb-server]="status share unshare list adduser deluser reload enable disable menu" +_pos_subcmds[system-alias]="create edit remove list show" _pos_subcmds[system-backup]="menu" _pos_subcmds[system-schedule]="run list config enable disable status migrate menu" _pos_subcmds[ai]="ask chat sessions capture models providers llamacpp alias gemini hf openrouter server" diff --git a/postinstall.sh b/postinstall.sh index d9f1c0a..336bb04 100755 --- a/postinstall.sh +++ b/postinstall.sh @@ -156,6 +156,11 @@ if [ -d systemd ] && [ -n "$(ls -A systemd/*.service 2>/dev/null)" ]; then warn "usb-automount feature not installed — skipping usb-automount.service (run ./install.sh --feature)" continue fi + # ssh-agent.service — gated on ssh-agent flag + if [ "$svc_name" = "ssh-agent.service" ] && ! flag_is_set ssh-agent; then + warn "ssh-agent feature not installed — skipping ssh-agent.service (run ./install.sh --feature)" + continue + fi run sudo systemctl enable --now "$svc_name" 2>/dev/null || \ run sudo systemctl enable "$svc_name" log "service enabled: $svc_name"