Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 287f0b75b7 | |||
| 59d834a71c | |||
| 11b4a679a8 | |||
| e23d57e551 | |||
| c5d501ad25 | |||
| 73d15a26b4 | |||
| 41efc91cf0 | |||
| 0b76d4d29e | |||
| 2c77e73799 | |||
| df1cca478d |
@@ -33,3 +33,6 @@ dream.md
|
||||
|
||||
# Agent process reports — local-only shared memory (not tracked)
|
||||
AgentsReport/
|
||||
|
||||
# Task breakdown notes — never committed
|
||||
.tasks/
|
||||
|
||||
@@ -42,6 +42,12 @@ summary (newest last).
|
||||
|
||||
## Done
|
||||
|
||||
- **`pos bank` command bank** — feature (41efc91) + lib registration fix (73d15a2) + main-menu visibility & `run` crash fix (c5d501a) + tab-completion of category-less tools (e23d57e) + multiline command storage/execution fix (11b4a67, v2 escaped format with backward compat). Done 2026-09-12. **2026-09-12: migrated into the `system` category → `pos system bank` (`bin/pos-system-bank`); storage seam (`bank.env`, `BANK_FILE`, v2 format) unchanged.**
|
||||
|
||||
- **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-12: removed per user request (feature no longer wanted); `bin/pos-system-alias` deleted, docs cleaned up.**
|
||||
|
||||
- **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).
|
||||
|
||||
- **2026-09-08** — `install.sh` version gate (Architect→Builder): skip+abort when installed version == current version, `--force` to bypass, version scheme `0.0c<git commit count>` (auto-bumps per commit). `install_version()` derives `0.0c$(git rev-list --count HEAD)`; empty when `.git` absent → gate skipped (silently); `INSTALL_VERSION_OVERRIDE` env var (presence-check) = test seam. Gate after arg-parse, before phases, numeric comparison (strip `0.0c`, `-eq`); `log "Already installed ($CURRENT_VERSION). Use --force to re-install."` / `--dry-run` → `(dry-run) Would skip install: already at version $CURRENT_VERSION`, both exit 0. `FORCE=0` init, `--force` parse + usage. `flag_set installed_version "$CURRENT_VERSION"` after "Bootstrap complete" banner (only when DRY_RUN≠1 and version non-empty; even under --force). New `tests/t-install-version.sh` (21 checks / 9 cases). Docs: README/SCRIPTS/AGENT_Context (flags, flow, line count 248→301, tests/README row). Verified: `bash -n` clean; `make gen` idempotent; `make check` OK; `make lint` 0 FAIL / 0 WARN; `make test` suite green.
|
||||
|
||||
@@ -10,19 +10,19 @@
|
||||
|
||||
<!-- GEN:START docmap -->
|
||||
| ## 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–694 |
|
||||
| ## 14. Common Tasks for Agents | 695–728 |
|
||||
<!-- GEN:END docmap -->
|
||||
|
||||
## 1. Project Overview
|
||||
@@ -111,6 +111,7 @@ Linux_post_install/
|
||||
│ ├── pos-ssh-load-keys # Load all SSH keys into the agent
|
||||
│ ├── pos-system-backup # Encrypted (AES-256) folder snapshots (tar + gpg)
|
||||
│ │ [deps: tar]
|
||||
│ ├── pos-system-bank # Persistent command bank for saving and running shell commands
|
||||
│ ├── pos-system-firewall # Interactive UFW management
|
||||
│ ├── pos-system-health # Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL
|
||||
│ ├── pos-system-schedule # Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently
|
||||
@@ -230,7 +231,7 @@ User runs: ./install.sh [--apps|--full|--feature|--dry-run|--force|--skip <phase
|
||||
│ └─ Copies bin/* → /usr/local/bin/ (chmod 755)
|
||||
│ └─ Copies lib/*.sh (common, flags, notify, registry, entertainment-lib,
|
||||
│ scheduler-lib, config-ui, user-timers-lib, entertainment-plugin-lib,
|
||||
│ usb-lib, share-lib, menu-lib) → /usr/local/bin/ (chmod 644)
|
||||
│ usb-lib, share-lib, menu-lib, yt-lib, bank-lib) → /usr/local/bin/ (chmod 644)
|
||||
│ └─ Copies x64_bin/* → /usr/local/bin/ on x86_64 (arm64_bin/ on aarch64)
|
||||
│ └─ [if --feature] Copies features/* → /usr/local/bin/ (asks before overwriting),
|
||||
│ then sets the matching feature flag
|
||||
@@ -336,6 +337,7 @@ All non-interactive `pos` commands log output to `~/.local/share/linux_post_inst
|
||||
| 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 | backup | `pos-system-backup` | Encrypted (AES-256) folder snapshots (tar + gpg) | tar | |
|
||||
| system | bank | `pos-system-bank` | Persistent command bank for saving and running shell commands | | |
|
||||
| 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 | | |
|
||||
| system | schedule | `pos-system-schedule` | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently | | |
|
||||
@@ -624,13 +626,14 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| `lib/menu-lib.sh` | 362 | Category-neutral interactive menu primitives (`menu_guard` tty guard, `menu_run` looping boxed menu, `menu_pick` type-to-filter picker, `menu_ask_value` prompt-with-default via raw-mode bracketed-paste-safe `menu_read_value`; stderr render, fail-closed on non-tty/EOF) — sourced by `share-lib.sh`, open to any category |
|
||||
| `lib/registry.sh` | 199 | Shared query API for POS tool metadata headers (`# POS_*:`) — `reg_scan`/`reg_list`/`reg_lookup`/`reg_each`/config scope helpers; used by `pos-tree` and `gen-docs.sh` |
|
||||
| `lib/yt-lib.sh` | 50 | Shared YouTube helpers for `pos media yt *` (`yt_check_deps`, `yt_validate_url`, `yt_echo_cmd`, `classify_url`) — sourced by `yt-mp3`/`yt-mp4`/`yt-grab`/`yt-subtitles` |
|
||||
| `lib/bank-lib.sh` | 183 | Shared Command Bank storage helpers for `pos system bank` (`bank_load`/`bank_save`/`bank_add`/`bank_remove`/`bank_update`, `{param}` template substitution, v2 `\n`-escaped multiline storage with backward-compat raw load; store `~/.config/linux_post_install/bank.env`) — sourced by `pos-system-bank` |
|
||||
| `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` | 50 | Boot-time feature (moved from `bin/`, flag-gated service) |
|
||||
| `features/usb-automount.sh` | 138 | USB automount feature (udev rule + flag-gated service) |
|
||||
<!-- GEN:START filetable -->
|
||||
| `bin/pos` | 302 | CLI dispatcher with smart arg matching + logging + category help |
|
||||
| `bin/pos` | 310 | CLI dispatcher with smart arg matching + logging + category help |
|
||||
| `bin/pos-ai-alias` | 760 | manage AI agent aliases |
|
||||
| `bin/pos-ai-gemini` | 7 | Forward to pos ai --provider gemini (backward compat) |
|
||||
| `bin/pos-ai-hf` | 993 | Download AI models from Hugging Face (search, download, manage) |
|
||||
@@ -640,7 +643,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| `bin/pos-communication-matrix-listener` | 582 | Matrix listener: map /command → bash, run them on room messages |
|
||||
| `bin/pos-communication-matrix-sender` | 215 | Send messages to a Matrix room via the client-server API (send, test, login) |
|
||||
| `bin/pos-communication-scrcpy` | 245 | Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info) |
|
||||
| `bin/pos-communication-telegram-listener` | 815 | Telegram bot listener: map /command → bash and <prefix> → app, run them on chat messages |
|
||||
| `bin/pos-communication-telegram-listener` | 889 | Telegram bot listener: map /command → bash and <prefix> → app, run them on chat messages |
|
||||
| `bin/pos-communication-telegram-sender` | 212 | Send Telegram messages/files/links/stickers via Bot API (send, test) |
|
||||
| `bin/pos-docker-compose` | 487 | Docker Compose service manager (ls/up/down/restart/logs/update/config) |
|
||||
| `bin/pos-docker-health` | 107 | One-glance container health dashboard (exits 1 if unhealthy) |
|
||||
@@ -675,14 +678,15 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| `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-backup` | 301 | Encrypted (AES-256) folder snapshots (tar + gpg) |
|
||||
| `bin/pos-system-bank` | 311 | Persistent command bank for saving and running shell commands |
|
||||
| `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 |
|
||||
| `bin/pos-system-schedule` | 151 | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently |
|
||||
| `bin/pos-system-uninstall` | 517 | Remove pos toolkit binaries, services, shell integration, config, and data |
|
||||
| `bin/pos-system-uninstall` | 518 | Remove pos toolkit binaries, services, shell integration, config, and data |
|
||||
| `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` | 330 | Dynamic bash completion |
|
||||
<!-- GEN:END filetable -->
|
||||
| `apps/install.sh` | 171 | App install/uninstall picker/orchestrator |
|
||||
|
||||
|
||||
+25
@@ -296,9 +296,34 @@ 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 <cmd>` | `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/<name>.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-<name>.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 <name>` 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 bank` | `bin/pos-system-bank` | Persistent command bank for saving and running shell commands — list, add, show, run, edit, remove | Commands stored in `~/.config/linux_post_install/bank.env` (pipe-delimited: `name\|description\|command`, chmod 600, managed by the tool). Parameterized `{param}` templates are substituted at run time. Interactive menu on a TTY with no args. `BANK_FILE` env seam overrides the path |
|
||||
|
||||
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).
|
||||
|
||||
#### system bank
|
||||
|
||||
**File:** `bin/pos-system-bank`
|
||||
|
||||
Persistent command bank for saving and running shell commands. Commands are stored in `~/.config/linux_post_install/bank.env` (pipe-delimited: `name|description|command`, chmod 600, managed by the tool). Supports parameterized templates with `{param}` placeholders that are substituted at run time (quoted for safe shell evaluation).
|
||||
|
||||
On a TTY with no arguments, `pos system bank` opens an interactive menu (list / add / run / edit / remove).
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `pos system bank list` | List all saved commands |
|
||||
| `pos system bank add <name> [desc] [cmd]` | Add a new command (interactive for missing args) |
|
||||
| `pos system bank show <name>` | Show command details and detected parameters |
|
||||
| `pos system bank run <name> [key=val …]` | Run a command (interactive for missing params) |
|
||||
| `pos system bank edit <name>` | Edit an existing command |
|
||||
| `pos system bank remove <name>` | Remove a command |
|
||||
|
||||
Example with parameters:
|
||||
|
||||
```
|
||||
pos system bank add convert "Convert video" "ffmpeg -i {input} -crf {quality} {output}"
|
||||
pos system bank run convert input=clip.mp4 quality=23 output=clip.mkv
|
||||
```
|
||||
|
||||
### ssh
|
||||
|
||||
| Command | File | Purpose | Configuration |
|
||||
|
||||
+8
-1
@@ -45,7 +45,7 @@ The phases:
|
||||
| # | Phase | Script/action |
|
||||
|---|-------|----------------|
|
||||
| 1 | preinstall | `preinstall.sh` — apt packages + yt-dlp |
|
||||
| 2 | scripts | Copies `bin/*` → `/usr/local/bin/` (755), `lib/common.sh` + `lib/flags.sh` + `lib/notify.sh` + `lib/entertainment-lib.sh` + `lib/entertainment-plugin-lib.sh` + `lib/scheduler-lib.sh` + `lib/config-ui.sh` + `lib/user-timers-lib.sh` + `lib/usb-lib.sh` + `lib/share-lib.sh` + `lib/menu-lib.sh` + `lib/registry.sh` → `/usr/local/bin/` (644). Copies precompiled arch binaries from `x64_bin/` (or `arm64_bin/`) → `/usr/local/bin/`. With `--feature`: also installs `features/*` (see below) |
|
||||
| 2 | scripts | Copies `bin/*` → `/usr/local/bin/` (755), `lib/common.sh` + `lib/flags.sh` + `lib/notify.sh` + `lib/entertainment-lib.sh` + `lib/entertainment-plugin-lib.sh` + `lib/scheduler-lib.sh` + `lib/config-ui.sh` + `lib/user-timers-lib.sh` + `lib/usb-lib.sh` + `lib/share-lib.sh` + `lib/menu-lib.sh` + `lib/registry.sh` + `lib/yt-lib.sh` + `lib/bank-lib.sh` → `/usr/local/bin/` (644). Copies precompiled arch binaries from `x64_bin/` (or `arm64_bin/`) → `/usr/local/bin/`. With `--feature`: also installs `features/*` (see below) |
|
||||
| 3 | postinstall | `postinstall.sh` — PATH, completion, SSH keys, systemd |
|
||||
| 4 | scalepoint | Shallow-clones ScaleTail templates to `/usr/local/share/linux_post_install/scale-tail` |
|
||||
| 5 (opt) | apps | `apps/install.sh` when `--apps` (interactive) or `--full` (all, non-interactive) |
|
||||
@@ -245,6 +245,13 @@ Sourced by `bin/pos-tree` (tree rendering incl. the `[deps: …]` annotations) a
|
||||
|
||||
---
|
||||
|
||||
## lib/yt-lib.sh — shared YouTube helpers
|
||||
|
||||
**File:** `lib/yt-lib.sh` (installed to `/usr/local/bin/yt-lib.sh`)
|
||||
**Purpose:** shared helpers for yt-dlp-based media tools (`pos media yt *`). Provides `yt_check_deps` (dependency validation for yt-dlp/ffmpeg), `yt_validate_url` (return-1 URL checker, never exits — callers prefix errors), `yt_echo_cmd` (dry-run command display), and `classify_url` (URL type classification). Sourced by `bin/pos-media-yt-mp3`, `bin/pos-media-yt-mp4`, `bin/pos-media-yt-grab`, and `bin/pos-media-yt-subtitles` via the standard fallback chain.
|
||||
|
||||
---
|
||||
|
||||
## features/autostart.sh — boot-time feature
|
||||
|
||||
**File:** `features/autostart.sh` (installed to `/usr/local/bin/autostart.sh` by `./install.sh --feature`)
|
||||
|
||||
+7
-2
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ _pos_commands() {
|
||||
# ── Category list (sorted, filename-derived) ─────────────────────
|
||||
# Nested sub-tools (pos-<cat>-<a>-<b> where pos-<cat>-<a> exists) are
|
||||
# omitted from the cheat-sheet line — they are shown under their parent tool.
|
||||
# Category-less tools (pos-config, pos-tree) are emitted as their
|
||||
# own line with an empty subcommand column; a name already present as a
|
||||
# category (pos-ai vs the ai category built from pos-ai-*) is NOT duplicated.
|
||||
_pos_category_list() {
|
||||
local -A cats=()
|
||||
local cmd cat f
|
||||
@@ -35,7 +38,10 @@ _pos_category_list() {
|
||||
[ -x "$f" ] || continue
|
||||
cmd="${f##*/pos-}"
|
||||
cat="${cmd%%-*}"
|
||||
[ "$cat" = "$cmd" ] && continue
|
||||
if [ "$cat" = "$cmd" ]; then
|
||||
cats["$cmd"]="${cats[$cmd]:-}"
|
||||
continue
|
||||
fi
|
||||
cats["$cat"]+="${cmd#*-} "
|
||||
done
|
||||
local c subs joined s s2 top=() is_nested
|
||||
@@ -208,6 +214,8 @@ EXAMPLES
|
||||
|
||||
pos tree Show the command tree (categories + subcommands)
|
||||
|
||||
pos system bank run <name> Run a saved command from the bank
|
||||
|
||||
pos docker vbox create lab1 Create disposable Docker VM
|
||||
pos docker vbox create lab1 --dir . Create VM using current directory
|
||||
pos docker vbox enter lab1 Shell into a Docker VM
|
||||
@@ -266,7 +274,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="system-bank 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"
|
||||
|
||||
for ((i=n-1; i>=0; i--)); do
|
||||
cmd="pos"
|
||||
|
||||
@@ -36,10 +36,12 @@ Commands:
|
||||
(none) Interactive editor for the /command → bash map
|
||||
--enable Install + start the systemd user service (autostarts on login)
|
||||
--disable Stop + disable + remove the service
|
||||
--status Show service state and the command map
|
||||
--status Show service state (single-instance lock) and the command map
|
||||
--sync-commands
|
||||
Push the mapped /commands to the bot's "/" menu (setMyCommands)
|
||||
--run Run the polling loop in the foreground (used by the service)
|
||||
--run Run the polling loop in the foreground (used by the service).
|
||||
Single instance: only one --run may poll the bot token at a
|
||||
time — a second --run exits immediately with an error.
|
||||
prefix [word [command...]]
|
||||
Manage the text-prefix map (telegram_prefixes.env): any
|
||||
non-command message '<prefix> <text>' runs the mapped command
|
||||
@@ -337,24 +339,67 @@ prefix_map_show() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Run a mapped command line and reply with its output: empty output → "OK",
|
||||
# non-zero exit → "exit <rc>" + output; quiet=1 suppresses the reply (for
|
||||
# '@quiet ' entries that self-notify). Used by the /command map (60s cap)
|
||||
# and the text-prefix bridge (120s cap for app calls).
|
||||
# ── async command execution ─────────────────────────────────────
|
||||
# Commands run in the background so the listener never blocks. stdin is
|
||||
# /dev/null (prevents interactive hangs — FFmpeg reading 'q', scripts
|
||||
# waiting for prompts); stdout+stderr go to a temp file; output is collected
|
||||
# and replied asynchronously from the main loop.
|
||||
#
|
||||
# PID → metadata arrays (populated by run_and_reply, drained by reap_commands)
|
||||
declare -A _CMD_OUT _CMD_MSG _CMD_QUIET
|
||||
# Exit codes stored by the SIGCHLD handler (wait -n) so reap_commands can
|
||||
# retrieve them without calling blocking wait.
|
||||
declare -A _EXIT_CODES
|
||||
|
||||
# run_and_reply <cmdline> <msg_id> [timeout] [quiet]
|
||||
# Starts the command in the background and returns immediately. The main
|
||||
# loop calls reap_commands after each getUpdates cycle to collect output
|
||||
# and send replies.
|
||||
run_and_reply() {
|
||||
local cmdline="$1" msg_id="$2" tmo="${3:-120}" quiet="${4:-0}" output rc
|
||||
if output="$(timeout "$tmo" bash -c "$cmdline" 2>&1)"; then
|
||||
rc=0
|
||||
else
|
||||
rc=$?
|
||||
fi
|
||||
[ "$quiet" -eq 1 ] && return
|
||||
[ -n "$output" ] || output="OK"
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
reply "$(printf 'exit %s\n%s' "$rc" "$output")" "$msg_id" "$rc"
|
||||
else
|
||||
reply "$output" "$msg_id"
|
||||
fi
|
||||
local cmdline="$1" msg_id="$2" tmo="${3:-120}" quiet="${4:-0}"
|
||||
local out_file
|
||||
out_file="$(mktemp /tmp/pos-cmd.XXXXXX)"
|
||||
# stdin=/dev/null: prevents interactive hangs (FFmpeg 'q', read prompts).
|
||||
# The child inherits nothing from the listener's own stdin.
|
||||
timeout "$tmo" bash -c "$cmdline" </dev/null >"$out_file" 2>&1 &
|
||||
local pid=$!
|
||||
_CMD_OUT[$pid]="$out_file"
|
||||
_CMD_MSG[$pid]="$msg_id"
|
||||
_CMD_QUIET[$pid]="$quiet"
|
||||
}
|
||||
|
||||
# reap_commands — called from the main loop after each getUpdates cycle.
|
||||
# Checks every tracked PID with kill -0 (non-blocking); when a process has
|
||||
# exited, reads its output file and sends the reply. Never blocks the loop.
|
||||
reap_commands() {
|
||||
local pid
|
||||
for pid in "${!_CMD_OUT[@]}"; do
|
||||
# Non-blocking: has the process exited?
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
# Retrieve exit code (SIGCHLD handler stores it; fallback to wait).
|
||||
local rc="${_EXIT_CODES[$pid]:-}"
|
||||
if [ -n "$rc" ]; then
|
||||
unset _EXIT_CODES[$pid]
|
||||
else
|
||||
wait "$pid" 2>/dev/null; rc=$?
|
||||
fi
|
||||
local out_file="${_CMD_OUT[$pid]}"
|
||||
local msg_id="${_CMD_MSG[$pid]}"
|
||||
local quiet="${_CMD_QUIET[$pid]}"
|
||||
local output=""
|
||||
[ -s "$out_file" ] && output="$(cat "$out_file" 2>/dev/null)"
|
||||
rm -f "$out_file"
|
||||
if [ "$quiet" -ne 1 ]; then
|
||||
[ -n "$output" ] || output="OK"
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
reply "$(printf 'exit %s\n%s' "$rc" "$output")" "$msg_id" "$rc"
|
||||
else
|
||||
reply "$output" "$msg_id"
|
||||
fi
|
||||
fi
|
||||
unset _CMD_OUT[$pid] _CMD_MSG[$pid] _CMD_QUIET[$pid]
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
ui_run_command() {
|
||||
@@ -521,8 +566,8 @@ disable_service() {
|
||||
}
|
||||
|
||||
status() {
|
||||
if systemctl --user is-active --quiet "$SERVICE" 2>/dev/null; then
|
||||
echo "listener: running"
|
||||
if lock_held; then
|
||||
echo "listener: running (single instance lock held)"
|
||||
else
|
||||
echo "listener: not running"
|
||||
fi
|
||||
@@ -750,7 +795,31 @@ handle_message() {
|
||||
run_and_reply "$value" "$msg_id" 60 "$quiet"
|
||||
}
|
||||
|
||||
# ── single-instance guard ──────────────────────────────────────
|
||||
# flock(1) on a runtime lockfile — the kernel drops the lock when the process
|
||||
# dies, so there is no stale-lock/pidfile bookkeeping and the systemd
|
||||
# Restart=always unit restarts cleanly. Two getUpdates loops on one bot token
|
||||
# cause Telegram 409 conflicts and command stealing, so a second --run fails
|
||||
# closed instead of racing the active listener.
|
||||
LOCK_FILE="${XDG_RUNTIME_DIR:-/tmp}/pos-telegram-listener.lock"
|
||||
|
||||
acquire_lock() {
|
||||
exec 9>"$LOCK_FILE"
|
||||
flock -n 9 || err "listener already running (single instance) — check: systemctl --user status pos-telegram-listener"
|
||||
}
|
||||
|
||||
lock_held() {
|
||||
# Non-blocking probe: acquiring then dropping the flock in a subshell
|
||||
# succeeds only when nobody else holds it. Returns 0 when held.
|
||||
if ( flock -n 9 ) 9>"$LOCK_FILE" 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
run_daemon() {
|
||||
command -v flock &>/dev/null || err "flock not found (install util-linux)"
|
||||
acquire_lock
|
||||
command -v jq &>/dev/null || err "jq not found (install jq — in preinstall PACKAGES)"
|
||||
load_config
|
||||
[ -n "${TELEGRAM_BOT_TOKEN:-}" ] || err "No bot token — run 'pos config telegram'"
|
||||
@@ -764,7 +833,10 @@ run_daemon() {
|
||||
|
||||
local offset=0
|
||||
log "listener running (chat ${TELEGRAM_CHAT_ID}, owner ${TELEGRAM_OWNER_ID:-unset}) — Ctrl+C to stop"
|
||||
trap 'kill $(jobs -p) 2>/dev/null; exit 0' TERM INT
|
||||
# SIGCHLD: reap finished children and store their exit codes so
|
||||
# reap_commands can retrieve them without blocking.
|
||||
trap 'local _p; while _p=$(wait -n 2>/dev/null); do _EXIT_CODES[$_p]=$?; done' CHLD
|
||||
trap 'kill $(jobs -p) 2>/dev/null; rm -f /tmp/pos-cmd.* 2>/dev/null; wait 2>/dev/null; exit 0' TERM INT
|
||||
while true; do
|
||||
local resp n i
|
||||
resp="$(curl -fsS -m 45 "${API}/bot${TELEGRAM_BOT_TOKEN}/getUpdates" \
|
||||
@@ -799,6 +871,8 @@ run_daemon() {
|
||||
fi
|
||||
handle_message "$text" "$msg_id" "$reply_text"
|
||||
done
|
||||
# Collect output from finished background commands and send replies.
|
||||
reap_commands
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
Executable
+311
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: system bank — Persistent command bank for saving and running shell commands
|
||||
# POS_SUBCMDS: list add show run edit remove
|
||||
|
||||
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"
|
||||
source "$(dirname "$0")/../lib/bank-lib.sh" 2>/dev/null || source "$(dirname "$0")/bank-lib.sh"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: pos system bank [subcommand] [args]
|
||||
|
||||
Persistent command bank for saving and running shell commands.
|
||||
|
||||
Subcommands:
|
||||
list List all saved commands
|
||||
add <name> [desc] [cmd] Add a new command (interactive if args missing)
|
||||
show <name> Show command details
|
||||
run <name> [key=val ...] Run a command (interactive for missing params)
|
||||
edit <name> Edit an existing command
|
||||
remove <name> Remove a command
|
||||
|
||||
Interactive menu:
|
||||
pos system bank (no args on a terminal)
|
||||
|
||||
Examples:
|
||||
pos system bank add disk-info "Disk usage" "df -h && du -sh /mnt/12T"
|
||||
pos system bank run disk-info
|
||||
pos system bank run convert-video input=/path/to/video.mp4 quality=23 output=/path/out.mp4
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help) usage ;;
|
||||
esac
|
||||
|
||||
# ── Interactive command picker ───────────────────────────────────
|
||||
_pick_command() {
|
||||
bank_load
|
||||
local count="${#BANK_NAMES[@]}"
|
||||
if [ "$count" -eq 0 ]; then
|
||||
warn "Command bank is empty — add one with: pos system bank add <name> \"desc\" \"cmd\""
|
||||
return 1
|
||||
fi
|
||||
local -a items=()
|
||||
local i
|
||||
for ((i = 0; i < count; i++)); do
|
||||
if [ -n "${BANK_DESCS[$i]}" ]; then
|
||||
items+=("${BANK_NAMES[$i]} — ${BANK_DESCS[$i]}")
|
||||
else
|
||||
items+=("${BANK_NAMES[$i]}")
|
||||
fi
|
||||
done
|
||||
local choice
|
||||
choice="$(menu_pick "Pick command" "${items[@]}")" || return 1
|
||||
echo "${BANK_NAMES[$((choice - 1))]}"
|
||||
}
|
||||
|
||||
# ── cmd_list ─────────────────────────────────────────────────────
|
||||
cmd_list() {
|
||||
bank_load
|
||||
local count="${#BANK_NAMES[@]}"
|
||||
if [ "$count" -eq 0 ]; then
|
||||
log "Command bank is empty — add one with: pos system bank add <name> \"desc\" \"cmd\""
|
||||
return 0
|
||||
fi
|
||||
echo
|
||||
echo "COMMAND BANK"
|
||||
echo
|
||||
local i
|
||||
for ((i = 0; i < count; i++)); do
|
||||
if [ -n "${BANK_DESCS[$i]}" ]; then
|
||||
printf ' %-25s %s\n' "${BANK_NAMES[$i]}" "${BANK_DESCS[$i]}"
|
||||
else
|
||||
printf ' %s\n' "${BANK_NAMES[$i]}"
|
||||
fi
|
||||
done
|
||||
echo
|
||||
}
|
||||
|
||||
# ── cmd_add ──────────────────────────────────────────────────────
|
||||
cmd_add() {
|
||||
local name="" desc="" cmd=""
|
||||
case $# in
|
||||
0) ;;
|
||||
1) name="$1" ;;
|
||||
2) name="$1"; desc="$2" ;;
|
||||
*) name="$1"; desc="$2"; cmd="$3" ;;
|
||||
esac
|
||||
if [ -z "$name" ]; then
|
||||
name="$(menu_ask_value "Name")" || return 0
|
||||
fi
|
||||
if [ -z "$desc" ]; then
|
||||
desc="$(menu_ask_value "Description" "")" || return 0
|
||||
fi
|
||||
if [ -z "$cmd" ]; then
|
||||
cmd="$(menu_read_value "Command")" || return 0
|
||||
fi
|
||||
bank_valid_name "$name" || err "Invalid name: $name (use letters, numbers, hyphens, underscores)"
|
||||
[ -n "$cmd" ] || err "Command cannot be empty"
|
||||
bank_load
|
||||
local i
|
||||
for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do
|
||||
[[ "${BANK_NAMES[$i]}" == "$name" ]] && err "Command '$name' already exists (use 'pos system bank edit' to modify)"
|
||||
done
|
||||
bank_add "$name" "$desc" "$cmd"
|
||||
ok "Saved: $name"
|
||||
}
|
||||
|
||||
# ── cmd_show ─────────────────────────────────────────────────────
|
||||
cmd_show() {
|
||||
[ -n "${1:-}" ] || err "Usage: pos system bank show <name>"
|
||||
local name="$1"
|
||||
bank_load
|
||||
bank_find "$name" >/dev/null 2>&1 || err "Command not found: $name"
|
||||
local cmd_name cmd_desc cmd_cmd
|
||||
cmd_name="${BANK_NAMES[$BANK_IDX]}"
|
||||
cmd_desc="${BANK_DESCS[$BANK_IDX]}"
|
||||
cmd_cmd="${BANK_CMDS[$BANK_IDX]}"
|
||||
echo
|
||||
echo "Name: $cmd_name"
|
||||
echo
|
||||
echo "Description:"
|
||||
if [ -n "$cmd_desc" ]; then
|
||||
echo "$cmd_desc"
|
||||
else
|
||||
echo " (none)"
|
||||
fi
|
||||
echo
|
||||
echo "Command:"
|
||||
echo
|
||||
echo "$cmd_cmd"
|
||||
local params
|
||||
params="$(_extract_params "$cmd_cmd")"
|
||||
if [ -n "$params" ]; then
|
||||
echo
|
||||
echo "Parameters:"
|
||||
echo "$params" | while IFS= read -r p; do
|
||||
echo " $p"
|
||||
done
|
||||
fi
|
||||
echo
|
||||
}
|
||||
|
||||
# ── cmd_run ──────────────────────────────────────────────────────
|
||||
cmd_run() {
|
||||
local name=""
|
||||
local -a cli_params=()
|
||||
if [ $# -eq 0 ]; then
|
||||
name="$(_pick_command)" || return 0
|
||||
else
|
||||
name="$1"; shift
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
*=*) cli_params+=("$1") ;;
|
||||
*) err "Invalid parameter format: $1 (expected key=value)" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
fi
|
||||
bank_load
|
||||
bank_find "$name" >/dev/null 2>&1 || err "Command not found: $name"
|
||||
local cmd_name cmd_desc cmd_cmd
|
||||
cmd_name="${BANK_NAMES[$BANK_IDX]}"
|
||||
cmd_desc="${BANK_DESCS[$BANK_IDX]}"
|
||||
cmd_cmd="${BANK_CMDS[$BANK_IDX]}"
|
||||
# Detect parameters
|
||||
local params_str
|
||||
params_str="$(_extract_params "$cmd_cmd")"
|
||||
local -a params=()
|
||||
if [ -n "$params_str" ]; then
|
||||
while IFS= read -r p; do
|
||||
params+=("$p")
|
||||
done <<< "$params_str"
|
||||
fi
|
||||
# Build parameter values from CLI args
|
||||
local -A param_values=()
|
||||
for p in "${cli_params[@]}"; do
|
||||
local key="${p%%=*}" val="${p#*=}"
|
||||
param_values["$key"]="$val"
|
||||
done
|
||||
# Interactively prompt for missing parameters
|
||||
local -a missing=()
|
||||
for p in "${params[@]}"; do
|
||||
[[ -z "${param_values[$p]+x}" ]] && missing+=("$p")
|
||||
done
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
for p in "${missing[@]}"; do
|
||||
local val
|
||||
val="$(menu_ask_value "$p" "")" || return 0
|
||||
param_values["$p"]="$val"
|
||||
done
|
||||
fi
|
||||
# Substitute parameters
|
||||
local final_cmd="$cmd_cmd"
|
||||
if [ ${#params[@]} -gt 0 ]; then
|
||||
local -a sub_args=()
|
||||
for p in "${params[@]}"; do
|
||||
sub_args+=("${p}=${param_values[$p]:-}")
|
||||
done
|
||||
final_cmd="$(_substitute_params "$cmd_cmd" "${sub_args[@]}")"
|
||||
fi
|
||||
echo
|
||||
log "Running: $final_cmd"
|
||||
echo
|
||||
eval "$final_cmd"
|
||||
return $?
|
||||
}
|
||||
|
||||
# ── cmd_edit ─────────────────────────────────────────────────────
|
||||
cmd_edit() {
|
||||
local name="${1:-}"
|
||||
if [ -z "$name" ]; then
|
||||
name="$(_pick_command)" || return 0
|
||||
fi
|
||||
bank_load
|
||||
bank_find "$name" >/dev/null 2>&1 || err "Command not found: $name"
|
||||
local old_name old_desc old_cmd
|
||||
old_name="${BANK_NAMES[$BANK_IDX]}"
|
||||
old_desc="${BANK_DESCS[$BANK_IDX]}"
|
||||
old_cmd="${BANK_CMDS[$BANK_IDX]}"
|
||||
echo
|
||||
log "Editing: $old_name"
|
||||
echo
|
||||
local new_name new_desc new_cmd
|
||||
new_name="$(menu_ask_value "New name" "$old_name")" || return 0
|
||||
new_desc="$(menu_ask_value "New description" "$old_desc")" || return 0
|
||||
new_cmd="$(menu_read_value "New command")" || return 0
|
||||
[ -z "$new_name" ] && new_name="$old_name"
|
||||
[ -z "$new_desc" ] && new_desc="$old_desc"
|
||||
[ -z "$new_cmd" ] && new_cmd="$old_cmd"
|
||||
if [ "$new_name" != "$old_name" ]; then
|
||||
bank_valid_name "$new_name" || err "Invalid name: $new_name"
|
||||
local i found=0
|
||||
for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do
|
||||
[[ "${BANK_NAMES[$i]}" == "$new_name" ]] && found=1
|
||||
done
|
||||
[[ "$found" -eq 1 ]] && err "A command named '$new_name' already exists"
|
||||
bank_remove "$old_name"
|
||||
bank_add "$new_name" "$new_desc" "$new_cmd"
|
||||
else
|
||||
bank_update "$new_name" "$new_desc" "$new_cmd"
|
||||
fi
|
||||
ok "Updated: $new_name"
|
||||
}
|
||||
|
||||
# ── cmd_remove ───────────────────────────────────────────────────
|
||||
cmd_remove() {
|
||||
local name="${1:-}"
|
||||
if [ -z "$name" ]; then
|
||||
name="$(_pick_command)" || return 0
|
||||
fi
|
||||
bank_load
|
||||
bank_find "$name" >/dev/null 2>&1 || err "Command not found: $name"
|
||||
if [ -t 0 ]; then
|
||||
confirm "Remove '$name'?" n || { log "Cancelled"; return 0; }
|
||||
fi
|
||||
bank_remove "$name"
|
||||
ok "Removed: $name"
|
||||
}
|
||||
|
||||
# ── Interactive menu ─────────────────────────────────────────────
|
||||
run_menu() {
|
||||
menu_guard || exit 1
|
||||
while true; do
|
||||
local choice
|
||||
choice="$(menu_run "Command Bank" \
|
||||
"List commands" \
|
||||
"Add command" \
|
||||
"Run command" \
|
||||
"Edit command" \
|
||||
"Remove command")" || return 0
|
||||
case "$choice" in
|
||||
1) cmd_list ;;
|
||||
2) cmd_add ;;
|
||||
3) cmd_run ;;
|
||||
4) cmd_edit ;;
|
||||
5) cmd_remove ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# ── Dispatch ─────────────────────────────────────────────────────
|
||||
if [ "${1:-}" = "menu" ]; then
|
||||
run_menu
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-h|--help) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ $# -eq 0 ] && [ -t 0 ]; then
|
||||
run_menu
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
list) shift; cmd_list "$@" ;;
|
||||
add) shift; cmd_add "$@" ;;
|
||||
show) shift; cmd_show "$@" ;;
|
||||
run) shift; cmd_run "$@" ;;
|
||||
edit) shift; cmd_edit "$@" ;;
|
||||
remove) shift; cmd_remove "$@" ;;
|
||||
"") usage ;;
|
||||
*) err "Unknown subcommand: $1 (see --help)" ;;
|
||||
esac
|
||||
@@ -55,7 +55,8 @@ done
|
||||
# ── pos-owned installed files (single source of truth, mirrors install.sh) ──
|
||||
# lib/*.sh list shipped by install.sh phase 2 to /usr/local/bin.
|
||||
POS_LIBS=(common.sh flags.sh notify.sh entertainment-lib.sh scheduler-lib.sh config-ui.sh \
|
||||
user-timers-lib.sh entertainment-plugin-lib.sh usb-lib.sh share-lib.sh menu-lib.sh registry.sh)
|
||||
user-timers-lib.sh entertainment-plugin-lib.sh usb-lib.sh share-lib.sh menu-lib.sh registry.sh \
|
||||
yt-lib.sh bank-lib.sh)
|
||||
# Exact lines postinstall.sh appends to ~/.bashrc.
|
||||
PATH_LINE='export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$HOME/.local/bin:$PATH"'
|
||||
COMPLETION_LINE='source /usr/local/share/bash-completion/completions/pos.bash 2>/dev/null || true'
|
||||
|
||||
+16
-2
@@ -50,6 +50,7 @@ _pos_subcmds[share-nfs-server]="status share unshare list reload enable disable
|
||||
_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-backup]="menu"
|
||||
_pos_subcmds[system-bank]="list add show run edit remove"
|
||||
_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"
|
||||
# GEN:END possubcmds
|
||||
@@ -113,7 +114,14 @@ _pos() {
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────
|
||||
_pos_complete_categories() {
|
||||
COMPREPLY=($(compgen -W "${!cat_cmds[*]} config" -- "$cur"))
|
||||
local solos=()
|
||||
for cmd in "${all_cmds[@]}"; do
|
||||
local cat_c="${cmd%%-*}"
|
||||
if [ "$cat_c" = "$cmd" ] && [ -z "${cat_cmds[$cmd]:-}" ]; then
|
||||
solos+=("$cmd")
|
||||
fi
|
||||
done
|
||||
COMPREPLY=($(compgen -W "${!cat_cmds[*]} ${solos[*]}" -- "$cur"))
|
||||
}
|
||||
|
||||
_pos_complete_subcats() {
|
||||
@@ -229,7 +237,13 @@ _pos() {
|
||||
3)
|
||||
case "${words[1]}" in
|
||||
config) _pos_config_scopes ;;
|
||||
*) _pos_complete_subcats ;;
|
||||
*)
|
||||
if [[ "${words[1]}" == *-* ]]; then
|
||||
_pos_complete_subcats
|
||||
else
|
||||
_pos_complete_tool "${words[1]}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
4)
|
||||
|
||||
+1
-1
@@ -175,7 +175,7 @@ if should_run 2 scripts; then
|
||||
done
|
||||
lib_count=0
|
||||
lib_names=""
|
||||
for lf in common.sh flags.sh notify.sh entertainment-lib.sh scheduler-lib.sh config-ui.sh user-timers-lib.sh entertainment-plugin-lib.sh usb-lib.sh share-lib.sh menu-lib.sh registry.sh; do
|
||||
for lf in common.sh flags.sh notify.sh entertainment-lib.sh scheduler-lib.sh config-ui.sh user-timers-lib.sh entertainment-plugin-lib.sh usb-lib.sh share-lib.sh menu-lib.sh registry.sh yt-lib.sh bank-lib.sh; do
|
||||
run sudo install -m 644 "lib/$lf" "/usr/local/bin/$lf"
|
||||
lib_count=$((lib_count + 1))
|
||||
lib_names+="$lf "
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env bash
|
||||
# lib/bank-lib.sh — shared storage helpers for the Command Bank.
|
||||
# Sourced by bin/pos-system-bank. Uses err() from lib/common.sh.
|
||||
#
|
||||
# Storage: ~/.config/linux_post_install/bank.env
|
||||
# Format: name|description|command
|
||||
# v2: commands with real newlines are saved with \\ (backslash) and \n
|
||||
# (newline) escapes; files written by v1 have no marker and load raw.
|
||||
#
|
||||
# Contracts:
|
||||
# * Defines ONLY bank_* functions — sourcing never clobbers a tool's helpers.
|
||||
# * Requires common.sh to be sourced by the CALLER.
|
||||
# * NEVER exits — return codes only.
|
||||
# * Performs NO interactive prompts (those stay in bin/pos-system-bank).
|
||||
|
||||
CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}"
|
||||
BANK_FILE="${BANK_FILE:-${CONFIG_DIR}/bank.env}"
|
||||
|
||||
# ── Load ────────────────────────────────────────────────────────
|
||||
# Populate parallel arrays: BANK_NAMES, BANK_DESCS, BANK_CMDS.
|
||||
bank_load() {
|
||||
BANK_NAMES=(); BANK_DESCS=(); BANK_CMDS=()
|
||||
[ -f "$BANK_FILE" ] || return 0
|
||||
|
||||
local line name desc cmd v2=0
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" =~ ^[[:space:]]*# ]] && { [[ "$line" == *"BANK_VERSION: 2"* ]] && v2=1; continue; }
|
||||
[[ -z "${line// /}" ]] && continue
|
||||
IFS='|' read -r name desc cmd <<< "$line"
|
||||
[[ -z "$name" ]] && continue
|
||||
[ "$v2" -eq 1 ] && cmd="$(printf '%b' "$cmd")"
|
||||
BANK_NAMES+=("$name")
|
||||
BANK_DESCS+=("${desc:-}")
|
||||
BANK_CMDS+=("${cmd:-}")
|
||||
done < "$BANK_FILE"
|
||||
}
|
||||
|
||||
# ── Save ────────────────────────────────────────────────────────
|
||||
# Atomic overwrite from parallel arrays.
|
||||
bank_save() {
|
||||
local dir
|
||||
dir="$(dirname "$BANK_FILE")"
|
||||
mkdir -p "$dir"
|
||||
local tmp
|
||||
tmp="$(mktemp "${dir}/.bank.XXXXXX")"
|
||||
{
|
||||
printf '%s\n' "# Command Bank — managed by pos bank (do not hand-edit)"
|
||||
printf '%s\n' "# Format: name|description|command (\\n = escaped newline in command)"
|
||||
printf '%s\n' "# BANK_VERSION: 2"
|
||||
local i
|
||||
for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do
|
||||
local cmd="${BANK_CMDS[$i]}"
|
||||
# Escape: every backslash → \\, every real newline → \n (keeps one record per physical line)
|
||||
cmd="${cmd//\\/\\\\}"
|
||||
cmd="${cmd//$'\n'/\\n}"
|
||||
printf '%s|%s|%s\n' "${BANK_NAMES[$i]}" "${BANK_DESCS[$i]}" "$cmd"
|
||||
done
|
||||
} > "$tmp"
|
||||
chmod 600 "$tmp"
|
||||
mv "$tmp" "$BANK_FILE"
|
||||
}
|
||||
|
||||
# ── Add ─────────────────────────────────────────────────────────
|
||||
bank_add() {
|
||||
local name="$1" desc="$2" cmd="$3"
|
||||
bank_load
|
||||
local i
|
||||
for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do
|
||||
[[ "${BANK_NAMES[$i]}" == "$name" ]] && return 1
|
||||
done
|
||||
BANK_NAMES+=("$name")
|
||||
BANK_DESCS+=("$desc")
|
||||
BANK_CMDS+=("$cmd")
|
||||
bank_save
|
||||
}
|
||||
|
||||
# ── Remove ──────────────────────────────────────────────────────
|
||||
bank_remove() {
|
||||
local name="$1"
|
||||
bank_load
|
||||
local found=0 i
|
||||
for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do
|
||||
if [[ "${BANK_NAMES[$i]}" == "$name" ]]; then
|
||||
unset 'BANK_NAMES[i]'
|
||||
unset 'BANK_DESCS[i]'
|
||||
unset 'BANK_CMDS[i]'
|
||||
found=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
[[ "$found" -eq 1 ]] || return 1
|
||||
# Re-index arrays (unset leaves gaps)
|
||||
BANK_NAMES=("${BANK_NAMES[@]}")
|
||||
BANK_DESCS=("${BANK_DESCS[@]}")
|
||||
BANK_CMDS=("${BANK_CMDS[@]}")
|
||||
bank_save
|
||||
}
|
||||
|
||||
# ── Update ──────────────────────────────────────────────────────
|
||||
bank_update() {
|
||||
local name="$1" desc="$2" cmd="$3"
|
||||
bank_load
|
||||
local i
|
||||
for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do
|
||||
if [[ "${BANK_NAMES[$i]}" == "$name" ]]; then
|
||||
BANK_DESCS[$i]="$desc"
|
||||
BANK_CMDS[$i]="$cmd"
|
||||
bank_save
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Find (by name) ──────────────────────────────────────────────
|
||||
# Sets BANK_IDX. Returns 0 if found, 1 if not.
|
||||
bank_find() {
|
||||
local name="$1"
|
||||
bank_load
|
||||
local i
|
||||
for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do
|
||||
if [[ "${BANK_NAMES[$i]}" == "$name" ]]; then
|
||||
BANK_IDX=$i
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Get (by name) ───────────────────────────────────────────────
|
||||
# Outputs: name\tdescription\tcommand (tab-separated).
|
||||
bank_get() {
|
||||
local name="$1"
|
||||
bank_find "$name" || return 1
|
||||
printf '%s\t%s\t%s\n' "${BANK_NAMES[$BANK_IDX]}" "${BANK_DESCS[$BANK_IDX]}" "${BANK_CMDS[$BANK_IDX]}"
|
||||
}
|
||||
|
||||
# ── List names ──────────────────────────────────────────────────
|
||||
bank_list_names() {
|
||||
bank_load
|
||||
local i
|
||||
for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do
|
||||
printf '%s\n' "${BANK_NAMES[$i]}"
|
||||
done
|
||||
}
|
||||
|
||||
# ── Count ───────────────────────────────────────────────────────
|
||||
bank_count() {
|
||||
bank_load
|
||||
echo "${#BANK_NAMES[@]}"
|
||||
}
|
||||
|
||||
# ── Validate name ───────────────────────────────────────────────
|
||||
bank_valid_name() {
|
||||
[[ "$1" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]]
|
||||
}
|
||||
|
||||
# ── Extract {param} names from a command template ───────────────
|
||||
_extract_params() {
|
||||
local cmd="$1"
|
||||
grep -oE '\{[a-zA-Z_][a-zA-Z0-9_]*\}' <<< "$cmd" 2>/dev/null | \
|
||||
sed 's/[{}]//g' | \
|
||||
awk '!seen[$0]++' || true
|
||||
}
|
||||
|
||||
# ── Substitute {param} with quoted values ───────────────────────
|
||||
# Usage: _substitute_params "cmd template" "key1=val1" "key2=val2" ...
|
||||
# Uses awk to build quoted replacements safely, avoiding bash quote-nesting.
|
||||
_substitute_params() {
|
||||
local cmd="$1"; shift
|
||||
# Build a sed expression: for each key=val, replace {key} with "val"
|
||||
local sed_expr=""
|
||||
while [ $# -gt 0 ]; do
|
||||
local key="${1%%=*}"
|
||||
local val="${1#*=}"
|
||||
# Escape sed special chars in the value
|
||||
val="${val//\\/\\\\}"
|
||||
val="${val//\//\\/}"
|
||||
sed_expr+="s/{${key}}/\"${val}\"/g; "
|
||||
shift
|
||||
done
|
||||
[ -n "$sed_expr" ] && printf '%s' "$cmd" | sed "$sed_expr" || printf '%s' "$cmd"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# Decision: lib-registration standard is a 3-surface symmetry (install.sh + POS_LIBS + docs)
|
||||
|
||||
## Finding (2026-09-12, MAINTENANCE_COMPLETE)
|
||||
|
||||
lib/bank-lib.sh (added 41efc91 feat: pos bank) was never registered in install.sh's
|
||||
phase-2 lib copy list, bin/pos-system-uninstall's POS_LIBS, or the hand-maintained docs.
|
||||
lib/yt-lib.sh had been added to install.sh (0b76d4d) but missed POS_LIBS. Result: after
|
||||
`./install.sh`, `pos bank` failed with `/usr/local/bin/bank-lib.sh: No such file or directory`,
|
||||
and uninstall would leave stale yt-lib.sh. The symmetry gate tests/t-uninstall-manifest.sh
|
||||
(set-equality of the two lists) was failing.
|
||||
|
||||
## Established standard (restored)
|
||||
|
||||
Every lib/ file sourced by a bin/ tool MUST be registered in all THREE places:
|
||||
1. install.sh phase-2 lib copy loop (`for lf in …; do`) → ships to /usr/local/bin,
|
||||
2. bin/pos-system-uninstall `POS_LIBS` array (same set, backslash-continuation style),
|
||||
3. hand-maintained docs: DOC/SCRIPTS.md phase-2 table row + DOC/AGENT_Context_Project.md
|
||||
phase-2 lib list AND its hand-maintained filetable row (above GEN:START filetable,
|
||||
with real `wc -l` count).
|
||||
|
||||
Validator: tests/t-uninstall-manifest.sh (must stay green; it enforces set equality and
|
||||
that every listed lib exists). Never edit GEN:START/GEN:END blocks by hand — run make gen.
|
||||
@@ -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"
|
||||
|
||||
+3
-1
@@ -55,4 +55,6 @@ silently.
|
||||
| `t-lint-gate.sh` | `make lint` green on the real tree; planted violations are caught and named |
|
||||
| `t-install-version.sh` | install.sh version gate: match→skip, mismatch→proceed, --force bypass, dry-run variant, flag write, numeric comparison |
|
||||
| `t-share-mountpoint.sh` | share-client `ask_mountpoint` UX: existing/new/declined/rejected paths, confirm gate, mkdir side effects, non-TTY stdin contract, static `n`→`t` guards |
|
||||
| `t-pos-media-yt.sh` | unified `pos media yt` suite: dispatcher + forwarder resolution, shared yt-lib helpers, yt-mp3/mp4/grab/subtitles flags, dry-run deps, `YT_OUT_DIR` seam, `GRAB_DEFAULT` config, negative controls (unsafe-URL no-expansion, `--lang en,ar` single arg, txt timestamp-stripping) |
|
||||
| `t-pos-media-yt.sh` | unified `pos media yt` suite: dispatcher + forwarder resolution, shared yt-lib helpers, yt-mp3/mp4/grab/subtitles flags, dry-run deps, `YT_OUT_DIR` seam, `GRAB_DEFAULT` config, negative controls (unsafe-URL no-expansion, `--lang en,ar` single arg, txt timestamp-stripping) |
|
||||
| `t-telegram-listener-singleton.sh` | Telegram listener single-instance guard: first `--run` acquires the flock, second `--run` fails fast with the exact message, lock auto-releases so the next start is clean, `--status` reports the lock state |
|
||||
| `t-bank.sh` | Command Bank: bank-lib.sh unit tests (add/remove/update/find/get/list/count/valid/extract_params/substitute_params, multiline `\n` storage round-trip, literal-`\n` escape round-trip, v1 backward compat) + pos system bank CLI integration (help, list, add, show, run, remove, params, invalid name, multiline show/run) |
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/bank-test-helper.sh — minimal shim that sources bank-lib.sh for unit
|
||||
# testing. Provides a stub `err()` so bank-lib.sh can load without the full
|
||||
# common.sh / menu-lib.sh dependency chain.
|
||||
set -euo pipefail
|
||||
|
||||
# Stub err() — bank-lib.sh is contractually "never exits" but bank_valid_name
|
||||
# callers sometimes chain via `|| err …`. This version returns 1 so the test
|
||||
# harness can assert rc.
|
||||
err() { printf 'ERROR: %s\n' "$*" >&2; return 1; }
|
||||
|
||||
# Source the library under test.
|
||||
source "$ROOT/lib/bank-lib.sh"
|
||||
Executable
+467
@@ -0,0 +1,467 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# t-bank.sh — Command Bank feature: bank-lib.sh unit tests + pos system bank CLI
|
||||
# integration tests.
|
||||
#
|
||||
# Storage contract: pipe-delimited name|description|command in $BANK_FILE.
|
||||
# Parameter syntax: {param} placeholders in command templates.
|
||||
|
||||
run_test() {
|
||||
local sandbox helper
|
||||
sandbox="$(mksandbox bank)"
|
||||
helper="$sandbox/helper.sh"
|
||||
|
||||
# ── Build a sourced helper that gives us bank-lib functions ──
|
||||
cat > "$helper" <<'HELPER'
|
||||
source "$ROOT/tests/bank-test-helper.sh"
|
||||
HELPER
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Part A: bank-lib.sh unit tests
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
# A1: bank_add + bank_load — add two entries, verify arrays
|
||||
(
|
||||
BANK_FILE="$sandbox/a1.bank.env"
|
||||
: > "$BANK_FILE"
|
||||
source "$helper"
|
||||
bank_add "deploy" "Deploy to prod" "ssh prod 'cd /app && git pull'"
|
||||
bank_add "backup" "Backup database" "pg_dump mydb | gzip > /tmp/db.sql.gz"
|
||||
bank_load
|
||||
# Check arrays
|
||||
[ "${#BANK_NAMES[@]}" -eq 2 ] || { echo "FAIL A1: expected 2 names, got ${#BANK_NAMES[@]}"; exit 1; }
|
||||
[ "${BANK_NAMES[0]}" = "deploy" ] || { echo "FAIL A1: name[0] mismatch"; exit 1; }
|
||||
[ "${BANK_DESCS[0]}" = "Deploy to prod" ] || { echo "FAIL A1: desc[0] mismatch"; exit 1; }
|
||||
[ "${BANK_CMDS[1]}" = "pg_dump mydb | gzip > /tmp/db.sql.gz" ] || { echo "FAIL A1: cmd[1] mismatch"; exit 1; }
|
||||
echo "PASS A1: bank_add + bank_load populates arrays correctly"
|
||||
) && printf ' PASS bank_add + bank_load populates arrays\n' \
|
||||
|| printf ' FAIL bank_add + bank_load populates arrays\n'
|
||||
|
||||
# A2: bank_add duplicate — returns rc 1
|
||||
(
|
||||
BANK_FILE="$sandbox/a2.bank.env"
|
||||
printf 'old|Old cmd|echo old\n' > "$BANK_FILE"
|
||||
source "$helper"
|
||||
bank_add "old" "New desc" "echo new"
|
||||
exit $?
|
||||
)
|
||||
check_rc "bank_add duplicate returns rc 1" 1 $?
|
||||
|
||||
# A3: bank_remove — remove entry, verify gone
|
||||
(
|
||||
BANK_FILE="$sandbox/a3.bank.env"
|
||||
printf 'aaa|desc a|cmd a\nbbb|desc b|cmd b\nccc|desc c|cmd c\n' > "$BANK_FILE"
|
||||
source "$helper"
|
||||
bank_remove "bbb"
|
||||
bank_load
|
||||
[ "${#BANK_NAMES[@]}" -eq 2 ] || { echo "FAIL A3: expected 2, got ${#BANK_NAMES[@]}"; exit 1; }
|
||||
[ "${BANK_NAMES[0]}" = "aaa" ] || { echo "FAIL A3: name[0] mismatch"; exit 1; }
|
||||
[ "${BANK_NAMES[1]}" = "ccc" ] || { echo "FAIL A3: name[1] mismatch"; exit 1; }
|
||||
# Verify file on disk
|
||||
grep -q '^bbb|' "$BANK_FILE" && { echo "FAIL A3: bbb still in file"; exit 1; }
|
||||
echo "PASS A3: bank_remove"
|
||||
) && printf ' PASS bank_remove removes entry\n' \
|
||||
|| printf ' FAIL bank_remove removes entry\n'
|
||||
|
||||
# A4: bank_remove missing — returns rc 1
|
||||
(
|
||||
BANK_FILE="$sandbox/a4.bank.env"
|
||||
printf 'xxx|desc|cmd\n' > "$BANK_FILE"
|
||||
source "$helper"
|
||||
bank_remove "nonexistent"
|
||||
exit $?
|
||||
)
|
||||
check_rc "bank_remove missing returns rc 1" 1 $?
|
||||
|
||||
# A5: bank_update — update entry, verify changed
|
||||
(
|
||||
BANK_FILE="$sandbox/a5.bank.env"
|
||||
printf 'mycmd|old desc|old cmd\n' > "$BANK_FILE"
|
||||
source "$helper"
|
||||
bank_update "mycmd" "new desc" "new cmd"
|
||||
bank_load
|
||||
[ "${BANK_DESCS[0]}" = "new desc" ] || { echo "FAIL A5: desc mismatch"; exit 1; }
|
||||
[ "${BANK_CMDS[0]}" = "new cmd" ] || { echo "FAIL A5: cmd mismatch"; exit 1; }
|
||||
# Verify on disk
|
||||
grep -q 'new desc|new cmd' "$BANK_FILE" || { echo "FAIL A5: not persisted"; exit 1; }
|
||||
echo "PASS A5: bank_update"
|
||||
) && printf ' PASS bank_update updates entry\n' \
|
||||
|| printf ' FAIL bank_update updates entry\n'
|
||||
|
||||
# A6: bank_update missing — returns rc 1
|
||||
(
|
||||
BANK_FILE="$sandbox/a6.bank.env"
|
||||
printf 'exists|desc|cmd\n' > "$BANK_FILE"
|
||||
source "$helper"
|
||||
bank_update "nope" "desc" "cmd"
|
||||
exit $?
|
||||
)
|
||||
check_rc "bank_update missing returns rc 1" 1 $?
|
||||
|
||||
# A7: bank_find — found sets BANK_IDX, not-found returns 1
|
||||
(
|
||||
BANK_FILE="$sandbox/a7.bank.env"
|
||||
printf 'alpha|d1|c1\nbeta|d2|c2\ngamma|d3|c3\n' > "$BANK_FILE"
|
||||
source "$helper"
|
||||
bank_find "beta"
|
||||
[ "${BANK_IDX:-}" = "1" ] || { echo "FAIL A7: expected BANK_IDX=1, got ${BANK_IDX:-unset}"; exit 1; }
|
||||
bank_find "nonexistent" && { echo "FAIL A7: should return 1 for missing"; exit 1; }
|
||||
echo "PASS A7: bank_find"
|
||||
) && printf ' PASS bank_find sets BANK_IDX / returns 1\n' \
|
||||
|| printf ' FAIL bank_find sets BANK_IDX / returns 1\n'
|
||||
|
||||
# A8: bank_get — returns tab-separated output
|
||||
(
|
||||
BANK_FILE="$sandbox/a8.bank.env"
|
||||
printf 'mytool|My Tool|echo hello\n' > "$BANK_FILE"
|
||||
source "$helper"
|
||||
local out
|
||||
out="$(bank_get "mytool")"
|
||||
[ "$out" = "mytool My Tool echo hello" ] || { echo "FAIL A8: unexpected output [$out]"; exit 1; }
|
||||
echo "PASS A8: bank_get"
|
||||
) && printf ' PASS bank_get returns tab-separated output\n' \
|
||||
|| printf ' FAIL bank_get returns tab-separated output\n'
|
||||
|
||||
# A9: bank_list_names — lists all names
|
||||
(
|
||||
BANK_FILE="$sandbox/a9.bank.env"
|
||||
printf 'one|x|y\ntwo|a|b\nthree|c|d\n' > "$BANK_FILE"
|
||||
source "$helper"
|
||||
local names
|
||||
names="$(bank_list_names)"
|
||||
local expected
|
||||
expected="$(printf 'one\ntwo\nthree')"
|
||||
[ "$names" = "$expected" ] || { echo "FAIL A9: expected [$expected] got [$names]"; exit 1; }
|
||||
echo "PASS A9: bank_list_names"
|
||||
) && printf ' PASS bank_list_names lists all names\n' \
|
||||
|| printf ' FAIL bank_list_names lists all names\n'
|
||||
|
||||
# A10: bank_count — returns correct count
|
||||
(
|
||||
BANK_FILE="$sandbox/a10.bank.env"
|
||||
printf 'a|1|2\nb|3|4\nc|5|6\nd|7|8\n' > "$BANK_FILE"
|
||||
source "$helper"
|
||||
local cnt
|
||||
cnt="$(bank_count)"
|
||||
[ "$cnt" -eq 4 ] || { echo "FAIL A10: expected 4, got $cnt"; exit 1; }
|
||||
echo "PASS A10: bank_count"
|
||||
) && printf ' PASS bank_count returns correct count\n' \
|
||||
|| printf ' FAIL bank_count returns correct count\n'
|
||||
|
||||
# A11: bank_count — empty file returns 0
|
||||
(
|
||||
BANK_FILE="$sandbox/a11.bank.env"
|
||||
: > "$BANK_FILE"
|
||||
source "$helper"
|
||||
local cnt
|
||||
cnt="$(bank_count)"
|
||||
[ "$cnt" -eq 0 ] || { echo "FAIL A11: expected 0, got $cnt"; exit 1; }
|
||||
echo "PASS A11: bank_count empty"
|
||||
) && printf ' PASS bank_count returns 0 for empty file\n' \
|
||||
|| printf ' FAIL bank_count returns 0 for empty file\n'
|
||||
|
||||
# A12: bank_valid_name — valid names pass
|
||||
(
|
||||
source "$helper"
|
||||
local fail=0
|
||||
for n in "deploy" "my-cmd" "test_123" "A" "z9"; do
|
||||
bank_valid_name "$n" || { fail=1; printf 'FAIL A12: %s should be valid\n' "$n" >&2; }
|
||||
done
|
||||
[ "$fail" -eq 0 ] || exit 1
|
||||
echo "PASS A12: bank_valid_name accepts valid names"
|
||||
) && printf ' PASS bank_valid_name accepts valid names\n' \
|
||||
|| printf ' FAIL bank_valid_name accepts valid names\n'
|
||||
|
||||
# A13: bank_valid_name — invalid names fail
|
||||
(
|
||||
source "$helper"
|
||||
local fail=0
|
||||
for n in "" "1abc" "-start" "_under" "has space" "special!"; do
|
||||
bank_valid_name "$n" && { fail=1; printf 'FAIL A13: %s should be invalid\n' "$n" >&2; }
|
||||
done
|
||||
[ "$fail" -eq 0 ] || exit 1
|
||||
echo "PASS A13: bank_valid_name rejects invalid names"
|
||||
) && printf ' PASS bank_valid_name rejects invalid names\n' \
|
||||
|| printf ' FAIL bank_valid_name rejects invalid names\n'
|
||||
|
||||
# A14: _extract_params — extracts {param} names from template
|
||||
(
|
||||
source "$helper"
|
||||
local params
|
||||
params="$(_extract_params "echo {name} {path}")"
|
||||
local expected
|
||||
expected="$(printf 'name\npath')"
|
||||
[ "$params" = "$expected" ] || { echo "FAIL A14: expected [$expected] got [$params]"; exit 1; }
|
||||
echo "PASS A14: _extract_params"
|
||||
) && printf ' PASS _extract_params extracts param names\n' \
|
||||
|| printf ' FAIL _extract_params extracts param names\n'
|
||||
|
||||
# A15: _extract_params — no params returns empty
|
||||
(
|
||||
source "$helper"
|
||||
local params
|
||||
params="$(_extract_params "echo hello world")"
|
||||
[ -z "$params" ] || { echo "FAIL A15: expected empty, got [$params]"; exit 1; }
|
||||
echo "PASS A15: _extract_params empty"
|
||||
) && printf ' PASS _extract_params returns empty for no params\n' \
|
||||
|| printf ' FAIL _extract_params returns empty for no params\n'
|
||||
|
||||
# A16: _extract_params — deduplicates repeated params
|
||||
(
|
||||
source "$helper"
|
||||
local params
|
||||
params="$(_extract_params "cp {src} {dst} && ls {src}")"
|
||||
local count
|
||||
count="$(printf '%s' "$params" | grep -c '^src$' || true)"
|
||||
[ "$count" -eq 1 ] || { echo "FAIL A16: expected 1 occurrence of src, got $count"; exit 1; }
|
||||
echo "PASS A16: _extract_params deduplicates"
|
||||
) && printf ' PASS _extract_params deduplicates\n' \
|
||||
|| printf ' FAIL _extract_params deduplicates\n'
|
||||
|
||||
# A17: _substitute_params — replaces {param} with quoted values
|
||||
(
|
||||
source "$helper"
|
||||
local result
|
||||
result="$(_substitute_params "scp {file} user@host:{dest}" "file=/tmp/data.csv" "dest=/var/data")"
|
||||
local expected='scp "/tmp/data.csv" user@host:"/var/data"'
|
||||
[ "$result" = "$expected" ] || { echo "FAIL A17: expected [$expected] got [$result]"; exit 1; }
|
||||
echo "PASS A17: _substitute_params"
|
||||
) && printf ' PASS _substitute_params replaces params\n' \
|
||||
|| printf ' FAIL _substitute_params replaces params\n'
|
||||
|
||||
# A18: bank_load — comments and blank lines are skipped
|
||||
(
|
||||
BANK_FILE="$sandbox/a18.bank.env"
|
||||
cat > "$BANK_FILE" <<'BANK'
|
||||
# Command Bank — managed by pos system bank (do not hand-edit)
|
||||
# Format: name|description|command
|
||||
|
||||
real|Real command|echo real
|
||||
# another comment
|
||||
|
||||
# indented comment
|
||||
|
||||
BANK
|
||||
source "$helper"
|
||||
bank_load
|
||||
[ "${#BANK_NAMES[@]}" -eq 1 ] || { echo "FAIL A18: expected 1, got ${#BANK_NAMES[@]}"; exit 1; }
|
||||
[ "${BANK_NAMES[0]}" = "real" ] || { echo "FAIL A18: name mismatch"; exit 1; }
|
||||
echo "PASS A18: bank_load skips comments and blanks"
|
||||
) && printf ' PASS bank_load skips comments and blank lines\n' \
|
||||
|| printf ' FAIL bank_load skips comments and blank lines\n'
|
||||
|
||||
# A19: multiline command storage round-trip — escapes \n, single record line
|
||||
(
|
||||
BANK_FILE="$sandbox/a19.bank.env"
|
||||
: > "$BANK_FILE"
|
||||
source "$helper"
|
||||
script="$(cat <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
for tag in one two; do
|
||||
out="$(printf '%s' "$tag" | tr 'a-z' 'A-Z')"
|
||||
echo "tag=${tag} out=${out} done"
|
||||
done
|
||||
SCRIPT
|
||||
)"
|
||||
bank_add "ml" "Multiline demo" "$script"
|
||||
bank_load
|
||||
[ "${#BANK_NAMES[@]}" -eq 1 ] || { echo "FAIL A19: expected 1 entry, got ${#BANK_NAMES[@]}"; exit 1; }
|
||||
[ "${BANK_CMDS[0]}" = "$script" ] || { echo "FAIL A19: command bytes differ"; exit 1; }
|
||||
rec_lines="$(grep -c '^ml|' "$BANK_FILE" || true)"
|
||||
[ "$rec_lines" -eq 1 ] || { echo "FAIL A19: expected 1 record line, got $rec_lines"; exit 1; }
|
||||
[ "$(wc -l < "$BANK_FILE")" -eq 4 ] || { echo "FAIL A19: file has bogus physical lines"; exit 1; }
|
||||
grep -q '\\n' "$BANK_FILE" || { echo "FAIL A19: missing \\n escape in file"; exit 1; }
|
||||
echo "PASS A19: multiline round-trip"
|
||||
) && printf ' PASS multiline storage round-trip (one record line, \\n escapes)\n' \
|
||||
|| printf ' FAIL multiline storage round-trip (one record line, \\n escapes)\n'
|
||||
|
||||
# A20: literal \n (backslash-n) in command round-trips literally — NOT a real newline
|
||||
(
|
||||
BANK_FILE="$sandbox/a20.bank.env"
|
||||
: > "$BANK_FILE"
|
||||
source "$helper"
|
||||
esc="$(printf '%s' "printf 'a\\nb'")"
|
||||
bank_add "esc" "Escapes" "$esc"
|
||||
bank_load
|
||||
[ "${BANK_CMDS[0]}" = "$esc" ] || { echo "FAIL A20: literal \\n not preserved [${BANK_CMDS[0]}]"; exit 1; }
|
||||
case "${BANK_CMDS[0]}" in
|
||||
*$'\n'*) echo "FAIL A20: decoded literal \\n into a real newline"; exit 1 ;;
|
||||
esac
|
||||
echo "PASS A20: literal \\n round-trip"
|
||||
) && printf ' PASS literal \\n command round-trips literally\n' \
|
||||
|| printf ' FAIL literal \\n command round-trips literally\n'
|
||||
|
||||
# A21: old-format file (no BANK_VERSION marker) keeps raw backslashes — no %b decode
|
||||
(
|
||||
BANK_FILE="$sandbox/a21.bank.env"
|
||||
cat > "$BANK_FILE" <<'BANK'
|
||||
# Command Bank — managed by pos system bank (do not hand-edit)
|
||||
# Format: name|description|command
|
||||
raw|Raw echo|echo 'a\b'
|
||||
BANK
|
||||
source "$helper"
|
||||
bank_load
|
||||
expected="$(printf '%s' "echo 'a\\b'")"
|
||||
[ "${BANK_CMDS[0]}" = "$expected" ] || { echo "FAIL A21: raw backslash changed [${BANK_CMDS[0]}]"; exit 1; }
|
||||
echo "PASS A21: old-format raw backslash"
|
||||
) && printf ' PASS old-format file keeps raw backslash (no decode)\n' \
|
||||
|| printf ' FAIL old-format file keeps raw backslash (no decode)\n'
|
||||
|
||||
# A22: old-format entry survives a re-save byte-identically (escape + decode round-trip)
|
||||
(
|
||||
BANK_FILE="$sandbox/a22.bank.env"
|
||||
cat > "$BANK_FILE" <<'BANK'
|
||||
# Command Bank — managed by pos system bank (do not hand-edit)
|
||||
# Format: name|description|command
|
||||
ts-google|Tailscale Google|tailscale status --json >/tmp/ts.json && grep -q '"ExitNodeStatus":null' /tmp/ts.json && tailscale set --exit-node=google || tailscale set --exit-node=
|
||||
BANK
|
||||
source "$helper"
|
||||
bank_load
|
||||
orig="${BANK_CMDS[0]}"
|
||||
bank_add "new-cmd" "New entry" "echo ok"
|
||||
bank_load
|
||||
[ "${#BANK_NAMES[@]}" -eq 2 ] || { echo "FAIL A22: expected 2 entries, got ${#BANK_NAMES[@]}"; exit 1; }
|
||||
[ "${BANK_CMDS[0]}" = "$orig" ] || { echo "FAIL A22: old entry changed after re-save"; exit 1; }
|
||||
[ "${BANK_CMDS[1]}" = "echo ok" ] || { echo "FAIL A22: new entry missing"; exit 1; }
|
||||
echo "PASS A22: old-format re-save round-trip"
|
||||
) && printf ' PASS old-format entry round-trips through re-save\n' \
|
||||
|| printf ' FAIL old-format entry round-trips through re-save\n'
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Part B: pos system bank CLI integration tests
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
local pos_bank="$ROOT/bin/pos-system-bank"
|
||||
|
||||
# B1: pos system bank --help shows usage
|
||||
test_run env BANK_FILE="$sandbox/b1.bank.env" "$pos_bank" --help
|
||||
check_rc "pos system bank --help exits 0" 0 "$TR_RC"
|
||||
check_contains "pos system bank --help shows Usage" "Usage:" "$TR_OUT"
|
||||
check_contains "pos system bank --help mentions subcommands" "Subcommands:" "$TR_OUT"
|
||||
|
||||
# B2: pos system bank list on empty bank shows empty message
|
||||
: > "$sandbox/b2.bank.env"
|
||||
test_run env BANK_FILE="$sandbox/b2.bank.env" "$pos_bank" list
|
||||
check_rc "pos system bank list empty exits 0" 0 "$TR_RC"
|
||||
check_contains "pos system bank list empty message" "Command bank is empty" "$TR_OUT"
|
||||
|
||||
# B3: pos system bank add — adds a command, verify in bank.env
|
||||
: > "$sandbox/b3.bank.env"
|
||||
test_run env BANK_FILE="$sandbox/b3.bank.env" "$pos_bank" add "disk-usage" "Check disk usage" "df -h"
|
||||
check_rc "pos system bank add exits 0" 0 "$TR_RC"
|
||||
check_contains "pos system bank add confirms" "Saved: disk-usage" "$TR_OUT"
|
||||
# Verify the file
|
||||
if grep -q '^disk-usage|Check disk usage|df -h$' "$sandbox/b3.bank.env"; then
|
||||
printf ' PASS pos system bank add persists to bank.env\n'
|
||||
else
|
||||
printf ' FAIL pos system bank add did not persist to bank.env\n'
|
||||
fi
|
||||
|
||||
# B4: pos system bank add duplicate — fails
|
||||
test_run env BANK_FILE="$sandbox/b3.bank.env" "$pos_bank" add "disk-usage" "dup" "echo dup"
|
||||
check_not_contains "pos system bank add duplicate errors" "0" "$TR_RC"
|
||||
check_contains "pos system bank add duplicate message" "already exists" "$TR_OUT"
|
||||
|
||||
# B5: pos system bank show — shows command details
|
||||
test_run env BANK_FILE="$sandbox/b3.bank.env" "$pos_bank" show "disk-usage"
|
||||
check_rc "pos system bank show exits 0" 0 "$TR_RC"
|
||||
check_contains "pos system bank show shows name" "Name: disk-usage" "$TR_OUT"
|
||||
check_contains "pos system bank show shows description" "Check disk usage" "$TR_OUT"
|
||||
check_contains "pos system bank show shows command" "df -h" "$TR_OUT"
|
||||
|
||||
# B6: pos system bank show missing — fails
|
||||
test_run env BANK_FILE="$sandbox/b3.bank.env" "$pos_bank" show "nonexistent"
|
||||
check_not_contains "pos system bank show missing exits non-zero" "0" "$TR_RC"
|
||||
check_contains "pos system bank show missing message" "not found" "$TR_OUT"
|
||||
|
||||
# B7: pos system bank list after add — shows the entry
|
||||
test_run env BANK_FILE="$sandbox/b3.bank.env" "$pos_bank" list
|
||||
check_rc "pos system bank list populated exits 0" 0 "$TR_RC"
|
||||
check_contains "pos system bank list shows header" "COMMAND BANK" "$TR_OUT"
|
||||
check_contains "pos system bank list shows entry" "disk-usage" "$TR_OUT"
|
||||
|
||||
# B8: pos system bank remove — removes a command
|
||||
test_run env BANK_FILE="$sandbox/b3.bank.env" "$pos_bank" remove "disk-usage"
|
||||
check_rc "pos system bank remove exits 0" 0 "$TR_RC"
|
||||
check_contains "pos system bank remove confirms" "Removed: disk-usage" "$TR_OUT"
|
||||
# Verify gone
|
||||
if grep -q '^disk-usage|' "$sandbox/b3.bank.env"; then
|
||||
printf ' FAIL pos system bank remove did not delete from bank.env\n'
|
||||
else
|
||||
printf ' PASS pos system bank remove deletes from bank.env\n'
|
||||
fi
|
||||
|
||||
# B9: pos system bank remove missing — fails
|
||||
: > "$sandbox/b9.bank.env"
|
||||
test_run env BANK_FILE="$sandbox/b9.bank.env" "$pos_bank" remove "ghost"
|
||||
check_not_contains "pos system bank remove missing exits non-zero" "0" "$TR_RC"
|
||||
check_contains "pos system bank remove missing message" "not found" "$TR_OUT"
|
||||
|
||||
# B10: pos system bank add with parameters — show lists them
|
||||
: > "$sandbox/b10.bank.env"
|
||||
test_run env BANK_FILE="$sandbox/b10.bank.env" "$pos_bank" add "convert" "Convert video" "ffmpeg -i {input} -q:v {quality} {output}"
|
||||
check_rc "pos system bank add with params exits 0" 0 "$TR_RC"
|
||||
test_run env BANK_FILE="$sandbox/b10.bank.env" "$pos_bank" show "convert"
|
||||
check_contains "show lists input param" "input" "$TR_OUT"
|
||||
check_contains "show lists quality param" "quality" "$TR_OUT"
|
||||
check_contains "show lists output param" "output" "$TR_OUT"
|
||||
|
||||
# B11: pos system bank add invalid name — fails
|
||||
: > "$sandbox/b11.bank.env"
|
||||
test_run env BANK_FILE="$sandbox/b11.bank.env" "$pos_bank" add "1bad" "desc" "cmd"
|
||||
check_not_contains "pos system bank add invalid name exits non-zero" "0" "$TR_RC"
|
||||
check_contains "pos system bank add invalid name message" "Invalid name" "$TR_OUT"
|
||||
|
||||
# B12: pos system bank run — executes a saved command (no params)
|
||||
# Negative control: the old `local name="" -a cli_params=()` declaration
|
||||
# crashed at line 150 before any output (rc != 0, no "Running:" line).
|
||||
: > "$sandbox/b12.bank.env"
|
||||
test_run env BANK_FILE="$sandbox/b12.bank.env" "$pos_bank" add "greet" "Greet" "echo bank-run-ok"
|
||||
check_rc "pos system bank add for run exits 0" 0 "$TR_RC"
|
||||
test_run env BANK_FILE="$sandbox/b12.bank.env" "$pos_bank" run "greet"
|
||||
check_rc "pos system bank run executes saved command" 0 "$TR_RC"
|
||||
check_contains "pos system bank run logs the command" "Running: echo bank-run-ok" "$TR_OUT"
|
||||
check_contains "pos system bank run executes output" "bank-run-ok" "$TR_OUT"
|
||||
|
||||
# B13: pos system bank run missing command — fails
|
||||
test_run env BANK_FILE="$sandbox/b12.bank.env" "$pos_bank" run "ghost"
|
||||
check_not_contains "pos system bank run missing exits non-zero" "0" "$TR_RC"
|
||||
check_contains "pos system bank run missing message" "Command not found" "$TR_OUT"
|
||||
|
||||
# B14: pos system bank run with params — CLI key=val substitution, no interactive prompt
|
||||
: > "$sandbox/b14.bank.env"
|
||||
test_run env BANK_FILE="$sandbox/b14.bank.env" "$pos_bank" add "echo-param" "Echo param" "echo hi {who}"
|
||||
check_rc "pos system bank add param exits 0" 0 "$TR_RC"
|
||||
test_run env BANK_FILE="$sandbox/b14.bank.env" "$pos_bank" run "echo-param" "who=there"
|
||||
check_rc "pos system bank run with params exits 0" 0 "$TR_RC"
|
||||
check_contains "pos system bank run substitutes param" 'Running: echo hi "there"' "$TR_OUT"
|
||||
check_contains "pos system bank run executes substituted command" "hi there" "$TR_OUT"
|
||||
|
||||
# B15: pos system bank add multiline + show — cmd_show retrieves the FULL script via arrays
|
||||
# Negative control: bank_get + cut -f3 truncated the command at the first newline.
|
||||
# (Brace-free script: {param} template detection would prompt on run in a non-TTY.)
|
||||
: > "$sandbox/b15.bank.env"
|
||||
ml_script="$(cat <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
n=0
|
||||
while [ "$n" -lt 2 ]; do
|
||||
n=$((n + 1))
|
||||
echo "round $n: $(printf 'ok')"
|
||||
done
|
||||
SCRIPT
|
||||
)"
|
||||
test_run env BANK_FILE="$sandbox/b15.bank.env" "$pos_bank" add "ml-demo" "Multiline demo" "$ml_script"
|
||||
check_rc "pos system bank add multiline exits 0" 0 "$TR_RC"
|
||||
test_run env BANK_FILE="$sandbox/b15.bank.env" "$pos_bank" show "ml-demo"
|
||||
check_rc "pos system bank show multiline exits 0" 0 "$TR_RC"
|
||||
check_contains "pos system bank show prints script shebang" '#!/usr/bin/env bash' "$TR_OUT"
|
||||
check_contains "pos system bank show prints loop line" 'while [ "$n" -lt 2 ]; do' "$TR_OUT"
|
||||
check_contains "pos system bank show prints arithmetic line" 'n=$((n + 1))' "$TR_OUT"
|
||||
check_contains "pos system bank show prints substitution echo" 'echo "round $n: $(printf' "$TR_OUT"
|
||||
|
||||
# B16: pos system bank run multiline — executes the WHOLE script via eval of the full command
|
||||
test_run env BANK_FILE="$sandbox/b15.bank.env" "$pos_bank" run "ml-demo"
|
||||
check_rc "pos system bank run multiline exits 0" 0 "$TR_RC"
|
||||
check_contains "pos system bank run multiline output line 1" "round 1: ok" "$TR_OUT"
|
||||
check_contains "pos system bank run multiline output line 2" "round 2: ok" "$TR_OUT"
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# t-telegram-listener-exec.sh — async command execution in the Telegram
|
||||
# listener. Proves the listener can execute ANY valid Bash command without
|
||||
# blocking: simple output, compound commands, pipes, stderr, long-running
|
||||
# (timeout), and that the listener stays responsive while a command runs.
|
||||
#
|
||||
# Hermetic: stubbed curl (serves a canned getUpdates batch with /command
|
||||
# messages, then empty batches), stubbed systemctl, real jq/timeout.
|
||||
# No network, no real Telegram, no FFmpeg (unless /dev/video0 exists).
|
||||
|
||||
run_test() {
|
||||
require_cmd jq "telegram exec" || return 0
|
||||
require_cmd timeout "telegram exec" || return 0
|
||||
|
||||
local sandbox stubs cfg curl_log marker listener batch
|
||||
sandbox="$(mksandbox telegram-exec)"
|
||||
stubs="$sandbox/stubs"
|
||||
cfg="$sandbox/cfg"
|
||||
curl_log="$sandbox/curl.log"
|
||||
marker="$sandbox/executed.log"
|
||||
listener="$ROOT/bin/pos-communication-telegram-listener"
|
||||
mkdir -p "$stubs" "$cfg"
|
||||
: > "$curl_log"
|
||||
: > "$marker"
|
||||
|
||||
# ── command map: one /command per line, each triggers a known behavior ──
|
||||
cat > "$cfg/telegram_commands.env" <<'MAP'
|
||||
/echo_hello=echo hello
|
||||
/compound=sleep 0.2 && echo done
|
||||
/stdout_test=printf 'line1\nline2\n'
|
||||
/stderr_test=bash -c 'echo error_msg >&2; echo output_msg'
|
||||
/pipe_test=echo "hello world" | tr ' ' '\n'
|
||||
/long_run=sleep 30
|
||||
/no_output=true
|
||||
/quiet_test=@quiet echo hello_quiet
|
||||
MAP
|
||||
|
||||
: > "$cfg/telegram_prefixes.env"
|
||||
|
||||
# ── stub curl ──
|
||||
# Serve a batch with 8 commands (one per mapped /command), then empty.
|
||||
local batch_file="$sandbox/batch.json"
|
||||
cat > "$batch_file" <<'JSON'
|
||||
{"ok":true,"result":[
|
||||
{"update_id":1,"message":{"message_id":10,"from":{"id":123},"chat":{"id":456},"text":"/echo_hello"}},
|
||||
{"update_id":2,"message":{"message_id":11,"from":{"id":123},"chat":{"id":456},"text":"/compound"}},
|
||||
{"update_id":3,"message":{"message_id":12,"from":{"id":123},"chat":{"id":456},"text":"/stdout_test"}},
|
||||
{"update_id":4,"message":{"message_id":13,"from":{"id":123},"chat":{"id":456},"text":"/stderr_test"}},
|
||||
{"update_id":5,"message":{"message_id":14,"from":{"id":123},"chat":{"id":456},"text":"/pipe_test"}},
|
||||
{"update_id":6,"message":{"message_id":15,"from":{"id":123},"chat":{"id":456},"text":"/long_run"}},
|
||||
{"update_id":7,"message":{"message_id":16,"from":{"id":123},"chat":{"id":456},"text":"/no_output"}},
|
||||
{"update_id":8,"message":{"message_id":17,"from":{"id":123},"chat":{"id":456},"text":"/quiet_test"}}
|
||||
]}
|
||||
JSON
|
||||
|
||||
cat > "$stubs/curl" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
printf 'curl %s\n' "\$*" >> "$curl_log"
|
||||
for a in "\$@"; do
|
||||
case "\$a" in
|
||||
*getUpdates*)
|
||||
if [ ! -e "$sandbox/served.once" ]; then
|
||||
touch "$sandbox/served.once"
|
||||
cat "$batch_file"
|
||||
else
|
||||
sleep 1
|
||||
printf '%s' '{"ok":true,"result":[]}'
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
printf '%s' '{"ok":true}'
|
||||
STUB
|
||||
chmod +x "$stubs/curl"
|
||||
|
||||
printf '#!/usr/bin/env bash\nexit 1\n' > "$stubs/systemctl"
|
||||
chmod +x "$stubs/systemctl"
|
||||
|
||||
local common=(PATH="$stubs:/usr/bin:/bin" CONFIG_DIR="$cfg"
|
||||
TELEGRAM_BOT_TOKEN=testbot TELEGRAM_CHAT_ID=456 TELEGRAM_OWNER_ID=123)
|
||||
|
||||
# ── run the listener ──
|
||||
# /long_run (sleep 30) runs in background — the listener does NOT block.
|
||||
# The 45s outer timeout proves the listener stayed responsive.
|
||||
test_run_env "${common[@]}" -- timeout 45 "$listener" --run
|
||||
|
||||
local curl_content
|
||||
curl_content="$(cat "$curl_log")"
|
||||
|
||||
# ── all commands were dispatched ──
|
||||
check_contains "listener processed /echo_hello" "exec: /echo_hello" "$TR_OUT"
|
||||
check_contains "listener processed /compound" "exec: /compound" "$TR_OUT"
|
||||
check_contains "listener processed /long_run" "exec: /long_run" "$TR_OUT"
|
||||
|
||||
# ── /echo_hello → "hello" ──
|
||||
check_contains "/echo_hello reply" "text=hello" "$curl_content"
|
||||
|
||||
# ── /compound (sleep 0.2 && echo done) → "done" ──
|
||||
check_contains "/compound reply" "text=done" "$curl_content"
|
||||
|
||||
# ── /stdout_test → multi-line stdout captured ──
|
||||
check_contains "/stdout_test reply" "text=line1" "$curl_content"
|
||||
|
||||
# ── /stderr_test → stderr+stdout both captured ──
|
||||
# Output is "error_msg\noutput_msg" (newline-separated).
|
||||
# The curl log may split this across lines, so check each token alone.
|
||||
check_contains "/stderr_test stderr captured" "error_msg" "$curl_content"
|
||||
check_contains "/stderr_test stdout captured" "output_msg" "$curl_content"
|
||||
|
||||
# ── /pipe_test → pipe works ──
|
||||
check_contains "/pipe_test reply" "text=hello" "$curl_content"
|
||||
|
||||
# ── /no_output → "OK" (no output → default reply) ──
|
||||
check_contains "/no_output reply" "text=OK" "$curl_content"
|
||||
|
||||
# ── /quiet_test → NO sendMessage with "hello_quiet" ──
|
||||
# The setMyCommands call may contain "hello_quiet" in the description,
|
||||
# so we check that no sendMessage line contains it.
|
||||
local quiet_send_count
|
||||
quiet_send_count="$(printf '%s' "$curl_content" | grep 'sendMessage' | grep -c 'hello_quiet' || true)"
|
||||
check_eq "/quiet_test suppresses reply" 0 "$quiet_send_count"
|
||||
|
||||
# ── the daemon exited within the outer timeout (not hung) ──
|
||||
# rc=124 means `timeout` killed it — listener was alive and processing.
|
||||
# rc=0 means it exited cleanly. Both prove no hang.
|
||||
if [ "${TR_RC:-0}" -eq 124 ] || [ "${TR_RC:-0}" -eq 0 ]; then
|
||||
printf ' PASS daemon exited cleanly (rc=%s, not hung)\n' "${TR_RC}"
|
||||
else
|
||||
printf ' FAIL daemon exited with unexpected rc=%s\n' "${TR_RC:-?}"
|
||||
fi
|
||||
}
|
||||
Executable
+129
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# t-telegram-listener-singleton.sh — single-instance guard for the Telegram
|
||||
# listener daemon (flock on ${XDG_RUNTIME_DIR:-/tmp}/pos-telegram-listener.lock):
|
||||
# (a) the first --run acquires the lock and reaches its poll loop;
|
||||
# (b) a second --run on the same runtime dir fails fast (exit 1) with the
|
||||
# exact single-instance message — no 409/getUpdates race;
|
||||
# (c) the flock auto-releases when the first instance exits, so the next
|
||||
# --run starts cleanly (systemd Restart=always path);
|
||||
# (d) --status reports the lock through the same primitives.
|
||||
# Hermetic: stubbed curl (no network) + systemctl (no user bus), real
|
||||
# jq/flock/timeout, sandboxed XDG_RUNTIME_DIR + CONFIG_DIR.
|
||||
|
||||
run_test() {
|
||||
require_cmd jq "telegram singleton guard" || return 0
|
||||
require_cmd flock "telegram singleton guard" || return 0
|
||||
require_cmd timeout "telegram singleton guard" || return 0
|
||||
|
||||
local sandbox stubs cfg runtime home listener curl_log marker first_log
|
||||
sandbox="$(mksandbox telegram-singleton)"
|
||||
stubs="$sandbox/stubs"
|
||||
cfg="$sandbox/cfg"
|
||||
runtime="$sandbox/runtime"
|
||||
home="$sandbox/home"
|
||||
listener="$ROOT/bin/pos-communication-telegram-listener"
|
||||
curl_log="$sandbox/curl.log"
|
||||
marker="$sandbox/loop.started"
|
||||
first_log="$sandbox/first.log"
|
||||
mkdir -p "$stubs" "$cfg" "$runtime" "$home"
|
||||
: > "$curl_log"
|
||||
|
||||
# Stub curl: never touches the network. getUpdates serves an empty batch
|
||||
# forever (first call touches $marker so the test knows the daemon reached
|
||||
# its poll loop — which only happens AFTER the lock was acquired and the
|
||||
# config checks passed); everything else returns {ok:true}. The small
|
||||
# sleep keeps the empty-poll loop from spinning while the test runs.
|
||||
cat > "$stubs/curl" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
printf 'curl %s\n' "\$*" >> "$curl_log"
|
||||
for a in "\$@"; do
|
||||
case "\$a" in
|
||||
*getUpdates*)
|
||||
touch "$marker"
|
||||
sleep 1
|
||||
printf '%s' '{"ok":true,"result":[]}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
printf '%s' '{"ok":true}'
|
||||
STUB
|
||||
chmod +x "$stubs/curl"
|
||||
|
||||
# Stub systemctl: deterministic exit 1 — --status must not reach the real
|
||||
# user bus; the autostart line is not what this test asserts.
|
||||
printf '#!/usr/bin/env bash\nexit 1\n' > "$stubs/systemctl"
|
||||
chmod +x "$stubs/systemctl"
|
||||
|
||||
: > "$cfg/telegram_commands.env"
|
||||
: > "$cfg/telegram_prefixes.env"
|
||||
|
||||
local common=(PATH="$stubs:/usr/bin:/bin" CONFIG_DIR="$cfg"
|
||||
XDG_RUNTIME_DIR="$runtime" HOME="$home"
|
||||
TELEGRAM_BOT_TOKEN=testbot TELEGRAM_CHAT_ID=456 TELEGRAM_OWNER_ID=123)
|
||||
|
||||
# ── (a) first instance acquires the lock and runs ──
|
||||
rm -f "$marker"
|
||||
env "${common[@]}" timeout 10 "$listener" --run >"$first_log" 2>&1 &
|
||||
local first_pid=$!
|
||||
|
||||
local waited=0
|
||||
until [ -e "$marker" ]; do
|
||||
sleep 0.1
|
||||
waited=$((waited + 1))
|
||||
if [ "$waited" -ge 100 ]; then
|
||||
printf ' FAIL first listener never reached the poll loop (log below)\n'
|
||||
cat "$first_log"
|
||||
kill "$first_pid" 2>/dev/null || true
|
||||
wait "$first_pid" 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
printf ' PASS first listener acquired lock and reached the poll loop\n'
|
||||
|
||||
test_run_env "${common[@]}" -- "$listener" --status
|
||||
check_rc "status while daemon up exits 0" 0 "$TR_RC"
|
||||
check_contains "status reports lock held while running" \
|
||||
"listener: running (single instance lock held)" "$TR_OUT"
|
||||
|
||||
# ── (b) second instance fails fast with the exact message ──
|
||||
test_run_env "${common[@]}" -- timeout 3 "$listener" --run
|
||||
check_rc "second instance fails fast (exit 1)" 1 "$TR_RC"
|
||||
check_contains "second instance prints exact single-instance message" \
|
||||
"ERROR: listener already running (single instance) — check: systemctl --user status pos-telegram-listener" \
|
||||
"$TR_OUT"
|
||||
|
||||
# ── (c) lock releases when the first instance ends ──
|
||||
kill "$first_pid" 2>/dev/null || true
|
||||
wait "$first_pid" 2>/dev/null || true
|
||||
|
||||
test_run_env "${common[@]}" -- "$listener" --status
|
||||
check_contains "status reports not running after first exits" \
|
||||
"listener: not running" "$TR_OUT"
|
||||
|
||||
rm -f "$marker"
|
||||
env "${common[@]}" timeout 10 "$listener" --run >"$sandbox/third.log" 2>&1 &
|
||||
local third_pid=$!
|
||||
|
||||
waited=0
|
||||
until [ -e "$marker" ]; do
|
||||
sleep 0.1
|
||||
waited=$((waited + 1))
|
||||
if [ "$waited" -ge 100 ]; then
|
||||
printf ' FAIL third listener never reached the poll loop (log below)\n'
|
||||
cat "$sandbox/third.log"
|
||||
kill "$third_pid" 2>/dev/null || true
|
||||
wait "$third_pid" 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
printf ' PASS third listener starts cleanly after the lock was released\n'
|
||||
|
||||
kill "$third_pid" 2>/dev/null || true
|
||||
wait "$third_pid" 2>/dev/null || true
|
||||
|
||||
test_run_env "${common[@]}" -- "$listener" --status
|
||||
check_contains "status reports not running after third exits" \
|
||||
"listener: not running" "$TR_OUT"
|
||||
}
|
||||
Reference in New Issue
Block a user