Compare commits

...

14 Commits

Author SHA1 Message Date
Your Name 59a4c0e1df feat: add alias creation to pos system bank interactive menu
gates / consistency-and-conventions (push) Successful in 26s
New 'alias' subcommand (+ POS_SUBCMDS row, menu option 6 'Manage
aliases'): create/update/list/remove bash aliases that run
'pos system bank run <name>', written into ~/.bashrc inside a managed
marker block (BASH_RC_FILE seam for tests, atomic tmp+mv rewrite).

- alias_valid_name enforces ^[a-zA-Z][a-zA-Z0-9_-]*$
- same-name create is an idempotent retarget; last alias removal cleans
  the whole block; outer ~/.bashrc aliases preserved byte-identically
- outer-alias collision refused (file untouched), PATH-shadow non-blocking
  warn, 'bank remove' drops aliases pointing at the removed command
- GNU sed '1,0p' edge case (block at line 1) fixed with guarded ranges;
  regex-quote literalization avoided via glob case payload match
- t-bank.sh +49 checks (71 -> 120); docs: POS.md, howto/system.md,
  tests/README.md, AGENT_TODO.md; make gen byte-idempotent
  (also refreshes the pos-communication-telegram-listener filetable
  line count so the previous commit's tree is gen-consistent)
2026-09-12 13:53:44 -04:00
Your Name fcfa2a569a fix: stop telegram listener crash-loop from failed background commands
gates / consistency-and-conventions (push) Failing after 13s
A mapped command exiting non-zero (e.g. /capture -> ffmpeg with no
webcam, exit 254) killed the whole daemon: the CHLD trap only recorded
children that exited 0 (and wait -n inside a trap is unreliable on bash
5.2 anyway), so reap_commands fell back to a bare 'wait $pid' which
aborts the shell under set -euo pipefail before the exit code is
captured. systemd Restart=always then crash-looped (dead gaps + duplicate
command execution from getUpdates offset=0 restarts).

- reap_commands: single reaper path, set -e safe wait with || rc=$?,
  non-zero child exits now produce a normal reply with the real rc
- persist the confirmed getUpdates offset to $CONFIG_DIR/telegram-listener.state
  (LISTENER_STATE_FILE seam) and resume it on start, so a restart never
  re-delivers an unconfirmed burst
- new regression test t-telegram-listener-reap.sh (12 checks): 254-child
  reap survives daemon, negative control proves the old idiom dies,
  offset load/save resume + invalid fallback + empty-batch no-write
2026-09-12 13:48:02 -04:00
Your Name 287f0b75b7 refactor: move command bank under system category, drop system alias tool
gates / consistency-and-conventions (push) Successful in 21s
- rename bin/pos-bank -> bin/pos-system-bank with # POS: system bank
  header; CLI becomes 'pos system bank'; BANK_FILE storage seam and v2
  escaped format unchanged
- delete bin/pos-system-alias; remove its POS.md/howto sections and the
  system-alias INTERACTIVE_CMDS entry; pos ai alias untouched
- update bin/pos comment + usage example, completions (gen), docs,
  tests/t-bank.sh CLI path, AGENT_TODO Done notes
2026-09-12 11:13:41 -04:00
Your Name 59d834a71c chore: note pos bank multiline fix in AGENT_TODO Done
gates / consistency-and-conventions (push) Successful in 20s
2026-09-12 08:27:12 -04:00
Your Name 11b4a679a8 fix: store and execute multiline commands in pos bank
gates / consistency-and-conventions (push) Successful in 20s
bank.env is line-oriented (name|description|command) so commands with
real newlines spanned records: bank_load truncated the command to its
first line and the remaining script lines became bogus entries. The
bank_get+cut -f3 retrieval path also truncated at embedded tabs.

- lib/bank-lib.sh: v2 format escapes backslash->\\ and newline->\\n
  in the command field, writes # BANK_VERSION: 2; bank_load decodes
  with printf %b only for v2 files, so existing v1 files load raw
  (backward compatible, verified against the real ts-google entry).
- bin/pos-bank: cmd_show/cmd_run/cmd_edit now read fields from the
  BANK_* arrays via bank_find instead of bank_get+cut.
- tests/t-bank.sh: +13 checks (71 total) - multiline round-trip exact
  bytes, literal backslash-n, v1 raw-backslash compat, v1+re-save
  byte-identical, CLI show/run full script.
2026-09-12 08:25:40 -04:00
Your Name e23d57e551 fix: include category-less tools in pos tab completion
gates / consistency-and-conventions (push) Successful in 23s
_pos_complete_categories offered only hyphenated categories plus a
hardcoded 'config', so 'pos <TAB>' never showed bank/tree. Category-less
tools are now discovered from the executable scan, and the words==3
dispatch routes no-hyphen tools to _pos_complete_tool so 'pos bank <TAB>'
offers list/add/show/run/edit/remove.
2026-09-12 07:29:30 -04:00
Your Name c5d501ad25 fix: show Command Bank in main pos menu; fix pos bank run crash
gates / consistency-and-conventions (push) Successful in 27s
- bin/pos: _pos_category_list now emits category-less tools (pos-bank,
  pos-config, pos-tree) as sorted menu entries with an empty subcommand
  column, deduped against real categories (pos-ai). bank previously
  never appeared in the bare 'pos' menu.
- bin/pos-bank: fixed invalid 'local name="" -a cli_params=()' at
  cmd_run (line 150) — bash rejected '-a' after an assignment, crashing
  every 'pos bank run' with 'local: -a: not a valid identifier'.
  Split into 'local name=""' + 'local -a cli_params=()'.
- tests/t-bank.sh: added B12-B14 covering cmd_run (no-params,
  missing-command, param substitution) — would have caught the crash.
- AGENT_TODO.md: bank feature moved to Done.
2026-09-12 05:20:20 -04:00
Your Name 73d15a26b4 fix: register bank-lib.sh (and yt-lib.sh) in install/uninstall lib manifests
gates / consistency-and-conventions (push) Successful in 19s
lib/bank-lib.sh was added for pos bank but never registered in install.sh's
phase-2 lib copy list, so it never reached /usr/local/bin and pos bank failed
after install. Also restored yt-lib.sh to POS_LIBS (pre-existing gap: uninstall
left it behind). Symmetry gate tests/t-uninstall-manifest.sh now passes.
2026-09-12 03:28:15 -04:00
Your Name 41efc91cf0 feat: add pos bank — persistent command bank with parameterized templates
gates / consistency-and-conventions (push) Successful in 29s
2026-09-12 03:02:20 -04:00
Your Name 0b76d4d29e fix: add lib/yt-lib.sh to install.sh library list
gates / consistency-and-conventions (push) Successful in 17s
The yt-lib.sh library was missing from the install script's library
list, causing pos-media-yt-subtitles (and other yt-* tools) to fail
with 'No such file or directory' when run from /usr/local/bin/.

This adds yt-lib.sh to the install list and updates documentation
to reflect the change.
2026-09-11 13:00:14 -04:00
Your Name 2c77e73799 .
gates / consistency-and-conventions (push) Successful in 17s
2026-09-11 12:47:42 -04:00
Your Name df1cca478d fix: Telegram listener — async command execution + singleton guard
gates / consistency-and-conventions (push) Successful in 23s
Root cause: run_and_reply() blocked the entire listener synchronously.
FFmpeg hung because child processes inherited stdin (waiting for 'q').
Long-running commands froze the listener for up to 120s.

Fix:
- Commands run in background with stdin=/dev/null, output to temp file
- reap_commands() collects output non-blocking after each getUpdates cycle
- SIGCHLD handler pre-caches exit codes via wait -n
- TERM/INT trap kills background processes and cleans temp files
- Singleton guard (flock) prevents duplicate listeners racing getUpdates

Tests:
- t-telegram-listener-exec.sh: 12 hermetic checks (echo, pipes, stderr,
  compound commands, long-running, quiet mode)
- t-telegram-listener-singleton.sh: 8 checks (lock acquire/release/status)

Architect verdict: accepted as-is, no re-architecture needed.
2026-09-09 17:17:56 -04:00
Your Name f14d24950a feat: pos media yt — unified YouTube tools + subtitles (POS--9)
gates / consistency-and-conventions (push) Successful in 20s
2026-09-09 07:04:22 -04:00
Your Name 9a164462e7 feat: install.sh version gate — --force + per-commit 0.0cN
gates / consistency-and-conventions (push) Successful in 33s
Architect-approved (C): install.sh now skips+aborts (exit 0) when the
installed version equals the current git-derived version, with a --force
flag to re-install. Version scheme 0.0cN (N = git commit count) bumps by
construction on every commit.

- install_version(): derives 0.0c$count via git rev-list; empty when .git
  absent (gate skipped); INSTALL_VERSION_OVERRIDE presence-check seam for
  tests (empty override simulates no-git deterministically)
- Gate after arg parse, before phases; numeric comparison (strip 0.0c, -eq);
  messages: 'Already installed (X). Use --force to re-install.' and
  '(dry-run) Would skip install: already at version X', both exit 0
- flag_set installed_version after 'Bootstrap complete' banner (not in
  dry-run, even under --force, never on phase failure)
- Docs: SCRIPTS.md flag table + gate description, AGENT_Context line count
  248->301 + flags + flow diagram, README --force row, tests/README row,
  AGENT_TODO Done entry

Verified: new tests/t-install-version.sh 21 checks (9 contracted cases,
real install.sh + hermetic env seams); suite 20 files / 461 checks /
0 fail / 0 skip; make gen byte-idempotent; make check OK; make lint
0 FAIL, 0 WARN; bash -n clean; git diff --check clean; Reviewer
APPROVE_WITH_NOTES with 3 mutation disproofs (8/21, 9/21, 4/21 fail)
2026-09-08 10:17:31 -04:00
37 changed files with 3547 additions and 523 deletions
+3
View File
@@ -33,3 +33,6 @@ dream.md
# Agent process reports — local-only shared memory (not tracked)
AgentsReport/
# Task breakdown notes — never committed
.tasks/
+13
View File
@@ -42,6 +42,19 @@ summary (newest last).
## Done
- **`pos system bank alias`** — bash aliases for bank commands (Architect→Builder): `alias <name> [alias_name]`, `alias list`, `alias remove <alias_name>`, menu option 6 "Manage aliases", subcommand shown in `pos tree`/completion via `# POS_SUBCMDS`. Writes `alias <alias_name>='pos system bank run <name>'` lines into `~/.bashrc` inside a managed `# >>> pos bank aliases … <<<` block (`BASH_RC_FILE` seam); same alias name retargets the line, removing the last alias removes the whole block; outer-alias collision refused (line shown), PATH-shadow non-blocking warn, `bank remove` drops aliases pointing at the removed command. Fixed during build: undeclared `declare -g -A` alias map → `unbound variable`; `[[ =~ ]]` literalizes `'` → payload matched via glob `case`; **GNU sed `1,0p` prints line 1** (0 = last-line sentinel) → guarded extraction ranges so edge blocks rewrite cleanly. New `tests/t-bank.sh` Part B2 (49 alias checks). Docs: POS.md system category + detail block, howto/system.md new bank section. Verified: `bash -n`, t-bank 120 checks, `make gen` byte-idempotent, `make check`, `make lint` 0 FAIL / 0 WARN. Done 2026-09-12.
- **`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.
- **2026-09-07** — `pos ai` API-key contract mismatch fixed (Architect→Builder→Reviewer; docs/history evidence): docs claimed `AI_API_KEY` was the required primary key, but `resolve_key()` read only provider-specific keys (`7ae2e77` had removed shared-key priority to fix cross-provider leakage; docs never updated). Decision C: provider key stays primary (leakage guard intact), legacy `AI_API_KEY` honored as backward-compat fallback when the provider's own key is empty; `cmd_providers()` "configured" mirrors it; `require_key()` messages byte-stable; `AI_API_KEY` NOT re-added to `# POS_CONFIG:`/`# PROVIDER_CONFIG:` registry. Docs reworded (POS.md rows 91/96/98 + precedence, howto/ai.md first-run hints, HOWTO.md, AGENT_Context 2 prose spots, config/ai.env comment). New regression `tests/t-ai-key-resolution.sh` (24 checks / 10 cases: gemini+openrouter via provider key only, via AI_API_KEY only, both→provider wins, env-wins, llamacpp no-key, missing-key message, providers configured status). Verified: suite 19 files / 440 checks / 0 fail / 0 skip; `make gen` byte-idempotent; `make check` OK; `make lint` 0 FAIL, 0 WARN; Reviewer APPROVE_WITH_NOTES with mutation-based disproof (inverted precedence → C3/C6 fail).
- **2026-09-07** — opencode project skill: created `.opencode/skills/linux-post-install/SKILL.md` (repo had no `.opencode/`). Skill encodes the repo's operational playbook for agents: repo shape, `pos` tool model (`# POS:` header system, exec-bit, deps-guard-before-help, INTERACTIVE_CMDS, determinism), doc authority order (MAINTENANCE Phase 0: templates → DEV.md → AGENTS.md → code), Definition of Done gates (`make gen` ×2 idempotent → `make check``make lint` 0/0 → `make test` 18 files/416 checks), test conventions (hard-skip contract, negative controls), and repo commands (ci-status, gitea API, pos tree/config). Frontmatter validated (name matches folder, description with trigger keywords); auto-discovered at `.opencode/skills/` — no opencode.json change needed; restart opencode to load.
+52 -24
View File
@@ -10,19 +10,19 @@
<!-- GEN:START docmap -->
| ## 1. Project Overview | 2843 |
| ## 2. Directory Structure | 44210 |
| ## 3. Installation Flow | 211264 |
| ## 4. The `pos` CLI System | 265346 |
| ## 5. Shared Library — `lib/common.sh` | 347378 |
| ## 6. Docker Compose / ScaleTail | 379421 |
| ## 7. Optional Apps (`apps/`) | 422451 |
| ## 8. Entertainment Module | 452465 |
| ## 9. Systemd Services | 466477 |
| ## 10. Configuration Files | 478504 |
| ## 11. Coding Conventions | 505537 |
| ## 12. Development Workflow | 538590 |
| ## 13. Key File Quick Reference | 591666 |
| ## 14. Common Tasks for Agents | 667700 |
| ## 2. Directory Structure | 44217 |
| ## 3. Installation Flow | 218276 |
| ## 4. The `pos` CLI System | 277365 |
| ## 5. Shared Library — `lib/common.sh` | 366397 |
| ## 6. Docker Compose / ScaleTail | 398440 |
| ## 7. Optional Apps (`apps/`) | 441470 |
| ## 8. Entertainment Module | 471484 |
| ## 9. Systemd Services | 485496 |
| ## 10. Configuration Files | 497523 |
| ## 11. Coding Conventions | 524556 |
| ## 12. Development Workflow | 557609 |
| ## 13. Key File Quick Reference | 610694 |
| ## 14. Common Tasks for Agents | 695728 |
<!-- GEN:END docmap -->
## 1. Project Overview
@@ -90,7 +90,13 @@ Linux_post_install/
│ ├── pos-media-mp4 # Download video as MP4 (smart/interactive format select)
│ ├── pos-media-sync # Incremental Music → USB sync (mp3/mp4, add/update only)
│ │ [deps: lsblk jq]
│ ├── pos-media-yt-grab # Auto-download URL as audio or video (classify + route)
│ ├── pos-media-yt-mp3 # Download audio as MP3 (yt-dlp)
│ ├── pos-media-yt-mp4 # Download video as MP4 (smart/interactive format select)
│ ├── pos-media-yt-subtitles # Extract subtitles/captions from a URL (yt-dlp)
│ ├── pos-media-yt-ytsync # Incrementally sync YouTube channels/playlists into ~/Videos
│ ├── pos-media-ytsync # Incrementally sync YouTube channels/playlists into ~/Videos
│ ├── pos-media-yt # YouTube download tools (mp3/mp4/grab/ytsync/subtitles)
│ ├── pos-network-checkport # Check TCP/UDP port reachability (nmap, or bash/nc fallback) + local interface view
│ ├── pos-network-download # aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits)
│ │ [deps: aria2c jq curl]
@@ -105,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
@@ -211,7 +218,11 @@ Linux_post_install/
## 3. Installation Flow
```
User runs: ./install.sh [--apps|--full|--feature|--dry-run|--skip <phase>|--steps <spec>]
User runs: ./install.sh [--apps|--full|--feature|--dry-run|--force|--skip <phase>|--steps <spec>]
├─ Version gate: compares installed version (flag) vs current (0.0c<commit count>)
│ └─ match + no --force → "Already installed. Use --force to re-install." / exit 0
│ (--dry-run variant prints "Would skip install"); no git or no flag → skip gate
├─ Phase 1: preinstall.sh (requires root)
│ └─ apt update + installs 25+ packages + yt-dlp + fail2ban
@@ -220,7 +231,7 @@ User runs: ./install.sh [--apps|--full|--feature|--dry-run|--skip <phase>|--step
│ └─ 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
@@ -249,6 +260,7 @@ User runs: ./install.sh [--apps|--full|--feature|--dry-run|--skip <phase>|--step
| `--full` | Core install + all apps (non-interactive) |
| `--feature` | Install `features/` scripts to `/usr/local/bin/` (asks before overwriting), set their flags |
| `--dry-run` | Preview without executing |
| `--force` | Re-install even if the version matches |
| `--skip <phase>` | Skip a phase (repeatable): `preinstall`, `scripts`, `postinstall`, `scalepoint`, `apps` |
| `--steps <spec>` | Run only specific phases. Format: `1,3,4` or `1-3` |
| `--no-color` | Disable colored output |
@@ -306,7 +318,13 @@ All non-interactive `pos` commands log output to `~/.local/share/linux_post_inst
| media | mp3 | `pos-media-mp3` | Download audio as MP3 (yt-dlp) | | |
| media | mp4 | `pos-media-mp4` | Download video as MP4 (smart/interactive format select) | | |
| media | sync | `pos-media-sync` | Incremental Music → USB sync (mp3/mp4, add/update only) | lsblk jq | pos media sync --mp3 → Sync only MP3 files to USB · pos media sync --mp4 --dry-run → Preview MP4 sync without copying |
| media | yt-grab | `pos-media-yt-grab` | Auto-download URL as audio or video (classify + route) | | |
| media | yt-mp3 | `pos-media-yt-mp3` | Download audio as MP3 (yt-dlp) | | |
| media | yt-mp4 | `pos-media-yt-mp4` | Download video as MP4 (smart/interactive format select) | | |
| media | yt-subtitles | `pos-media-yt-subtitles` | Extract subtitles/captions from a URL (yt-dlp) | | |
| media | yt-ytsync | `pos-media-yt-ytsync` | Incrementally sync YouTube channels/playlists into ~/Videos | | |
| media | ytsync | `pos-media-ytsync` | Incrementally sync YouTube channels/playlists into ~/Videos | | |
| media | yt | `pos-media-yt` | YouTube download tools (mp3/mp4/grab/ytsync/subtitles) | | |
| network | checkport | `pos-network-checkport` | Check TCP/UDP port reachability (nmap, or bash/nc fallback) + local interface view | | |
| network | download | `pos-network-download` | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) | aria2c jq curl | pos network download add https://example.com/file.zip → Enqueue an HTTP download (auto-starts daemon) · pos network download status → Daemon health + global transfer stats · pos network download watch → Live progress view |
| network | hotspot | `pos-network-hotspot` | Wi-Fi hotspot via create_ap + wihotspot-gui | | |
@@ -319,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 | | |
@@ -371,7 +390,7 @@ source "$(dirname "$0")/../lib/common.sh"
**Scripts that do NOT source common.sh** (self-contained):
<!-- GEN:START selfcontained -->
`pos`, `pos-ai-gemini`, `pos-ai-llamacpp`, `pos-ai-openrouter`, `pos-communication-matrix-sender`, `pos-communication-telegram-listener`, `pos-communication-telegram-sender`, `pos-network-checkport`, `pos-network-hotspot`, `pos-network-ip`, `pos-network-scan`, `pos-ssh-load-keys`, `pos-system-firewall`.
`pos`, `pos-ai-gemini`, `pos-ai-llamacpp`, `pos-ai-openrouter`, `pos-communication-matrix-sender`, `pos-communication-telegram-listener`, `pos-communication-telegram-sender`, `pos-media-grab`, `pos-media-mp3`, `pos-media-mp4`, `pos-media-yt-ytsync`, `pos-network-checkport`, `pos-network-hotspot`, `pos-network-ip`, `pos-network-scan`, `pos-ssh-load-keys`, `pos-system-firewall`.
<!-- GEN:END selfcontained -->
---
@@ -592,7 +611,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
| File | Lines | Purpose |
|------|-------|---------|
| `install.sh` | 248 | Main orchestrator — 4 phases with CLI flags, `--feature`, prebuilt arch bins |
| `install.sh` | 301 | Main orchestrator — 4 phases with CLI flags, `--feature`, `--force`, version gate, prebuilt arch bins |
| `preinstall.sh` | 76 | System packages + hotspot deps + yt-dlp + fail2ban |
| `postinstall.sh` | 168 | fail2ban config, PATH, bash completion, systemd (flag-gated) |
| `lib/common.sh` | 151 | Shared library (log/warn/err/run/spawn, dry-run aware, `load_system_env`, CONFIG_DIR) |
@@ -606,13 +625,15 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
| `lib/share-lib.sh` | 318 | Domain layer for the share suite (usbsrv/smbclient record parsers, folder+mountpoint candidates, remote listings, service/firewall advisories; EOF-safe) + compat shims to `lib/menu-lib.sh` — used by all five `pos share *` tools |
| `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) |
@@ -622,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` | 924 | 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) |
@@ -634,11 +655,17 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
| `bin/pos-entertainment-enable` | 49 | Enable an auto-trigger for a plugin on a schedule |
| `bin/pos-entertainment-send` | 95 | Run a public-API plugin and send its output via the configured notify platforms |
| `bin/pos-entertainment-status` | 62 | Show enabled plugins and scheduler state |
| `bin/pos-media-grab` | 219 | Auto-download URL as audio or video (classify + route) |
| `bin/pos-media-mp3` | 86 | Download audio as MP3 (yt-dlp) |
| `bin/pos-media-mp4` | 132 | Download video as MP4 (smart/interactive format select) |
| `bin/pos-media-grab` | 5 | Auto-download URL as audio or video (classify + route) |
| `bin/pos-media-mp3` | 5 | Download audio as MP3 (yt-dlp) |
| `bin/pos-media-mp4` | 5 | Download video as MP4 (smart/interactive format select) |
| `bin/pos-media-sync` | 219 | Incremental Music → USB sync (mp3/mp4, add/update only) |
| `bin/pos-media-yt-grab` | 208 | Auto-download URL as audio or video (classify + route) |
| `bin/pos-media-yt-mp3` | 86 | Download audio as MP3 (yt-dlp) |
| `bin/pos-media-yt-mp4` | 132 | Download video as MP4 (smart/interactive format select) |
| `bin/pos-media-yt-subtitles` | 169 | Extract subtitles/captions from a URL (yt-dlp) |
| `bin/pos-media-yt-ytsync` | 5 | Incrementally sync YouTube channels/playlists into ~/Videos |
| `bin/pos-media-ytsync` | 1213 | Incrementally sync YouTube channels/playlists into ~/Videos |
| `bin/pos-media-yt` | 33 | YouTube download tools (mp3/mp4/grab/ytsync/subtitles) |
| `bin/pos-network-checkport` | 498 | Check TCP/UDP port reachability (nmap, or bash/nc fallback) + local interface view |
| `bin/pos-network-download` | 1113 | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) |
| `bin/pos-network-hotspot` | 93 | Wi-Fi hotspot via create_ap + wihotspot-gui |
@@ -651,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` | 647 | 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` | 314 | Dynamic bash completion |
| `completions/pos.bash` | 330 | Dynamic bash completion |
<!-- GEN:END filetable -->
| `apps/install.sh` | 171 | App install/uninstall picker/orchestrator |
+45 -3
View File
@@ -263,9 +263,15 @@ The standalone `vbox` command still works and forwards to `pos docker vbox` (see
| Command | File | Purpose | Configuration |
|---------|------|---------|---------------|
| `pos media mp3 <url>` | `bin/pos-media-mp3` | Download audio as MP3 via yt-dlp, with thumbnail + metadata | Output to `~/Music/%(title)s.%(ext)s`, `--audio-quality 0` |
| `pos media mp4 <url>` | `bin/pos-media-mp4` | Download video via yt-dlp with **interactive format selection** | Lists formats (`yt-dlp -F`), asks for a format ID, saves to `~/Videos/` |
| `pos media grab <url>` | `bin/pos-media-grab` | Auto-download a URL as audio or video (classify + route to mp3/mp4) | Domain-based classification (YouTube Music/SoundCloud/Bandcamp → audio; YouTube/Vimeo/Twitch → video); `--audio`/`--video` force the mode; `--best` default for video (non-interactive); prints a clean summary (🎵/🎬 title, path, size). Config: `GRAB_DEFAULT` (`pos config grab`, default `video`) for unknown domains |
| `pos media yt` | `bin/pos-media-yt` | YouTube download tools dispatcher (mp3/mp4/grab/ytsync/subtitles); bare invocation prints help listing subcommands | Subcommands resolved via `bin/pos-media-yt-<sub>` files |
| `pos media yt mp3 <url>` | `bin/pos-media-yt-mp3` | Download audio as MP3 via yt-dlp, with thumbnail + metadata | Output to `~/Music/%(title)s.%(ext)s`, `--audio-quality 0`; `--by-artist` organizes as `<dir>/<artist>/<title>.mp3`. Env seam: `YT_OUT_DIR` overrides the default output dir |
| `pos media yt mp4 <url>` | `bin/pos-media-yt-mp4` | Download video via yt-dlp with **interactive format selection** | Lists formats (`yt-dlp -F`), asks for a format ID, saves to `~/Videos/`; `-f/--best/--worst` skip the prompt. Env seam: `YT_OUT_DIR` overrides the default output dir. Reads stdin (interactive format pick) → in `INTERACTIVE_CMDS` |
| `pos media yt grab <url>` | `bin/pos-media-yt-grab` | Auto-download a URL as audio or video (classify + route to yt-mp3/yt-mp4) | Domain-based classification (YouTube Music/SoundCloud/Bandcamp → audio; YouTube/Vimeo/Twitch → video); `--audio`/`--video` force the mode; `--best` default for video (non-interactive); prints a clean summary (🎵/🎬 title, path, size). Config: `GRAB_DEFAULT` (`pos config grab`, default `video`) for unknown domains |
| `pos media yt ytsync [add\|sync\|list\|remove]` | `bin/pos-media-yt-ytsync` | Thin forwarder → `pos media ytsync` (incremental YouTube channel/playlist sync into `~/Videos`) | See `bin/pos-media-ytsync` below |
| `pos media yt subtitles <url>` | `bin/pos-media-yt-subtitles` | Extract subtitles/captions from a URL (yt-dlp) | Default `--write-subs --write-auto-subs --sub-langs best`; `--lang en,ar` (comma = single `--sub-langs` arg); `--format srt` (default) / `vtt` / `txt` (srt→txt post-conversion); `--auto-only` drops manual subs; `--list-subs` probe; output to current dir (`-o ./%(title)s.%(sub_lang)s.%(ext)s`), `--output <dir>` overrides. Non-interactive; needs only yt-dlp (no ffmpeg) |
| `pos media mp3 <url>` | `bin/pos-media-mp3` | **Forwarder**`pos media yt mp3` (backward-compat alias) | Legacy spelling still works |
| `pos media mp4 <url>` | `bin/pos-media-mp4` | **Forwarder**`pos media yt mp4` (backward-compat alias) | Legacy spelling still works |
| `pos media grab <url>` | `bin/pos-media-grab` | **Forwarder**`pos media yt grab` (backward-compat alias) | Legacy spelling still works |
| `pos media sync [--mp3\|--mp4]` | `bin/pos-media-sync` | Incremental Music → USB sync (add/update only — never deletes) | Copies mp3/mp4 from `$HOME/Music` (or `--source <dir>`) into `<usb>/Music/`, preserving the tree; missing or changed (size/mtime) files are copied, identical ones skipped. Same USB detection as `pos system backup` (lsblk TRAN + lsusb/by-id, mount offer for unmounted sticks, multi-stick picker). `--mp3`/`--mp4` filter by extension, neither = both; `--dry-run` previews. Config: `MEDIA_SYNC_SOURCE`, `MEDIA_SYNC_DEST`, shared `USB_MOUNT_BASE`/`USB_BYID` from `~/.config/linux_post_install/system.env`. Result notified via `lib/notify.sh`. Bare invocation on a terminal (or the `menu` subcommand) opens an interactive menu wrapping these actions (sync now mp3+mp4, dry-run preview, mp3-only, mp4-only, change source folder); flags stay scriptable |
| `pos media ytsync [add\|sync\|list\|remove]` | `bin/pos-media-ytsync` | Incremental YouTube channel/playlist sync — first run asks for a URL (bare invocation = interactive menu; empty state goes straight to the prompt), repeat runs fetch only new videos | One yt-dlp call per new video (`bestvideo*+bestaudio/best` → MP4, metadata/chapters/thumbnail embedded, `--no-overwrites`, `--windows-filenames --trim-filenames 120`); per-source `--download-archive` (`~/.local/share/linux_post_install/ytsync/archive/<slug>.txt`) makes runs crash-safe and idempotent; registry tracks slug/type/url/subdir. Verbs never prompt (scheduler/timer safe); non-tty interactive entry prints a guard line and exits 0. `--dry-run` probes + plans with zero writes. Notify digest only when new>0 or failed>0 via `lib/notify.sh`. Config: `YTSYNC_VIDEOS_DIR`, `YTSYNC_EXTRA_ARGS` via `pos config ytsync`; automate with `pos system schedule` (`COMMAND=pos media ytsync sync`, `NOTIFY=never`) |
@@ -290,9 +296,45 @@ 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, alias | 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. `alias` subcommand manages bash aliases in `~/.bashrc` inside a managed `# >>> pos bank aliases …` block (`BASH_RC_FILE` env seam) |
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 (also drops aliases pointing at it) |
| `pos system bank alias <name> [alias_name]` | Add/update a bash alias for a bank command (default alias name = bank name) |
| `pos system bank alias list` | List bash aliases from `~/.bashrc` |
| `pos system bank alias remove <alias_name>` | Remove a bash alias |
Aliases are bash aliases written into `~/.bashrc` inside a managed block
(`# >>> pos bank aliases … <<<`, `bashrc`-path seam: `BASH_RC_FILE`), as
`alias <alias_name>='pos system bank run <name>'` — retyping the same alias
name retargets the line; removing the last alias removes the whole block.
An alias name already defined outside the block is refused, and aliasing a
command that also exists on `PATH` prints a non-blocking warning (the alias
will shadow it in interactive shells).
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 |
+11 -2
View File
@@ -34,7 +34,8 @@ Everything that runs during the bootstrap install: `install.sh`, `preinstall.sh`
1. **Pre-parse `--no-color`** before anything else, so colors are disabled early (`TERM=dumb` is exported).
2. Source `lib/common.sh` (logging, `run`, `spawn`, …) and `lib/flags.sh` (feature flags).
3. Parse CLI options.
4. For each phase, `should_run <num> <name>` decides whether to run it:
4. **Version gate:** derive the current version (`0.0c<git commit count>` via `install_version()`; empty when `.git` is absent). If the installed version (stored as the `installed_version` flag) matches and `--force` is not given, skip the install — with `--dry-run` it prints `(dry-run) Would skip install: already at version <v>`, otherwise `Already installed (<v>). Use --force to re-install.` and exits 0. When no `installed_version` flag exists or the current version cannot be determined (no `.git`), the gate is skipped.
5. For each phase, `should_run <num> <name>` decides whether to run it:
- `--skip <phase>` removes a phase (takes precedence).
- `--steps <spec>` restricts the run to the listed phases only (`1,3,4` or `1-3`).
- Phase map: `1=preinstall`, `2=scripts`, `3=postinstall`, `4=scalepoint` (+ `apps` handled separately).
@@ -44,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) |
@@ -63,6 +64,7 @@ No config file — everything is command-line:
| `--full` | Core install + every app (non-interactive) |
| `--feature` | Install `features/` scripts to `/usr/local/bin/` (prompts on overwrite), sets their flags |
| `--dry-run` | Log every action instead of executing. **Note:** applies to `install.sh` itself; `postinstall.sh` runs as a subprocess and does not inherit `DRY_RUN` |
| `--force` | Re-install even if the version matches |
| `--skip <phase>` | Skip a phase (repeatable): `preinstall`, `scripts`, `postinstall`, `scalepoint`, `apps` |
| `--steps <spec>` | Run only listed phases: `1,3,4` or `1-3` |
| `--no-color` | Disable colored output |
@@ -243,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
View File
@@ -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.
---
+68 -27
View File
@@ -3,26 +3,34 @@
Download audio and video from the web via `yt-dlp`, auto-classify URLs,
sync your library to a USB stick, and keep YouTube channels incrementally
up to date.
Tools: `grab`, `mp3`, `mp4`, `sync`, `ytsync`.
Tools: `yt mp3`, `yt mp4`, `yt grab`, `yt subtitles`, `sync`, `ytsync`.
The YouTube download tools live under `pos media yt` (`mp3`, `mp4`, `grab`,
`subtitles`, `ytsync`). The legacy standalone names `pos media mp3`, `pos media
mp4` and `pos media grab` still work — they are thin forwarders to the `yt`
forms.
| Tool | What it does |
|------|--------------|
| `pos media grab` | Auto-classify URL and download as audio or video |
| `pos media mp3` | Download audio, convert to MP3 |
| `pos media mp4` | Download video with smart/interactive format selection |
| `pos media yt mp3` | Download audio, convert to MP3 |
| `pos media yt mp4` | Download video with smart/interactive format selection |
| `pos media yt grab` | Auto-classify URL and download as audio or video |
| `pos media yt subtitles` | Extract subtitles/captions from a URL |
| `pos media yt ytsync` | Forwarder → `pos media ytsync` |
| `pos media sync` | Incrementally copy `~/Music` onto a USB stick (mp3/mp4) |
| `pos media ytsync` | Track YouTube channels/playlists and download only new videos into `~/Videos` |
Requires `yt-dlp` and `ffmpeg` (`sudo apt install yt-dlp ffmpeg`); the tools
fail with a clean error message instead of a raw `command not found` if either
is missing.
Requires `yt-dlp` and `ffmpeg` (`sudo apt install yt-dlp ffmpeg`) for `mp3`/
`mp4`/`grab`; `subtitles` needs only `yt-dlp` (no ffmpeg). The tools fail with a
clean error message instead of a raw `command not found` if a dependency is
missing.
---
## `pos media mp3` — audio as MP3
## `pos media yt mp3` — audio as MP3
```bash
pos media mp3 <url>
pos media yt mp3 <url> # or the legacy: pos media mp3 <url>
```
Extracts and converts the audio track to MP3 in `~/Music/`. With `--by-artist`
@@ -30,8 +38,8 @@ files land in `~/Music/<artist>/<title>.mp3` (falls back to the uploader name
when there's no artist tag), so a library stays organized.
```bash
pos media mp3 https://youtube.com/watch?v=dQw4w9WgXcQ
pos media mp3 --by-artist https://youtu.be/dQw4w9WgXcQ
pos media yt mp3 https://youtube.com/watch?v=dQw4w9WgXcQ
pos media yt mp3 --by-artist https://youtu.be/dQw4w9WgXcQ
```
MP3s are encoded at best quality with title/artist/album/date/chapters embedded
@@ -48,15 +56,15 @@ cover art as a JPEG thumbnail. Existing files are never overwritten.
**Recipe:** batch — loop over a list of URLs:
```bash
while read -r url; do pos media mp3 --by-artist "$url"; done < urls.txt
while read -r url; do pos media yt mp3 --by-artist "$url"; done < urls.txt
```
---
## `pos media mp4` — video with smart format choice
## `pos media yt mp4` — video with smart format choice
```bash
pos media mp4 <url>
pos media yt mp4 <url> # or the legacy: pos media mp4 <url>
```
Without a format flag, the available formats are shown as a **short curated
@@ -77,8 +85,8 @@ Non-interactive (scripting-friendly):
| `--dry-run` | Print the yt-dlp command without running it |
```bash
pos media mp4 --best https://youtube.com/watch?v=dQw4w9WgXcQ
pos media mp4 -f 22 https://youtube.com/watch?v=dQw4w9WgXcQ
pos media yt mp4 --best https://youtube.com/watch?v=dQw4w9WgXcQ
pos media yt mp4 -f 22 https://youtube.com/watch?v=dQw4w9WgXcQ
```
Videos merge to MP4 with metadata, chapters, subtitles (all languages) and the
@@ -90,13 +98,13 @@ video+audio and merges them.
---
## `pos media grab` — auto-classify URL and download
## `pos media yt grab` — auto-classify URL and download
```bash
pos media grab <url>
pos media yt grab <url> # or the legacy: pos media grab <url>
```
Smart URL classifier that routes to `pos media mp3` or `pos media mp4`
Smart URL classifier that routes to `pos media yt mp3` or `pos media yt mp4`
automatically based on the domain. Send a URL from your phone via Telegram and
the bot downloads it to the right place without you thinking about it.
@@ -116,16 +124,16 @@ unrecognized domains is `video` — change it with `pos config grab` or set
`GRAB_DEFAULT=audio` in `~/.config/linux_post_install/grab.env`.
```bash
pos media grab https://music.youtube.com/watch?v=abc # → ~/Music
pos media grab https://youtube.com/watch?v=xyz # → ~/Videos
pos media grab --audio https://vimeo.com/123 # force mp3
pos media grab --worst https://youtu.be/abc # lowest quality
pos media grab --dry-run https://soundcloud.com/artist/track # preview only
pos media yt grab https://music.youtube.com/watch?v=abc # → ~/Music
pos media yt grab https://youtube.com/watch?v=xyz # → ~/Videos
pos media yt grab --audio https://vimeo.com/123 # force mp3
pos media yt grab --worst https://youtu.be/abc # lowest quality
pos media yt grab --dry-run https://soundcloud.com/artist/track # preview only
```
Non-interactive by design — `pos media mp4` receives `--best` by default so it
never prompts for a format (critical for Telegram bot context where there's no
TTY). Pass `--worst` if you want the smallest file.
Non-interactive by design — `pos media yt mp4` receives `--best` by default so
it never prompts for a format (critical for Telegram bot context where there's
no TTY). Pass `--worst` if you want the smallest file.
| Flag | Meaning |
|------|---------|
@@ -140,6 +148,39 @@ TTY). Pass `--worst` if you want the smallest file.
---
## `pos media yt subtitles` — extract subtitles/captions
```bash
pos media yt subtitles <url>
```
Downloads subtitles/captions from a URL via yt-dlp. Fetches manual captions and
auto-generated captions by default (`--write-subs --write-auto-subs
--sub-langs best` — "best" picks the manually-created track when available,
otherwise the auto one). Output files land in the current directory as
`<title>.<lang>.<ext>`.
```bash
pos media yt subtitles https://youtube.com/watch?v=dQw4w9WgXcQ
pos media yt subtitles --lang en https://youtu.be/dQw4w9WgXcQ
pos media yt subtitles --lang en,ar --format txt https://youtube.com/watch?v=dQw4w9WgXcQ
```
| Flag | Meaning |
|------|---------|
| `--lang <list>` | Subtitle languages, comma-separated (default `best`) — `en,ar` is passed as ONE `--sub-langs` arg |
| `--format <fmt>` | `srt` (default) / `vtt` / `txt``txt` converts srt→txt (timestamps, seq numbers and HTML tags stripped) |
| `--auto-only` | Only auto-generated captions (no manual subs) |
| `-o, --output <dir>` | Output directory (default: current directory) |
| `--list-subs` | List available subtitles for the URL and exit (probe only, no download) |
| `--no-playlist` | Download only the single video |
| `--dry-run` | Print the yt-dlp command without running it |
If a video has no available subtitles the tool reports
`unavailable subtitles for this video (try --list-subs to check)`.
---
## `pos media sync` — music onto a USB stick
```bash
+37 -2
View File
@@ -1,7 +1,8 @@
# How-To: `pos system`
Host care: encrypted backups, firewall, health dashboard, and uninstall. Tools:
`backup`, `firewall`, `health`, `uninstall`.
Host care: encrypted backups, firewall, health dashboard, uninstall, and the
persistent command bank. Tools: `backup`, `firewall`, `health`, `uninstall`,
`bank`.
| Tool | What it does |
|------|--------------|
@@ -9,6 +10,7 @@ Host care: encrypted backups, firewall, health dashboard, and uninstall. Tools:
| `pos system backup` | gpg-encrypted (AES-256) folder snapshots |
| `pos system firewall` | Interactive UFW ("UFW POWER") management |
| `pos system uninstall` | Safe, interactive uninstaller for the pos toolkit |
| `pos system bank` | Persistent command bank — save, run, and alias shell commands |
---
@@ -233,6 +235,39 @@ confirmation. The git repo is **never** removed — delete it manually if desire
- "Nothing to remove" → pos toolkit is not installed (or already removed)
- After uninstall, run `source ~/.bashrc` or restart your shell
## `pos system bank` — persistent command bank + bash aliases
```bash
pos system bank add convert "Convert video" "ffmpeg -i {input} -crf {quality} {output}"
pos system bank list
pos system bank run convert input=clip.mp4 quality=23 output=clip.mkv
pos system bank alias convert conv # bash alias: conv='pos system bank run convert'
pos system bank alias list
```
The bank is a persistent store of shell commands in
`~/.config/linux_post_install/bank.env` (pipe-delimited, chmod 600, managed by
the tool). `{param}` placeholders are substituted at run time; on a TTY with no
arguments the tool opens an interactive menu.
**Bash aliases** are written to `~/.bashrc` inside a managed block
(`# >>> pos bank aliases … <<<`), one line per alias:
`alias <alias_name>='pos system bank run <name>'`. Details:
- Default alias name is the bank name; pass a second argument for a shorter one:
`pos system bank alias convert conv`.
- Reusing an alias name **retargets** it to the new bank command; removing the
last alias removes the whole managed block.
- An alias name already defined elsewhere in `~/.bashrc` is **refused** (shown
with its line) so the managed block never shadows your own config. Aliasing a
command that also exists on `PATH` prints a non-blocking notice that it will
shadow the real command in interactive shells.
- `pos system bank remove <name>` also drops aliases pointing at the removed
command.
After adding/removing an alias, run `source ~/.bashrc` (or open a new shell)
for it to take effect.
---
## Related
+1
View File
@@ -35,6 +35,7 @@ cd Linux_post_install
| `--apps` | Interactive app picker after core install |
| `--full` | Core install + all apps (non-interactive) |
| `--dry-run` | Preview without executing |
| `--force` | Re-install even if the version matches |
| `--skip <phase>` | Skip a phase: `preinstall`, `scripts`, `postinstall`, `scalepoint`, `apps` |
| `--steps <spec>` | Run only specific phases, e.g. `--steps 1,3` |
| `--no-color` | Disable colored output |
+10 -2
View File
@@ -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-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"
+133 -24
View File
@@ -8,6 +8,9 @@ CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}"
CONFIG_FILE="$CONFIG_DIR/telegram.env"
MAP_FILE="$CONFIG_DIR/telegram_commands.env"
PREFIX_FILE="$CONFIG_DIR/telegram_prefixes.env"
# Persisted getUpdates offset (survives restarts so unconfirmed updates are
# never re-delivered in a burst after a crash/restart).
STATE_FILE="${LISTENER_STATE_FILE:-$CONFIG_DIR/telegram-listener.state}"
API="https://api.telegram.org"
SERVICE="pos-telegram-listener.service"
USER_SYSTEMD_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
@@ -36,10 +39,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
@@ -95,6 +100,31 @@ load_config() {
load_env_file "$CONFIG_FILE"
}
# ── getUpdates offset persistence ────────────────────────────────
# load_offset — read the last confirmed update offset from STATE_FILE.
# Returns 0 when the file is missing/invalid (fresh start).
load_offset() {
local val
[ -f "$STATE_FILE" ] || { printf '0'; return 0; }
val="$(cat "$STATE_FILE" 2>/dev/null || true)"
case "$val" in
''|*[!0-9]*) printf '0' ;;
*) printf '%s' "$val" ;;
esac
}
# save_offset — atomically persist the last processed update offset.
save_offset() {
local val="$1"
mkdir -p "$(dirname "$STATE_FILE")"
local tmp
tmp="$(mktemp)"
printf '%s\n' "$val" > "$tmp"
chmod 600 "$tmp"
mv "$tmp" "$STATE_FILE"
chmod 600 "$STATE_FILE"
}
# ── command map (MAP_FILE) ──────────────────────────────────────
# Lines: /cmd=bash command, or /cmd::description=bash command. Keys keep the
# leading slash; read via awk so values may contain '='. Entries are emitted
@@ -337,24 +367,69 @@ 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
#
# No SIGCHLD reaper: `wait -n`/`wait -n -p` inside a CHLD trap is unreliable
# on bash 5.2 (it reports "no children" even when processes have exited), so
# a trap-based reaper silently never fires. reap_commands below polls with
# kill -0 and reaps via `wait "$pid" || rc=$?` — simple, non-blocking, and
# exit-code-correct (see the crash this design replaces).
# 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. `wait "$pid"` returns immediately since the
# process has exited. IMPORTANT: a bare `wait "$pid"` under
# `set -e` would abort the daemon on non-zero exits (the crash
# this code replaces), so the status is captured via `|| rc=$?`.
local rc=0
wait "$pid" 2>/dev/null || rc=$?
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 +596,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 +825,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'"
@@ -762,9 +861,16 @@ run_daemon() {
fi
sync_bot_commands || true
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
# Resume from the last persisted update offset so unconfirmed updates are
# not re-delivered in a burst after a crash/restart (systemd Restart=always
# used to restart from 0 and re-run duplicate commands).
local offset
offset="$(load_offset)"
log "listener running (chat ${TELEGRAM_CHAT_ID}, owner ${TELEGRAM_OWNER_ID:-unset}, offset ${offset}) — Ctrl+C to stop"
# No SIGCHLD reaper here: `wait -n` inside a CHLD trap is unreliable on
# bash 5.2 (see the note near the _CMD_* arrays). reap_commands handles
# finished children after each polling cycle.
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" \
@@ -785,6 +891,7 @@ run_daemon() {
msg_id="$(printf '%s' "$resp" | jq -r ".result[$i].message.message_id // empty")"
reply_text="$(printf '%s' "$resp" | jq -r ".result[$i].message.reply_to_message.text // .result[$i].message.reply_to_message.caption // empty")"
offset=$((u + 1))
save_offset "$offset"
[ -n "$text" ] || continue
if [ -z "${TELEGRAM_OWNER_ID:-}" ]; then
warn "TELEGRAM_OWNER_ID unset — ignoring command (set it with 'pos config telegram')"
@@ -799,6 +906,8 @@ run_daemon() {
fi
handle_message "$text" "$msg_id" "$reply_text"
done
# Collect output from finished background commands and send replies.
reap_commands
done
}
+2 -216
View File
@@ -1,219 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: media grab — Auto-download URL as audio or video (classify + route)
# POS_FLAGS: --audio --video --best --worst --output --no-playlist --cookies --dry-run
# POS_CONFIG: grab | grab.env | GRAB_DEFAULT=:Default mode for unknown domains (video or audio, default video)
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
# Shared config loader (canonical KEY=VALUE parser, env-wins precedence)
source "$(dirname "$0")/../lib/config-ui.sh" 2>/dev/null || source "$(dirname "$0")/config-ui.sh"
# Load grab.env config (env-seam: GRAB_DEFAULT)
load_grab_config() {
load_env_file "$CONFIG_DIR/grab.env"
}
load_grab_config
# ── URL classification ─────────────────────────────────────────
classify_url() {
local url="$1" mode="${GRAB_DEFAULT:-video}"
case "$url" in
*music.youtube.com*) echo "audio" ;;
*soundcloud.com*) echo "audio" ;;
*bandcamp.com*) echo "audio" ;;
*youtube.com*|*youtu.be*) echo "video" ;;
*vimeo.com*) echo "video" ;;
*twitch.tv*) echo "video" ;;
*) echo "$mode" ;;
esac
}
usage() {
cat <<EOF
Usage: pos media grab [options] <url>
Auto-download a URL as audio or video. Classifies the domain and delegates
to 'pos media mp3' (audio) or 'pos media mp4' (video).
Options:
--audio Force audio (mp3) download
--video Force video (mp4) download
--best Best quality for video (default for non-interactive)
--worst Lowest quality for video
-o, --output <dir> Output directory (passed to mp3/mp4)
--no-playlist Download only the single video
--cookies <file> Netscape cookies.txt for age-gated content
--dry-run Print the command that would run, don't execute
-h, --help This help
Examples:
pos media grab https://music.youtube.com/watch?v=abc
pos media grab https://youtube.com/watch?v=xyz
pos media grab --audio https://vimeo.com/123
pos media grab --worst https://youtu.be/abc
pos media grab --dry-run https://soundcloud.com/artist/track
EOF
exit 0
}
# ── Arg parsing ────────────────────────────────────────────────
URL=""
FORCE_AUDIO=0
FORCE_VIDEO=0
BEST=0
WORST=0
DRY_RUN=0
EXTRA_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage ;;
--audio) FORCE_AUDIO=1; shift ;;
--video) FORCE_VIDEO=1; shift ;;
--best) BEST=1; shift ;;
--worst) WORST=1; shift ;;
-o|--output)
[ $# -ge 2 ] || err "pos media grab: --output needs a value"
EXTRA_ARGS+=(--output "$2"); shift 2 ;;
--no-playlist) EXTRA_ARGS+=(--no-playlist); shift ;;
--cookies)
[ $# -ge 2 ] || err "pos media grab: --cookies needs a value"
EXTRA_ARGS+=(--cookies "$2"); shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
-*) err "pos media grab: Unknown option: $1 (see --help)" ;;
*)
[ -z "$URL" ] && URL="$1" && shift || err "pos media grab: Unexpected argument: $1" ;;
esac
done
[ -n "$URL" ] || usage
# Validate URL scheme
case "$URL" in
http://*|https://*) ;;
*) err "pos media grab: not a valid URL: $URL (must start with http:// or https://)" ;;
esac
# Validate mutually exclusive overrides
[ "$FORCE_AUDIO" -eq 1 ] && [ "$FORCE_VIDEO" -eq 1 ] && \
err "pos media grab: --audio and --video are mutually exclusive"
[ "$BEST" -eq 1 ] && [ "$WORST" -eq 1 ] && \
err "pos media grab: --best and --worst are mutually exclusive"
# ── Classification ─────────────────────────────────────────────
mode=""
if [ "$FORCE_AUDIO" -eq 1 ]; then
mode="audio"
elif [ "$FORCE_VIDEO" -eq 1 ]; then
mode="video"
else
mode="$(classify_url "$URL")"
fi
# ── Build delegated command ────────────────────────────────────
DELEGATE_ARGS=()
if [ "$mode" = "audio" ]; then
DELEGATE_ARGS=(pos media mp3 "${EXTRA_ARGS[@]}")
else
# mp4 route: --best by default (non-interactive), --worst if user passes it
if [ "$WORST" -eq 1 ]; then
DELEGATE_ARGS=(pos media mp4 --worst "${EXTRA_ARGS[@]}")
else
DELEGATE_ARGS=(pos media mp4 --best "${EXTRA_ARGS[@]}")
fi
fi
# ── Dry run ────────────────────────────────────────────────────
if [ "$DRY_RUN" -eq 1 ]; then
echo "${DELEGATE_ARGS[*]} $URL"
exit 0
fi
# ── Execute ────────────────────────────────────────────────────
output=""
rc=0
if output=$("${DELEGATE_ARGS[@]}" "$URL" 2>&1); then
rc=0
else
rc=$?
fi
if [ "$rc" -ne 0 ]; then
# Summarize stderr for the user
summary="$(printf '%s' "$output" | grep -i 'error\|fail' | head -1 || true)"
[ -z "$summary" ] && summary="exit code $rc"
err "pos media grab: ❌ Download failed: $summary"
fi
# ── Metadata + summary ────────────────────────────────────────
title=""
duration=""
file_path=""
# Determine expected output directory
if [ "$mode" = "audio" ]; then
out_dir="$HOME/Music"
for (( i=0; i<${#EXTRA_ARGS[@]}; i++ )); do
if [ "${EXTRA_ARGS[$i]}" = "--output" ] && [ $(( i + 1 )) -lt ${#EXTRA_ARGS[@]} ]; then
out_dir="${EXTRA_ARGS[$(( i + 1 ))]}"
break
fi
done
file_ext="mp3"
else
out_dir="$HOME/Videos"
for (( i=0; i<${#EXTRA_ARGS[@]}; i++ )); do
if [ "${EXTRA_ARGS[$i]}" = "--output" ] && [ $(( i + 1 )) -lt ${#EXTRA_ARGS[@]} ]; then
out_dir="${EXTRA_ARGS[$(( i + 1 ))]}"
break
fi
done
file_ext="mp4"
fi
# Fetch metadata (fast, no download)
if command -v yt-dlp &>/dev/null; then
meta="$(yt-dlp --print title --print duration_string --no-warnings "$URL" 2>/dev/null || true)"
title="$(printf '%s' "$meta" | sed -n '1p')"
duration="$(printf '%s' "$meta" | sed -n '2p')"
fi
# Find the downloaded file (most recent matching extension in out_dir)
if [ -d "$out_dir" ]; then
file_path="$(find "$out_dir" -maxdepth 1 -name "*.$file_ext" -printf '%T@ %p\n' 2>/dev/null \
| sort -rn | head -1 | cut -d' ' -f2- || true)"
fi
# Build summary
[ -z "$title" ] && title="$(basename "$URL" | sed 's/[?#].*//')"
[ -z "$duration" ] && duration="?"
if [ "$mode" = "audio" ]; then
emoji="🎵"
else
emoji="🎬"
fi
echo "$emoji Downloaded: $title ($duration)"
if [ -n "$file_path" ] && [ -f "$file_path" ]; then
file_size="$(stat --printf='%s' "$file_path" 2>/dev/null || echo "0")"
# Format size in human-readable form
if [ "$file_size" -ge 1073741824 ]; then
size_human="$(awk "BEGIN { printf \"%.1f GB\", $file_size / 1073741824 }")"
elif [ "$file_size" -ge 1048576 ]; then
size_human="$(awk "BEGIN { printf \"%.1f MB\", $file_size / 1048576 }")"
elif [ "$file_size" -ge 1024 ]; then
size_human="$(awk "BEGIN { printf \"%.1f KB\", $file_size / 1024 }")"
else
size_human="${file_size} B"
fi
# Show path relative to HOME
rel_path="${file_path/#$HOME/\~}"
echo "📁 $rel_path ($size_human)"
else
echo "📁 $out_dir/ ($file_ext)"
fi
# -h|--help forwarded via exec below
exec pos media yt grab "$@"
+2 -83
View File
@@ -1,86 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: media mp3 — Download audio as MP3 (yt-dlp)
# POS_FLAGS: --output --no-playlist --cookies --by-artist --dry-run
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
# --dry-run is parsed before the deps guards (below): it previews the yt-dlp
# command without requiring yt-dlp/ffmpeg to be installed.
DRY_RUN=0
for arg in "$@"; do
[ "$arg" = "--dry-run" ] && DRY_RUN=1
done
# Deps guards sit before -h|--help (help also errors on a box missing the deps).
if [ "$DRY_RUN" -eq 0 ]; then
command -v yt-dlp &>/dev/null || err "yt-dlp not found — install it with: sudo apt install yt-dlp"
command -v ffmpeg &>/dev/null || err "ffmpeg not found (needed for MP3 conversion) — install it with: sudo apt install ffmpeg"
fi
OUT_DIR="$HOME/Music"
BY_ARTIST=0
EXTRA_ARGS=()
usage() {
cat <<EOF
Usage: pos media mp3 [options] <url>
Download audio from a URL and convert to MP3 via yt-dlp.
Options:
-o, --output <dir> Output directory (default: $OUT_DIR)
--by-artist Organize as <dir>/<artist>/<title>.mp3
--no-playlist Download only the single video
--cookies <file> Netscape cookies.txt for age-gated content
--dry-run Print the yt-dlp command without running it
-h, --help This help
Examples:
pos media mp3 https://youtube.com/watch?v=dQw4w9WgXcQ
pos media mp3 --by-artist --no-playlist https://youtu.be/dQw4w9WgXcQ
pos media mp3 --cookies ~/cookies.txt https://vimeo.com/123
EOF
exit 0
}
URL=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage ;;
-o|--output)
[ $# -ge 2 ] || err "--output needs a value"
OUT_DIR="$2"; shift 2 ;;
--by-artist) BY_ARTIST=1; shift ;;
--no-playlist) EXTRA_ARGS+=(--no-playlist); shift ;;
--cookies)
[ $# -ge 2 ] || err "--cookies needs a value"
[ -f "$2" ] || err "cookies file not found: $2"
EXTRA_ARGS+=(--cookies "$2"); shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
-*) err "Unknown option: $1 (see --help)" ;;
*)
[ -z "$URL" ] && URL="$1" && shift || err "Unexpected argument: $1" ;;
esac
done
[ -n "$URL" ] || usage
if [ "$BY_ARTIST" -eq 1 ]; then
OUT_TEMPLATE="$OUT_DIR/%(artist,uploader)s/%(title)s.%(ext)s"
else
OUT_TEMPLATE="$OUT_DIR/%(title)s.%(ext)s"
fi
args=(yt-dlp -x --audio-format mp3 --audio-quality 0
--embed-metadata --embed-chapters
--embed-thumbnail --convert-thumbnails jpg
--parse-metadata "%(artist,uploader)s:%(artist)s"
--no-overwrites
-o "$OUT_TEMPLATE"
"${EXTRA_ARGS[@]}" "$URL")
if [ "$DRY_RUN" -eq 1 ]; then
echo "yt-dlp ${args[*]:1}"
else
spawn "downloading audio → $OUT_DIR" "${args[@]}"
fi
# -h|--help forwarded via exec below
exec pos media yt mp3 "$@"
+2 -129
View File
@@ -1,132 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: media mp4 — Download video as MP4 (smart/interactive format select)
# POS_FLAGS: --format --best --worst --output --no-playlist --cookies --dry-run
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
# --dry-run is parsed before the deps guards (below): it previews the yt-dlp
# command without requiring yt-dlp/ffmpeg to be installed.
DRY_RUN=0
for arg in "$@"; do
[ "$arg" = "--dry-run" ] && DRY_RUN=1
done
# Deps guards sit before -h|--help (help also errors on a box missing the deps).
if [ "$DRY_RUN" -eq 0 ]; then
command -v yt-dlp &>/dev/null || err "yt-dlp not found — install it with: sudo apt install yt-dlp"
command -v ffmpeg &>/dev/null || err "ffmpeg not found (needed for MP4 merge) — install it with: sudo apt install ffmpeg"
fi
OUT_DIR="$HOME/Videos"
FORMAT=""
BEST=0
WORST=0
EXTRA_ARGS=()
usage() {
cat <<EOF
Usage: pos media mp4 [options] <url>
Download video from a URL via yt-dlp. Without -f/--best/--worst, formats are
listed and picked interactively.
Options:
-f, --format <id> Download that format id directly (no prompt)
--best Best video + audio (no prompt)
--worst Lowest quality (no prompt)
-o, --output <dir> Output directory (default: $OUT_DIR)
--no-playlist Download only the single video
--cookies <file> Netscape cookies.txt for age-gated content
--dry-run Print the yt-dlp command without running it
-h, --help This help
Examples:
pos media mp4 https://youtube.com/watch?v=dQw4w9WgXcQ
pos media mp4 --best https://youtube.com/watch?v=dQw4w9WgXcQ
pos media mp4 -f 22 https://youtube.com/watch?v=dQw4w9WgXcQ
EOF
exit 0
}
URL=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage ;;
-f|--format)
[ $# -ge 2 ] || err "--format needs a value"
FORMAT="$2"; shift 2 ;;
--best) BEST=1; shift ;;
--worst) WORST=1; shift ;;
-o|--output)
[ $# -ge 2 ] || err "--output needs a value"
OUT_DIR="$2"; shift 2 ;;
--no-playlist) EXTRA_ARGS+=(--no-playlist); shift ;;
--cookies)
[ $# -ge 2 ] || err "--cookies needs a value"
[ -f "$2" ] || err "cookies file not found: $2"
EXTRA_ARGS+=(--cookies "$2"); shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
-*) err "Unknown option: $1 (see --help)" ;;
*)
[ -z "$URL" ] && URL="$1" && shift || err "Unexpected argument: $1" ;;
esac
done
[ -n "$URL" ] || usage
[ -n "$FORMAT" ] && { [ "$BEST" -eq 1 ] || [ "$WORST" -eq 1 ]; } \
&& err "--format and --best/--worst are mutually exclusive"
[ "$BEST" -eq 1 ] && [ "$WORST" -eq 1 ] && err "--best and --worst are mutually exclusive"
# Resolve the format selector: explicit flag → smart preset → interactive.
# The curated table goes to stderr; stdout carries ONLY the chosen format id
# (same ui_pick lesson — a $(...) capture must not swallow the menu).
fmt_spec=""
if [ -n "$FORMAT" ]; then
fmt_spec="$FORMAT"
elif [ "$BEST" -eq 1 ]; then
fmt_spec="bestvideo*+bestaudio/best"
elif [ "$WORST" -eq 1 ]; then
fmt_spec="worst"
else
table="$(yt-dlp -F "$URL" 2>&1)" || {
echo "ERROR: no formats listed (unsupported site or age-gated content)" >&2
exit 1
}
printf '%s' "$table" | awk -F'[[:space:]][[:space:]]+' '
NR <= 2 { print; next }
$0 ~ /^-+$/ { print; next }
$0 ~ /audio only/ && $0 !~ /storyboard/ && $1 !~ /^sb/ { print " [audio] " $0; next }
$0 ~ /video only/ && $1 !~ /^sb/ { print " [video] " $0; next }
$1 ~ /^[0-9]+$/ && $2 ~ /^(mp4|webm)$/ && $3 ~ /x/ { print " [combo] " $0 }
' >&2
echo >&2
read -rp "Format ID ('best'/'worst'/empty = best): " chosen
case "${chosen:-best}" in
best) fmt_spec="bestvideo*+bestaudio/best" ;;
worst) fmt_spec="worst" ;;
*)
if printf '%s' "$table" | grep -qE "^[[:space:]]*${chosen}[[:space:]]"; then
fmt_spec="$chosen"
else
echo "ERROR: unknown format id '$chosen'" >&2
exit 1
fi
;;
esac
fi
args=(yt-dlp -f "$fmt_spec"
--merge-output-format mp4
--embed-metadata --embed-chapters
--embed-subs --sub-langs all
--embed-thumbnail
--no-overwrites
-o "$OUT_DIR/%(title)s.%(ext)s"
"${EXTRA_ARGS[@]}" "$URL")
if [ "$DRY_RUN" -eq 1 ]; then
echo "yt-dlp ${args[*]:1}"
else
spawn "downloading video → $OUT_DIR" "${args[@]}"
fi
# -h|--help forwarded via exec below
exec pos media yt mp4 "$@"
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: media yt — YouTube download tools (mp3/mp4/grab/ytsync/subtitles)
# POS_SUBCMDS: mp3 mp4 grab ytsync subtitles
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
usage() {
cat <<'EOF'
Usage: pos media yt <command> [args]
YouTube download tools: audio, video, auto-grab, subtitles, channel sync.
Commands:
mp3 <url> Download audio as MP3
mp4 <url> Download video as MP4 (interactive format select)
grab <url> Auto-download as audio or video
ytsync [cmd] Incremental YouTube channel sync
subtitles <url> Extract subtitles/captions
Options:
-h, --help This help
Run 'pos media yt <command> --help' for details.
EOF
exit 0
}
case "${1:-}" in
-h|--help|"") usage ;;
esac
# Subcommands are resolved by the dispatcher via pos-media-yt-<sub> files.
# This file only handles bare 'pos media yt' (help) and unknown args.
err "unknown yt command: $1 (see 'pos media yt --help')"
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: media yt-grab — Auto-download URL as audio or video (classify + route)
# POS_FLAGS: --audio --video --best --worst --output --no-playlist --cookies --dry-run
# POS_CONFIG: grab | grab.env | GRAB_DEFAULT=:Default mode for unknown domains (video or audio, default video)
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
# Shared config loader (canonical KEY=VALUE parser, env-wins precedence)
source "$(dirname "$0")/../lib/config-ui.sh" 2>/dev/null || source "$(dirname "$0")/config-ui.sh"
# Shared yt helpers (classify_url, yt_validate_url, yt_check_deps, yt_echo_cmd)
source "$(dirname "$0")/../lib/yt-lib.sh" 2>/dev/null || source "$(dirname "$0")/yt-lib.sh"
# Load grab.env config (env-seam: GRAB_DEFAULT)
load_grab_config() {
load_env_file "$CONFIG_DIR/grab.env"
}
load_grab_config
usage() {
cat <<EOF
Usage: pos media yt grab [options] <url>
Auto-download a URL as audio or video. Classifies the domain and delegates
to 'pos media yt mp3' (audio) or 'pos media yt mp4' (video).
Options:
--audio Force audio (mp3) download
--video Force video (mp4) download
--best Best quality for video (default for non-interactive)
--worst Lowest quality for video
-o, --output <dir> Output directory (passed to mp3/mp4)
--no-playlist Download only the single video
--cookies <file> Netscape cookies.txt for age-gated content
--dry-run Print the command that would run, don't execute
-h, --help This help
Examples:
pos media yt grab https://music.youtube.com/watch?v=abc
pos media yt grab https://youtube.com/watch?v=xyz
pos media yt grab --audio https://vimeo.com/123
pos media yt grab --worst https://youtu.be/abc
pos media yt grab --dry-run https://soundcloud.com/artist/track
The legacy spelling 'pos media grab' still works (forwarder) and is equivalent.
EOF
exit 0
}
# ── Arg parsing ────────────────────────────────────────────────
URL=""
FORCE_AUDIO=0
FORCE_VIDEO=0
BEST=0
WORST=0
DRY_RUN=0
EXTRA_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage ;;
--audio) FORCE_AUDIO=1; shift ;;
--video) FORCE_VIDEO=1; shift ;;
--best) BEST=1; shift ;;
--worst) WORST=1; shift ;;
-o|--output)
[ $# -ge 2 ] || err "yt-grab: --output needs a value"
EXTRA_ARGS+=(--output "$2"); shift 2 ;;
--no-playlist) EXTRA_ARGS+=(--no-playlist); shift ;;
--cookies)
[ $# -ge 2 ] || err "yt-grab: --cookies needs a value"
EXTRA_ARGS+=(--cookies "$2"); shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
-*) err "yt-grab: Unknown option: $1 (see --help)" ;;
*)
[ -z "$URL" ] && URL="$1" && shift || err "yt-grab: Unexpected argument: $1" ;;
esac
done
[ -n "$URL" ] || usage
# Validate URL scheme
yt_validate_url "$URL" 2>/dev/null || \
err "yt-grab: not a valid URL: $URL (must start with http:// or https://)"
# Validate mutually exclusive overrides
[ "$FORCE_AUDIO" -eq 1 ] && [ "$FORCE_VIDEO" -eq 1 ] && \
err "yt-grab: --audio and --video are mutually exclusive"
[ "$BEST" -eq 1 ] && [ "$WORST" -eq 1 ] && \
err "yt-grab: --best and --worst are mutually exclusive"
# ── Classification ─────────────────────────────────────────────
mode=""
if [ "$FORCE_AUDIO" -eq 1 ]; then
mode="audio"
elif [ "$FORCE_VIDEO" -eq 1 ]; then
mode="video"
else
mode="$(classify_url "$URL")"
fi
# ── Build delegated command ────────────────────────────────────
DELEGATE_ARGS=()
if [ "$mode" = "audio" ]; then
DELEGATE_ARGS=(pos media yt mp3 "${EXTRA_ARGS[@]}")
else
# mp4 route: --best by default (non-interactive), --worst if user passes it
if [ "$WORST" -eq 1 ]; then
DELEGATE_ARGS=(pos media yt mp4 --worst "${EXTRA_ARGS[@]}")
else
DELEGATE_ARGS=(pos media yt mp4 --best "${EXTRA_ARGS[@]}")
fi
fi
# ── Dry run ────────────────────────────────────────────────────
if [ "$DRY_RUN" -eq 1 ]; then
echo "${DELEGATE_ARGS[*]} $URL"
exit 0
fi
# ── Execute ────────────────────────────────────────────────────
output=""
rc=0
if output=$("${DELEGATE_ARGS[@]}" "$URL" 2>&1); then
rc=0
else
rc=$?
fi
if [ "$rc" -ne 0 ]; then
# Summarize stderr for the user
summary="$(printf '%s' "$output" | grep -i 'error\|fail' | head -1 || true)"
[ -z "$summary" ] && summary="exit code $rc"
err "yt-grab: ❌ Download failed: $summary"
fi
# ── Metadata + summary ────────────────────────────────────────
title=""
duration=""
file_path=""
# Determine expected output directory
if [ "$mode" = "audio" ]; then
out_dir="$HOME/Music"
for (( i=0; i<${#EXTRA_ARGS[@]}; i++ )); do
if [ "${EXTRA_ARGS[$i]}" = "--output" ] && [ $(( i + 1 )) -lt ${#EXTRA_ARGS[@]} ]; then
out_dir="${EXTRA_ARGS[$(( i + 1 ))]}"
break
fi
done
file_ext="mp3"
else
out_dir="$HOME/Videos"
for (( i=0; i<${#EXTRA_ARGS[@]}; i++ )); do
if [ "${EXTRA_ARGS[$i]}" = "--output" ] && [ $(( i + 1 )) -lt ${#EXTRA_ARGS[@]} ]; then
out_dir="${EXTRA_ARGS[$(( i + 1 ))]}"
break
fi
done
file_ext="mp4"
fi
# Fetch metadata (fast, no download)
if command -v yt-dlp &>/dev/null; then
meta="$(yt-dlp --print title --print duration_string --no-warnings "$URL" 2>/dev/null || true)"
title="$(printf '%s' "$meta" | sed -n '1p')"
duration="$(printf '%s' "$meta" | sed -n '2p')"
fi
# Find the downloaded file (most recent matching extension in out_dir)
if [ -d "$out_dir" ]; then
file_path="$(find "$out_dir" -maxdepth 1 -name "*.$file_ext" -printf '%T@ %p\n' 2>/dev/null \
| sort -rn | head -1 | cut -d' ' -f2- || true)"
fi
# Build summary
[ -z "$title" ] && title="$(basename "$URL" | sed 's/[?#].*//')"
[ -z "$duration" ] && duration="?"
if [ "$mode" = "audio" ]; then
emoji="🎵"
else
emoji="🎬"
fi
echo "$emoji Downloaded: $title ($duration)"
if [ -n "$file_path" ] && [ -f "$file_path" ]; then
file_size="$(stat --printf='%s' "$file_path" 2>/dev/null || echo "0")"
# Format size in human-readable form
if [ "$file_size" -ge 1073741824 ]; then
size_human="$(awk "BEGIN { printf \"%.1f GB\", $file_size / 1073741824 }")"
elif [ "$file_size" -ge 1048576 ]; then
size_human="$(awk "BEGIN { printf \"%.1f MB\", $file_size / 1048576 }")"
elif [ "$file_size" -ge 1024 ]; then
size_human="$(awk "BEGIN { printf \"%.1f KB\", $file_size / 1024 }")"
else
size_human="${file_size} B"
fi
# Show path relative to HOME
rel_path="${file_path/#$HOME/\~}"
echo "📁 $rel_path ($size_human)"
else
echo "📁 $out_dir/ ($file_ext)"
fi
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: media yt-mp3 — Download audio as MP3 (yt-dlp)
# POS_FLAGS: --output --no-playlist --cookies --by-artist --dry-run
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/yt-lib.sh" 2>/dev/null || source "$(dirname "$0")/yt-lib.sh"
# --dry-run is parsed before the deps guards (below): it previews the yt-dlp
# command without requiring yt-dlp/ffmpeg to be installed.
DRY_RUN=0
for arg in "$@"; do
[ "$arg" = "--dry-run" ] && DRY_RUN=1
done
# Deps guards sit before -h|--help (help also errors on a box missing the deps).
# yt_check_deps with dry-run=1 skips the dep checks (dry-run preview needs none).
yt_check_deps "$DRY_RUN" "MP3 conversion"
# Env seam: YT_OUT_DIR overrides the default (keeps HOME sandbox-compatible).
OUT_DIR="${YT_OUT_DIR:-$HOME/Music}"
BY_ARTIST=0
EXTRA_ARGS=()
usage() {
cat <<EOF
Usage: pos media yt mp3 [options] <url>
Download audio from a URL and convert to MP3 via yt-dlp.
Options:
-o, --output <dir> Output directory (default: $OUT_DIR)
--by-artist Organize as <dir>/<artist>/<title>.mp3
--no-playlist Download only the single video
--cookies <file> Netscape cookies.txt for age-gated content
--dry-run Print the yt-dlp command without running it
-h, --help This help
Examples:
pos media yt mp3 https://youtube.com/watch?v=dQw4w9WgXcQ
pos media yt mp3 --by-artist --no-playlist https://youtu.be/dQw4w9WgXcQ
pos media yt mp3 --cookies ~/cookies.txt https://vimeo.com/123
EOF
exit 0
}
URL=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage ;;
-o|--output)
[ $# -ge 2 ] || err "--output needs a value"
OUT_DIR="$2"; shift 2 ;;
--by-artist) BY_ARTIST=1; shift ;;
--no-playlist) EXTRA_ARGS+=(--no-playlist); shift ;;
--cookies)
[ $# -ge 2 ] || err "--cookies needs a value"
[ -f "$2" ] || err "cookies file not found: $2"
EXTRA_ARGS+=(--cookies "$2"); shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
-*) err "Unknown option: $1 (see --help)" ;;
*)
[ -z "$URL" ] && URL="$1" && shift || err "Unexpected argument: $1" ;;
esac
done
[ -n "$URL" ] || usage
if [ "$BY_ARTIST" -eq 1 ]; then
OUT_TEMPLATE="$OUT_DIR/%(artist,uploader)s/%(title)s.%(ext)s"
else
OUT_TEMPLATE="$OUT_DIR/%(title)s.%(ext)s"
fi
args=(yt-dlp -x --audio-format mp3 --audio-quality 0
--embed-metadata --embed-chapters
--embed-thumbnail --convert-thumbnails jpg
--parse-metadata "%(artist,uploader)s:%(artist)s"
--no-overwrites
-o "$OUT_TEMPLATE"
"${EXTRA_ARGS[@]}" "$URL")
if [ "$DRY_RUN" -eq 1 ]; then
echo "yt-dlp ${args[*]:1}"
else
spawn "downloading audio → $OUT_DIR" "${args[@]}"
fi
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: media yt-mp4 — Download video as MP4 (smart/interactive format select)
# POS_FLAGS: --format --best --worst --output --no-playlist --cookies --dry-run
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/yt-lib.sh" 2>/dev/null || source "$(dirname "$0")/yt-lib.sh"
# --dry-run is parsed before the deps guards (below): it previews the yt-dlp
# command without requiring yt-dlp/ffmpeg to be installed.
DRY_RUN=0
for arg in "$@"; do
[ "$arg" = "--dry-run" ] && DRY_RUN=1
done
# Deps guards sit before -h|--help (help also errors on a box missing the deps).
# yt_check_deps with dry-run=1 skips the dep checks (dry-run preview needs none).
yt_check_deps "$DRY_RUN" "MP4 merge"
# Env seam: YT_OUT_DIR overrides the default (keeps HOME sandbox-compatible).
OUT_DIR="${YT_OUT_DIR:-$HOME/Videos}"
FORMAT=""
BEST=0
WORST=0
EXTRA_ARGS=()
usage() {
cat <<EOF
Usage: pos media yt mp4 [options] <url>
Download video from a URL via yt-dlp. Without -f/--best/--worst, formats are
listed and picked interactively.
Options:
-f, --format <id> Download that format id directly (no prompt)
--best Best video + audio (no prompt)
--worst Lowest quality (no prompt)
-o, --output <dir> Output directory (default: $OUT_DIR)
--no-playlist Download only the single video
--cookies <file> Netscape cookies.txt for age-gated content
--dry-run Print the yt-dlp command without running it
-h, --help This help
Examples:
pos media yt mp4 https://youtube.com/watch?v=dQw4w9WgXcQ
pos media yt mp4 --best https://youtube.com/watch?v=dQw4w9WgXcQ
pos media yt mp4 -f 22 https://youtube.com/watch?v=dQw4w9WgXcQ
EOF
exit 0
}
URL=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage ;;
-f|--format)
[ $# -ge 2 ] || err "--format needs a value"
FORMAT="$2"; shift 2 ;;
--best) BEST=1; shift ;;
--worst) WORST=1; shift ;;
-o|--output)
[ $# -ge 2 ] || err "--output needs a value"
OUT_DIR="$2"; shift 2 ;;
--no-playlist) EXTRA_ARGS+=(--no-playlist); shift ;;
--cookies)
[ $# -ge 2 ] || err "--cookies needs a value"
[ -f "$2" ] || err "cookies file not found: $2"
EXTRA_ARGS+=(--cookies "$2"); shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
-*) err "Unknown option: $1 (see --help)" ;;
*)
[ -z "$URL" ] && URL="$1" && shift || err "Unexpected argument: $1" ;;
esac
done
[ -n "$URL" ] || usage
[ -n "$FORMAT" ] && { [ "$BEST" -eq 1 ] || [ "$WORST" -eq 1 ]; } \
&& err "--format and --best/--worst are mutually exclusive"
[ "$BEST" -eq 1 ] && [ "$WORST" -eq 1 ] && err "--best and --worst are mutually exclusive"
# Resolve the format selector: explicit flag → smart preset → interactive.
# The curated table goes to stderr; stdout carries ONLY the chosen format id
# (same ui_pick lesson — a $(...) capture must not swallow the menu).
fmt_spec=""
if [ -n "$FORMAT" ]; then
fmt_spec="$FORMAT"
elif [ "$BEST" -eq 1 ]; then
fmt_spec="bestvideo*+bestaudio/best"
elif [ "$WORST" -eq 1 ]; then
fmt_spec="worst"
else
table="$(yt-dlp -F "$URL" 2>&1)" || {
echo "ERROR: no formats listed (unsupported site or age-gated content)" >&2
exit 1
}
printf '%s' "$table" | awk -F'[[:space:]][[:space:]]+' '
NR <= 2 { print; next }
$0 ~ /^-+$/ { print; next }
$0 ~ /audio only/ && $0 !~ /storyboard/ && $1 !~ /^sb/ { print " [audio] " $0; next }
$0 ~ /video only/ && $1 !~ /^sb/ { print " [video] " $0; next }
$1 ~ /^[0-9]+$/ && $2 ~ /^(mp4|webm)$/ && $3 ~ /x/ { print " [combo] " $0 }
' >&2
echo >&2
read -rp "Format ID ('best'/'worst'/empty = best): " chosen
case "${chosen:-best}" in
best) fmt_spec="bestvideo*+bestaudio/best" ;;
worst) fmt_spec="worst" ;;
*)
if printf '%s' "$table" | grep -qE "^[[:space:]]*${chosen}[[:space:]]"; then
fmt_spec="$chosen"
else
echo "ERROR: unknown format id '$chosen'" >&2
exit 1
fi
;;
esac
fi
args=(yt-dlp -f "$fmt_spec"
--merge-output-format mp4
--embed-metadata --embed-chapters
--embed-subs --sub-langs all
--embed-thumbnail
--no-overwrites
-o "$OUT_DIR/%(title)s.%(ext)s"
"${EXTRA_ARGS[@]}" "$URL")
if [ "$DRY_RUN" -eq 1 ]; then
echo "yt-dlp ${args[*]:1}"
else
spawn "downloading video → $OUT_DIR" "${args[@]}"
fi
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: media yt-subtitles — Extract subtitles/captions from a URL (yt-dlp)
# POS_FLAGS: --lang --format --auto-only --output --list-subs --no-playlist --dry-run
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/yt-lib.sh" 2>/dev/null || source "$(dirname "$0")/yt-lib.sh"
# --dry-run / --list-subs are parsed before the deps guards (below): dry-run
# previews the yt-dlp command without requiring yt-dlp to be installed.
DRY_RUN=0
LIST_SUBS=0
for arg in "$@"; do
[ "$arg" = "--dry-run" ] && DRY_RUN=1
[ "$arg" = "--list-subs" ] && LIST_SUBS=1
done
# Deps guards sit before -h|--help. Subtitles needs only yt-dlp (no ffmpeg).
# dry-run skips the deps check entirely (preview needs no binary).
if [ "$DRY_RUN" -eq 0 ]; then
command -v yt-dlp &>/dev/null || err "yt-dlp not found — install it with: sudo apt install yt-dlp"
fi
LANG_SPEC=""
FMT="srt"
AUTO_ONLY=0
OUT_DIR=""
NO_PLAYLIST=0
usage() {
cat <<EOF
Usage: pos media yt subtitles [options] <url>
Extract subtitles/captions from a URL via yt-dlp. Fetches manual captions and
auto-generated captions by default.
Options:
--lang <list> Subtitle languages (comma-separated), default: best
--format <fmt> Output format: srt (default), vtt, or txt
--auto-only Only auto-generated captions (no manual subs)
-o, --output <dir> Output directory (default: current directory)
--list-subs List available subtitles and exit (probe only)
--no-playlist Download only the single video
--dry-run Print the yt-dlp command without running it
-h, --help This help
Examples:
pos media yt subtitles https://youtube.com/watch?v=dQw4w9WgXcQ
pos media yt subtitles --lang en,ar https://youtube.com/watch?v=dQw4w9WgXcQ
pos media yt subtitles --format txt https://youtu.be/dQw4w9WgXcQ
pos media yt subtitles --list-subs https://youtube.com/watch?v=dQw4w9WgXcQ
EOF
exit 0
}
# convert_srt_to_txt <srt-file> — strip SRT timestamps, seq numbers, HTML tags
convert_srt_to_txt() {
local srt_file="$1" txt_file="${1%.srt}.txt"
sed '/^[0-9][0-9]:[0-9][0-9]/d; /^$/d; s/<[^>]*>//g' "$srt_file" > "$txt_file"
}
URL=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage ;;
--lang)
[ $# -ge 2 ] || err "--lang needs a value"
LANG_SPEC="$2"; shift 2 ;;
--format)
[ $# -ge 2 ] || err "--format needs a value"
case "$2" in
srt|vtt|txt) FMT="$2" ;;
*) err "unknown format '$2' (use srt, vtt, or txt)" ;;
esac
shift 2 ;;
--auto-only) AUTO_ONLY=1; shift ;;
-o|--output)
[ $# -ge 2 ] || err "--output needs a value"
OUT_DIR="$2"; shift 2 ;;
--list-subs) LIST_SUBS=1; shift ;;
--no-playlist) NO_PLAYLIST=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
-*) err "Unknown option: $1 (see --help)" ;;
*)
[ -z "$URL" ] && URL="$1" && shift || err "Unexpected argument: $1" ;;
esac
done
[ -n "$URL" ] || err "missing URL (see --help)"
yt_validate_url "$URL" 2>/dev/null || err "not a valid URL: $URL (must start with http:// or https://)"
# ── Build the yt-dlp command ─────────────────────────────────
if [ "$LIST_SUBS" -eq 1 ]; then
args=(yt-dlp --list-subs "$URL")
if [ "$DRY_RUN" -eq 1 ]; then
yt_echo_cmd "${args[@]:1}"
exit 0
fi
spawn "listing subtitles" "${args[@]}"
exit 0
fi
# Output template
OUT_TEMPLATE=""
if [ -n "$OUT_DIR" ]; then
OUT_TEMPLATE="$OUT_DIR/%(title)s.%(sub_lang)s.%(ext)s"
else
OUT_TEMPLATE="./%(title)s.%(sub_lang)s.%(ext)s"
fi
args=(yt-dlp)
if [ "$AUTO_ONLY" -eq 1 ]; then
args+=(--write-auto-subs)
else
args+=(--write-subs --write-auto-subs)
fi
if [ -n "$LANG_SPEC" ]; then
args+=(--sub-langs "$LANG_SPEC")
else
args+=(--sub-langs best)
fi
case "$FMT" in
vtt) args+=(--sub-format vtt) ;;
txt) args+=(--sub-format srt) ;;
*) args+=(--sub-format srt) ;;
esac
[ "$NO_PLAYLIST" -eq 1 ] && args+=(--no-playlist)
args+=(-o "$OUT_TEMPLATE" "$URL")
if [ "$DRY_RUN" -eq 1 ]; then
yt_echo_cmd "${args[@]:1}"
exit 0
fi
# ── Execute ───────────────────────────────────────────────────
set +e
out="$(mktemp)"
err_tmp="$(mktemp)"
"${args[@]}" >"$out" 2>"$err_tmp"
rc=$?
set -e
if [ "$rc" -ne 0 ]; then
# Unavailable-subtitle detection: exit!=0 AND stderr matches no-subs patterns
if grep -qiE 'no subtitles|no captions|unable to extract|subtitles not available' "$err_tmp"; then
rm -f "$out" "$err_tmp"
err "unavailable subtitles for this video (try --list-subs to check)"
fi
# Re-surface the real yt-dlp error otherwise
sed 's/^/ /' "$err_tmp" >&2
rm -f "$out" "$err_tmp"
exit "$rc"
fi
rm -f "$out" "$err_tmp"
# ── Post-conversion for txt format ────────────────────────────
if [ "$FMT" = "txt" ]; then
search_dir="."
[ -n "$OUT_DIR" ] && search_dir="$OUT_DIR"
while IFS= read -r -d '' f; do
case "$f" in
*.srt) convert_srt_to_txt "$f" ;;
esac
done < <(find "$search_dir" -maxdepth 1 -name '*.srt' -print0 2>/dev/null || true)
fi
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: media yt-ytsync — Incrementally sync YouTube channels/playlists into ~/Videos
# -h|--help forwarded via exec below
exec pos media ytsync "$@"
+647
View File
@@ -0,0 +1,647 @@
#!/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 alias
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
alias <name> [alias_name] Create/update a bash alias for a bank command
alias list List bash aliases in ~/.bashrc
alias remove <alias_name> Remove a bash alias
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"
# Drop bash aliases pointing at the removed command (best-effort upkeep;
# a malformed block is a warning, never a remove blocker).
if [ -f "$BASH_RC_FILE" ] && grep -qF "$ALIAS_START" "$BASH_RC_FILE"; then
if alias_block_parse; then
local -a keep=() i
local removed_alias=0
for ((i = 0; i < ${#ALIAS_NAMES[@]}; i++)); do
if [[ "${ALIAS_TARGETS[${ALIAS_NAMES[$i]}]:-}" == "$name" ]]; then
removed_alias=1
else
keep+=("${ALIAS_NAMES[$i]}")
fi
done
if [ "$removed_alias" -eq 1 ]; then
ALIAS_NAMES=("${keep[@]}")
alias_block_write
log "Removed alias pointing to '$name' from $BASH_RC_FILE"
fi
else
local rc=$?
[ "$rc" -eq 2 ] && warn "Alias block in $BASH_RC_FILE is malformed — left untouched"
fi
fi
ok "Removed: $name"
}
# ── Alias management ─────────────────────────────────────────────
# Bash aliases for bank commands live in a single managed block inside
# $BASH_RC_FILE (~/.bashrc by default), so they survive rebuilds and are easy
# to see/undo by hand. Every line has the exact shape:
# alias <alias_name>='pos system bank run <bank_name>'
# The block is written atomically (tmp + mv, preserving file permissions).
ALIAS_START='# >>> pos bank aliases (managed by pos system bank — do not hand-edit) <<<'
ALIAS_END='# <<< pos bank aliases (managed by pos system bank) <<<'
BASH_RC_FILE="${BASH_RC_FILE:-$HOME/.bashrc}"
# Valid bash alias names we manage: letters/digits/_/- (same rule as bank names).
alias_valid_name() {
[[ "$1" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]]
}
# Parse the managed block from $BASH_RC_FILE into:
# ALIAS_NAMES — ordered array of alias names (file order, unique)
# ALIAS_TARGETS — assoc: alias_name → bank command name
# Returns: 0 = healthy block present, 1 = no block, 2 = malformed block.
alias_block_parse() {
declare -g -a ALIAS_NAMES
declare -g -A ALIAS_TARGETS
ALIAS_NAMES=()
ALIAS_TARGETS=()
[ -f "$BASH_RC_FILE" ] || return 1
local line in_block=0 starts=0 ends=0 rc=0
while IFS= read -r line; do
if [ "$in_block" -eq 0 ]; then
if [[ "$line" == "$ALIAS_START" ]]; then
in_block=1
starts=$((starts + 1))
[ "$starts" -gt 1 ] && rc=2
elif [[ "$line" == "$ALIAS_END" ]]; then
rc=2
fi
elif [[ "$line" == "$ALIAS_END" ]]; then
in_block=0
ends=$((ends + 1))
elif [[ "$line" =~ ^alias[[:space:]]+([a-zA-Z][a-zA-Z0-9_-]*)= ]]; then
# NOTE: no `'` inside the =~ regex (bash treats quotes there as
# quoting operators and would literalize the ( ) groups) — the
# quoted payload is matched with a glob `case` instead.
local a="${BASH_REMATCH[1]}"
local payload="${line#*=}"
case "$payload" in
"'pos system bank run "[a-zA-Z][a-zA-Z0-9_-]*"'")
local t="${payload%\'}"
t="${t#\'pos system bank run }"
if [[ " ${ALIAS_NAMES[*]:-} " == *" $a "* ]]; then
rc=2
continue
fi
ALIAS_NAMES+=("$a")
ALIAS_TARGETS["$a"]="$t"
;;
*) rc=2 ;;
esac
else
rc=2
fi
done < "$BASH_RC_FILE"
[ "$in_block" -eq 1 ] && rc=2
[ "$rc" -eq 0 ] && [ "$starts" -eq 0 ] && return 1
return "$rc"
}
# Require a healthy managed block (alias commands err on missing/malformed).
alias_block_require() {
local rc
if alias_block_parse; then
return 0
else
rc=$?
fi
if [ "$rc" -eq 1 ]; then
err "No alias block in $BASH_RC_FILE — add one with: pos system bank alias <name>"
fi
err "Alias block in $BASH_RC_FILE is malformed (markers or lines were hand-edited) — fix it manually, then retry"
}
# Scan for an alias NAME defined OUTSIDE the managed block (a collision we
# refuse to overwrite). Prints the offending line; rc 0 = found, 1 = clean.
alias_collision() {
local name="$1"
[ -f "$BASH_RC_FILE" ] || return 1
local line in_block=0
while IFS= read -r line; do
if [ "$in_block" -eq 0 ] && [[ "$line" == "$ALIAS_START" ]]; then
in_block=1
continue
fi
if [ "$in_block" -eq 1 ] && [[ "$line" == "$ALIAS_END" ]]; then
in_block=0
continue
fi
if [ "$in_block" -eq 0 ] && [[ "$line" =~ ^[[:space:]]*alias[[:space:]]+$name= ]]; then
printf '%s\n' "$line"
return 0
fi
done < "$BASH_RC_FILE"
return 1
}
# Rewrite $BASH_RC_FILE from ALIAS_NAMES + ALIAS_TARGETS. Callers guarantee the
# file is absent or has at most one healthy block. Removes the block entirely
# when ALIAS_NAMES is empty; appends a fresh block when none exists yet.
alias_block_write() {
local content="" start_line=0 end_line=0 n=0 line
local pre="" post="" dir perms tmp new_content=""
if [ -f "$BASH_RC_FILE" ]; then
content="$(< "$BASH_RC_FILE")"
perms="$(stat -c %a "$BASH_RC_FILE" 2>/dev/null || printf '600')"
while IFS= read -r line; do
n=$((n + 1))
if [ "$start_line" -eq 0 ] && [[ "$line" == "$ALIAS_START" ]]; then
start_line=$n
elif [ "$start_line" -gt 0 ] && [ "$end_line" -eq 0 ] && [[ "$line" == "$ALIAS_END" ]]; then
end_line=$n
fi
done <<< "$content"
if [ "$start_line" -gt 0 ] && [ "$end_line" -eq 0 ]; then
err "Alias block in $BASH_RC_FILE has no end marker — fix it manually, then retry"
fi
else
perms=600
fi
# No block and nothing to manage → never touch the file (nor create it).
if [ "$start_line" -eq 0 ] && [ "${#ALIAS_NAMES[@]}" -eq 0 ]; then
return 0
fi
if [ "$start_line" -gt 0 ]; then
# NOTE: sed "1,0p" is NOT empty in GNU sed (0 is a last-line sentinel,
# it prints line 1) — guard the ranges so edge blocks rewrite cleanly.
if [ "$start_line" -gt 1 ]; then
pre="$(printf '%s\n' "$content" | sed -n "1,$((start_line - 1))p")"
fi
if [ "$end_line" -lt "$n" ]; then
post="$(printf '%s\n' "$content" | sed -n "$((end_line + 1)),\$p")"
fi
else
pre="$(printf '%s\n' "$content")"
fi
if [ -n "$pre" ]; then
new_content+="$pre"
case "$new_content" in
*$'\n') ;;
*) new_content+=$'\n' ;;
esac
fi
if [ "${#ALIAS_NAMES[@]}" -gt 0 ]; then
new_content+="$ALIAS_START"$'\n'
local i
for ((i = 0; i < ${#ALIAS_NAMES[@]}; i++)); do
new_content+="alias ${ALIAS_NAMES[$i]}='pos system bank run ${ALIAS_TARGETS[${ALIAS_NAMES[$i]}]:-}'"$'\n'
done
new_content+="$ALIAS_END"$'\n'
fi
if [ -n "$post" ]; then
new_content+="$post"
case "$new_content" in
*$'\n') ;;
*) new_content+=$'\n' ;;
esac
fi
dir="$(dirname "$BASH_RC_FILE")"
mkdir -p "$dir" || err "Cannot create directory: $dir"
tmp="$(mktemp "${dir}/.pos-bank-aliases.XXXXXX")" || err "Cannot create temp file in $dir"
printf '%s' "$new_content" > "$tmp"
chmod "$perms" "$tmp"
mv "$tmp" "$BASH_RC_FILE"
}
# ── cmd_alias ────────────────────────────────────────────────────
cmd_alias() {
case "${1:-}" in
list) shift; cmd_alias_list "$@" ;;
remove) shift; cmd_alias_remove "$@" ;;
"") err "Usage: pos system bank alias <name> [alias_name] | list | remove <alias_name>" ;;
*) cmd_alias_add "$@" ;;
esac
}
cmd_alias_add() {
local name="${1:-}" alias_name="${2:-$1}"
[ -n "$name" ] || err "Usage: pos system bank alias <name> [alias_name]"
bank_valid_name "$name" || err "Invalid command name: $name"
alias_valid_name "$alias_name" || err "Invalid alias name: $alias_name (use letters, numbers, hyphens, underscores)"
bank_load
bank_find "$name" >/dev/null 2>&1 || err "Command not found: $name"
local rc
if alias_block_parse; then
rc=0
else
rc=$?
fi
[ "$rc" -eq 2 ] && err "Alias block in $BASH_RC_FILE is malformed (hand-edited?) — fix it manually, then retry"
if [ -f "$BASH_RC_FILE" ] && hit="$(alias_collision "$alias_name")"; then
err "Alias '$alias_name' is already defined outside the managed block in $BASH_RC_FILE: $hit"
fi
if command -v "$alias_name" >/dev/null 2>&1; then
warn "Note: '$alias_name' is also a command on PATH — the alias will shadow it in interactive shells"
fi
local found=0 i
for ((i = 0; i < ${#ALIAS_NAMES[@]}; i++)); do
[[ "${ALIAS_NAMES[$i]}" == "$alias_name" ]] && found=1
done
[ "$found" -eq 0 ] && ALIAS_NAMES+=("$alias_name")
ALIAS_TARGETS["$alias_name"]="$name"
alias_block_write
ok "Alias '$alias_name' → pos system bank run $name (in $BASH_RC_FILE)"
log "Run: source $BASH_RC_FILE (or open a new shell)"
}
cmd_alias_list() {
alias_block_require
if [ "${#ALIAS_NAMES[@]}" -eq 0 ]; then
log "No aliases in $BASH_RC_FILE — add one with: pos system bank alias <name>"
return 0
fi
echo
echo "BANK ALIASES (in $BASH_RC_FILE)"
echo
local i
for ((i = 0; i < ${#ALIAS_NAMES[@]}; i++)); do
printf ' %-25s → pos system bank run %s\n' "${ALIAS_NAMES[$i]}" "${ALIAS_TARGETS[${ALIAS_NAMES[$i]}]:-}"
done
echo
}
# ── Interactive alias picker ─────────────────────────────────────
_pick_alias() {
local rc
if alias_block_parse; then
rc=0
else
rc=$?
fi
[ "$rc" -eq 2 ] && err "Alias block in $BASH_RC_FILE is malformed — fix it manually"
[ "$rc" -eq 1 ] && err "No alias block in $BASH_RC_FILE — add one with: pos system bank alias <name>"
if [ "${#ALIAS_NAMES[@]}" -eq 0 ]; then
warn "No aliases yet — add one first"
return 1
fi
local -a items=()
local i
for ((i = 0; i < ${#ALIAS_NAMES[@]}; i++)); do
items+=("${ALIAS_NAMES[$i]} → pos system bank run ${ALIAS_TARGETS[${ALIAS_NAMES[$i]}]:-}")
done
local choice
choice="$(menu_pick "Pick alias" "${items[@]}")" || return 1
echo "${ALIAS_NAMES[$((choice - 1))]}"
}
cmd_alias_remove() {
local alias_name="${1:-}"
if [ -z "$alias_name" ]; then
if [ -t 0 ]; then
alias_name="$(_pick_alias)" || return 0
else
err "Usage: pos system bank alias remove <alias_name>"
fi
fi
alias_block_require
local found=0 i
for ((i = 0; i < ${#ALIAS_NAMES[@]}; i++)); do
[[ "${ALIAS_NAMES[$i]}" == "$alias_name" ]] && found=1
done
[ "$found" -eq 1 ] || err "Alias '$alias_name' not found (see: pos system bank alias list)"
if [ -t 0 ]; then
confirm "Remove alias '$alias_name'?" n || { log "Cancelled"; return 0; }
fi
local -a keep=()
for ((i = 0; i < ${#ALIAS_NAMES[@]}; i++)); do
[[ "${ALIAS_NAMES[$i]}" != "$alias_name" ]] && keep+=("${ALIAS_NAMES[$i]}")
done
ALIAS_NAMES=("${keep[@]}")
unset "ALIAS_TARGETS[$alias_name]"
alias_block_write
ok "Removed alias '$alias_name' from $BASH_RC_FILE"
log "Run: source $BASH_RC_FILE (or open a new shell)"
}
alias_menu() {
while true; do
local choice
choice="$(menu_run "Bank Aliases" \
"List aliases" \
"Add alias" \
"Remove alias")" || return 0
case "$choice" in
1) cmd_alias_list ;;
2)
local name alias_name
name="$(_pick_command)" || return 0
alias_name="$(menu_ask_value "Alias name" "$name")" || return 0
[ -z "$alias_name" ] && alias_name="$name"
cmd_alias_add "$name" "$alias_name"
;;
3)
local alias_name
alias_name="$(_pick_alias)" || return 0
cmd_alias_remove "$alias_name"
;;
esac
done
}
# ── 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" \
"Manage aliases")" || return 0
case "$choice" in
1) cmd_list ;;
2) cmd_add ;;
3) cmd_run ;;
4) cmd_edit ;;
5) cmd_remove ;;
6) alias_menu ;;
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 "$@" ;;
alias) shift; cmd_alias "$@" ;;
"") usage ;;
*) err "Unknown subcommand: $1 (see --help)" ;;
esac
+2 -1
View File
@@ -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'
+21 -5
View File
@@ -11,10 +11,11 @@ _pos_flags[communication-telegram-sender]="--type --caption --parse-mode --no-pr
_pos_flags[docker-stack]="-a --all"
_pos_flags[docker-vbox]="--dir --gpu --device --port --cpus --memory --network"
_pos_flags[entertainment-send]="--print --markdown"
_pos_flags[media-grab]="--audio --video --best --worst --output --no-playlist --cookies --dry-run"
_pos_flags[media-mp3]="--output --no-playlist --cookies --by-artist --dry-run"
_pos_flags[media-mp4]="--format --best --worst --output --no-playlist --cookies --dry-run"
_pos_flags[media-sync]="--mp3 --mp4 --source --dry-run"
_pos_flags[media-yt-grab]="--audio --video --best --worst --output --no-playlist --cookies --dry-run"
_pos_flags[media-yt-mp3]="--output --no-playlist --cookies --by-artist --dry-run"
_pos_flags[media-yt-mp4]="--format --best --worst --output --no-playlist --cookies --dry-run"
_pos_flags[media-yt-subtitles]="--lang --format --auto-only --output --list-subs --no-playlist --dry-run"
_pos_flags[media-ytsync]="--dry-run"
_pos_flags[network-checkport]="--tcp --udp --ping --no-banner --versions --timeout"
_pos_flags[network-download]="--dir --out --split --seed --force --upload --gid --tmux"
@@ -42,12 +43,14 @@ _pos_subcmds[docker-compose]="ls installed up down restart logs update config me
_pos_subcmds[docker-vbox]="create enter stop start rm ls menu"
_pos_subcmds[media-sync]="menu"
_pos_subcmds[media-ytsync]="add sync list remove"
_pos_subcmds[media-yt]="mp3 mp4 grab ytsync subtitles"
_pos_subcmds[network-download]="start stop status add torrent metalink list info files peers pause resume remove purge move limit set watch restart retry replace menu"
_pos_subcmds[share-nfs-client]="mount unmount list persist unpersist menu"
_pos_subcmds[share-nfs-server]="status share unshare list reload enable disable menu"
_pos_subcmds[share-smb-client]="mount unmount list persist unpersist menu"
_pos_subcmds[share-smb-server]="status share unshare list adduser deluser reload enable disable menu"
_pos_subcmds[system-backup]="menu"
_pos_subcmds[system-bank]="list add show run edit remove alias"
_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
@@ -111,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() {
@@ -227,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)
+41 -1
View File
@@ -14,11 +14,26 @@ fi
source "$(dirname "$0")/lib/common.sh"
source "$(dirname "$0")/lib/flags.sh"
# Return the current install.sh version (e.g. "0.0c174").
# Derived from git commit count. Empty when .git is absent.
# Override with INSTALL_VERSION_OVERRIDE for testing.
install_version() {
if [ "${INSTALL_VERSION_OVERRIDE+x}" ]; then
printf '%s' "$INSTALL_VERSION_OVERRIDE"
return
fi
local repo_dir count
repo_dir="$(cd "$(dirname "$0")" && pwd)"
count="$(git -C "$repo_dir" rev-list --count HEAD 2>/dev/null)" || { printf ''; return; }
printf '0.0c%s' "$count"
}
# Exported so child phases (preinstall.sh, postinstall.sh) inherit it —
# otherwise '--dry-run' silently executes them for real.
export DRY_RUN=0
RUN_APPS=0
RUN_FEATURES=0
FORCE=0
SKIP_PHASES=""
STEPS_SPEC=""
@@ -58,6 +73,7 @@ Options:
--full Core install + all optional apps (non-interactive)
--feature Install features/ scripts (prompts before overwriting)
--dry-run Show what would be done without executing
--force Re-install even if the version matches
--skip <phase> Skip a phase (repeatable):
preinstall, scripts, postinstall, scalepoint, apps
--steps <spec> Run only specific phases. Format: 1,3,4 or 1-3
@@ -79,6 +95,7 @@ while [[ $# -gt 0 ]]; do
--full) RUN_APPS=2; shift ;;
--feature) RUN_FEATURES=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--force) FORCE=1; shift ;;
--skip)
[ -z "${2:-}" ] && err "Missing value for --skip"
SKIP_PHASES="${SKIP_PHASES:+$SKIP_PHASES,}$2"
@@ -95,6 +112,24 @@ while [[ $# -gt 0 ]]; do
esac
done
# ── Version gate ────────────────────────────────────────────────
CURRENT_VERSION="$(install_version)"
if [ "${FORCE:-0}" -ne 1 ] && [ -n "$CURRENT_VERSION" ]; then
INSTALLED_VERSION="$(flag_value installed_version 2>/dev/null)" || true
if [ -n "$INSTALLED_VERSION" ]; then
current_num="${CURRENT_VERSION#0.0c}"
installed_num="${INSTALLED_VERSION#0.0c}"
if [ "$current_num" -eq "$installed_num" ] 2>/dev/null; then
if [ "${DRY_RUN:-0}" -eq 1 ]; then
log "(dry-run) Would skip install: already at version $CURRENT_VERSION"
else
log "Already installed ($CURRENT_VERSION). Use --force to re-install."
fi
exit 0
fi
fi
fi
# ── Phase runner ────────────────────────────────────────────────
# Phase names → numbers: preinstall=1 scripts=2 postinstall=3 scalepoint=4
should_run() {
@@ -140,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 "
@@ -248,6 +283,11 @@ echo "${GREEN}══════════════════════
echo "${GREEN} Bootstrap complete ($(timer_stop))${RESET}"
echo "${GREEN}════════════════════════════════════════════${RESET}"
# ── Record installed version ────────────────────────────────────
if [ "${DRY_RUN:-0}" -ne 1 ] && [ -n "${CURRENT_VERSION:-}" ]; then
flag_set installed_version "$CURRENT_VERSION"
fi
# ── Optional apps ──────────────────────────────────────────────
if [ "$RUN_APPS" -eq 1 ]; then
echo
+183
View File
@@ -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"
}
+50
View File
@@ -0,0 +1,50 @@
# lib/yt-lib.sh — shared helpers for yt-dlp-based media tools.
# Sourced opt-in by yt-* subcommand files. Uses err() from lib/common.sh.
# yt_check_deps [dry_run] [ffmpeg_purpose]
# Check yt-dlp (+ ffmpeg unless dry_run=1 and caller wants dry-run to skip deps).
# ffmpeg_purpose is the parenthetical in the error message (default: "MP3 conversion").
# ytsync overrides this deliberately (needs yt-dlp+jq even in dry-run).
yt_check_deps() {
local dry="${1:-0}" purpose="${2:-MP3 conversion}"
command -v yt-dlp &>/dev/null || err "yt-dlp not found — install it with: sudo apt install yt-dlp"
if [ "$dry" -eq 0 ]; then
command -v ffmpeg &>/dev/null || err "ffmpeg not found (needed for $purpose) — install it with: sudo apt install ffmpeg"
fi
}
# yt_validate_url <url>
# Returns 1 (does NOT exit) when URL doesn't start with http:// or https://;
# prints the diagnostic to stderr. Callers surface the error themselves (e.g.
# `yt_validate_url "$url" 2>/dev/null || err "…"` with their own prefix) so a
# helper whose only job is to check can never kill the caller's error path.
yt_validate_url() {
local url="$1"
case "$url" in
http://*|https://*) return 0 ;;
*) echo "not a valid URL: $url (must start with http:// or https://)" >&2; return 1 ;;
esac
}
# yt_echo_cmd <args...>
# Prints the yt-dlp command for --dry-run mode
yt_echo_cmd() {
echo "yt-dlp $*"
}
# classify_url <url>
# Domain classification: music.youtube/soundcloud/bandcamp → audio;
# youtube/youtu.be/vimeo/twitch → video; unknown → ${GRAB_DEFAULT:-video}
# Migrated from bin/pos-media-grab
classify_url() {
local url="$1" mode="${GRAB_DEFAULT:-video}"
case "$url" in
*music.youtube.com*) echo "audio" ;;
*soundcloud.com*) echo "audio" ;;
*bandcamp.com*) echo "audio" ;;
*youtube.com*|*youtu.be*) echo "video" ;;
*vimeo.com*) echo "video" ;;
*twitch.tv*) echo "video" ;;
*) echo "$mode" ;;
esac
}
@@ -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.
+5
View File
@@ -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"
+6 -1
View File
@@ -53,4 +53,9 @@ silently.
| `t-uninstall-manifest.sh` | install.sh ↔ POS_LIBS symmetry, user-unit discovery, marker-driven plugin removal |
| `t-gen-docs-drift.sh` | `make gen` idempotence on a pristine tracked tree (CI drift gate) |
| `t-lint-gate.sh` | `make lint` green on the real tree; planted violations are caught and named |
| `t-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-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-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-telegram-listener-reap.sh` | Telegram listener crash-loop regression: non-zero (254) child exit no longer kills the daemon, reply carries the real exit code + output, negative control proves the old `wait`-under-`set -e` idiom dies, getUpdates offset persists across restarts (resume, invalid-state fallback, empty-batch no-write) |
| `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) + `alias` subcommand (managed `~/.bashrc` block, exact line format, idempotent create, retarget, list, remove, block cleaned when empty, outer collision refused w/ file untouched, PATH-shadow warn, invalid alias name, unknown command, `bank remove` drops aliases, unrelated bashrc content preserved byte-identically, malformed block, empty-block message) |
+13
View File
@@ -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"
+653
View File
@@ -0,0 +1,653 @@
#!/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"
# ═══════════════════════════════════════════════════════════════
# Part B2: pos system bank alias — managed ~/.bashrc block
# ═══════════════════════════════════════════════════════════════
local A_START='# >>> pos bank aliases (managed by pos system bank — do not hand-edit) <<<'
local A_END='# <<< pos bank aliases (managed by pos system bank) <<<'
# B17: alias create writes the managed block with the exact line format
: > "$sandbox/b17.bank.env"
: > "$sandbox/b17.bashrc"
test_run env BANK_FILE="$sandbox/b17.bank.env" BASH_RC_FILE="$sandbox/b17.bashrc" "$pos_bank" add "backup" "Backup" "rsync -a src/ dst/"
check_rc "pos system bank add for alias exits 0" 0 "$TR_RC"
test_run env BANK_FILE="$sandbox/b17.bank.env" BASH_RC_FILE="$sandbox/b17.bashrc" "$pos_bank" alias "backup" "bk"
check_rc "pos system bank alias create exits 0" 0 "$TR_RC"
check_contains "pos system bank alias create confirms" "Alias 'bk'" "$TR_OUT"
check_contains "pos system bank alias create hints source" "source $sandbox/b17.bashrc" "$TR_OUT"
if grep -qF "alias bk='pos system bank run backup'" "$sandbox/b17.bashrc" \
&& grep -qF "$A_START" "$sandbox/b17.bashrc" \
&& grep -qF "$A_END" "$sandbox/b17.bashrc"; then
printf ' PASS alias create writes managed block with exact line\n'
else
printf ' FAIL alias create writes managed block with exact line\n'
fi
# B18: alias create is idempotent — same alias name stays a single line
test_run env BANK_FILE="$sandbox/b17.bank.env" BASH_RC_FILE="$sandbox/b17.bashrc" "$pos_bank" alias "backup" "bk"
check_rc "pos system bank alias create idempotent exits 0" 0 "$TR_RC"
local cnt18
cnt18="$(grep -c '^alias bk=' "$sandbox/b17.bashrc" || true)"
[ "$cnt18" -eq 1 ] && printf ' PASS alias create stays a single line when repeated\n' \
|| printf ' FAIL alias create stays a single line when repeated (count=%s)\n' "$cnt18"
# B19: alias list — shows the header and the alias row
test_run env BANK_FILE="$sandbox/b17.bank.env" BASH_RC_FILE="$sandbox/b17.bashrc" "$pos_bank" alias list
check_rc "pos system bank alias list exits 0" 0 "$TR_RC"
check_contains "pos system bank alias list shows header" "BANK ALIASES" "$TR_OUT"
check_contains "pos system bank alias list shows bk row" "bk" "$TR_OUT"
check_contains "pos system bank alias list shows target" "pos system bank run backup" "$TR_OUT"
# B20: alias list without a block errors
: > "$sandbox/b20.bashrc"
test_run env BANK_FILE="$sandbox/b17.bank.env" BASH_RC_FILE="$sandbox/b20.bashrc" "$pos_bank" alias list
check_not_contains "pos system bank alias list no-block exits non-zero" "0" "$TR_RC"
check_contains "pos system bank alias list no-block message" "No alias block" "$TR_OUT"
# B21: alias for an unknown bank command errors
test_run env BANK_FILE="$sandbox/b17.bank.env" BASH_RC_FILE="$sandbox/b17.bashrc" "$pos_bank" alias "ghost"
check_not_contains "pos system bank alias unknown bank exits non-zero" "0" "$TR_RC"
check_contains "pos system bank alias unknown bank message" "Command not found" "$TR_OUT"
# B22: invalid alias name errors
test_run env BANK_FILE="$sandbox/b17.bank.env" BASH_RC_FILE="$sandbox/b17.bashrc" "$pos_bank" alias "backup" "1bad"
check_not_contains "pos system bank alias invalid name exits non-zero" "0" "$TR_RC"
check_contains "pos system bank alias invalid name message" "Invalid alias name" "$TR_OUT"
# B23: PATH-shadowed alias warns (non-blocking)
: > "$sandbox/b23.bank.env"
: > "$sandbox/b23.bashrc"
test_run env BANK_FILE="$sandbox/b23.bank.env" BASH_RC_FILE="$sandbox/b23.bashrc" "$pos_bank" add "cat" "Cat" "cat file"
check_rc "pos system bank add path-shadow cmd exits 0" 0 "$TR_RC"
test_run env BANK_FILE="$sandbox/b23.bank.env" BASH_RC_FILE="$sandbox/b23.bashrc" "$pos_bank" alias "cat"
check_rc "pos system bank alias path-shadow still exits 0" 0 "$TR_RC"
check_contains "pos system bank alias path-shadow warns" "also a command on PATH" "$TR_OUT"
# B24: alias colliding with an outer (unmanaged) alias is refused, file untouched
: > "$sandbox/b24.bank.env"
printf '%s\n' "alias ls='ls --color=auto'" "# my config" > "$sandbox/b24.bashrc"
test_run env BANK_FILE="$sandbox/b24.bank.env" BASH_RC_FILE="$sandbox/b24.bashrc" "$pos_bank" add "ls" "Ls" "ls -la"
check_rc "pos system bank add for collision exits 0" 0 "$TR_RC"
test_run env BANK_FILE="$sandbox/b24.bank.env" BASH_RC_FILE="$sandbox/b24.bashrc" "$pos_bank" alias "ls"
check_not_contains "pos system bank alias collision exits non-zero" "0" "$TR_RC"
check_contains "pos system bank alias collision message" "already defined outside the managed block" "$TR_OUT"
if grep -qF "alias ls='ls --color=auto'" "$sandbox/b24.bashrc" \
&& ! grep -qF "$A_START" "$sandbox/b24.bashrc"; then
printf ' PASS alias collision leaves bashrc untouched\n'
else
printf ' FAIL alias collision leaves bashrc untouched\n'
fi
# B25: retarget + multiple aliases — alias name is the key; alias <name> [alias_name]
: > "$sandbox/b25.bank.env"
: > "$sandbox/b25.bashrc"
test_run env BANK_FILE="$sandbox/b25.bank.env" BASH_RC_FILE="$sandbox/b25.bashrc" "$pos_bank" add "backup" "Backup" "rsync"
test_run env BANK_FILE="$sandbox/b25.bank.env" BASH_RC_FILE="$sandbox/b25.bashrc" "$pos_bank" add "df-x" "Df" "df -h"
test_run env BANK_FILE="$sandbox/b25.bank.env" BASH_RC_FILE="$sandbox/b25.bashrc" "$pos_bank" alias "backup" "bk"
check_rc "pos system bank alias first exits 0" 0 "$TR_RC"
# retarget: same alias name bk now points at df-x via a different bank name
test_run env BANK_FILE="$sandbox/b25.bank.env" BASH_RC_FILE="$sandbox/b25.bashrc" "$pos_bank" alias "df-x" "bk"
check_rc "pos system bank alias retarget exits 0" 0 "$TR_RC"
if grep -qF "alias bk='pos system bank run df-x'" "$sandbox/b25.bashrc" \
&& ! grep -qF "alias bk='pos system bank run backup'" "$sandbox/b25.bashrc"; then
printf ' PASS alias with same alias name retargets the existing line\n'
else
printf ' FAIL alias with same alias name retargets the existing line\n'
fi
# a second distinct alias for the same bank command is allowed
test_run env BANK_FILE="$sandbox/b25.bank.env" BASH_RC_FILE="$sandbox/b25.bashrc" "$pos_bank" alias "backup" "bku"
check_rc "pos system bank alias second name exits 0" 0 "$TR_RC"
local cnt25
cnt25="$(grep -c '^alias ' "$sandbox/b25.bashrc" || true)"
[ "$cnt25" -eq 2 ] && printf ' PASS two aliases coexist (2 lines)\n' \
|| printf ' FAIL two aliases coexist (lines=%s)\n' "$cnt25"
# B26: alias remove keeps the block when other aliases remain
test_run env BANK_FILE="$sandbox/b25.bank.env" BASH_RC_FILE="$sandbox/b25.bashrc" "$pos_bank" alias remove "bk"
check_rc "pos system bank alias remove exits 0" 0 "$TR_RC"
check_contains "pos system bank alias remove confirms" "Removed alias 'bk'" "$TR_OUT"
if grep -qF "$A_START" "$sandbox/b25.bashrc" && grep -qF "$A_END" "$sandbox/b25.bashrc" \
&& ! grep -qF "alias bk=" "$sandbox/b25.bashrc" \
&& grep -qF "alias bku=" "$sandbox/b25.bashrc"; then
printf ' PASS alias remove keeps block with remaining aliases\n'
else
printf ' FAIL alias remove keeps block with remaining aliases\n'
fi
# B27: removing the last alias cleans the block from the file entirely
: > "$sandbox/b27.bank.env"
: > "$sandbox/b27.bashrc"
test_run env BANK_FILE="$sandbox/b27.bank.env" BASH_RC_FILE="$sandbox/b27.bashrc" "$pos_bank" add "deploy" "Deploy" "deploy.sh"
test_run env BANK_FILE="$sandbox/b27.bank.env" BASH_RC_FILE="$sandbox/b27.bashrc" "$pos_bank" alias "deploy" "d"
test_run env BANK_FILE="$sandbox/b27.bank.env" BASH_RC_FILE="$sandbox/b27.bashrc" "$pos_bank" alias remove "d"
check_rc "pos system bank alias remove last exits 0" 0 "$TR_RC"
if ! grep -qF "$A_START" "$sandbox/b27.bashrc" && [ ! -s "$sandbox/b27.bashrc" ]; then
printf ' PASS removing last alias cleans block and empties file\n'
else
printf ' FAIL removing last alias cleans block and empties file\n'
fi
# B28: removing a missing alias errors (block present)
: > "$sandbox/b28.bank.env"
: > "$sandbox/b28.bashrc"
test_run env BANK_FILE="$sandbox/b28.bank.env" BASH_RC_FILE="$sandbox/b28.bashrc" "$pos_bank" add "deploy" "Deploy" "deploy.sh"
test_run env BANK_FILE="$sandbox/b28.bank.env" BASH_RC_FILE="$sandbox/b28.bashrc" "$pos_bank" alias "deploy" "d"
test_run env BANK_FILE="$sandbox/b28.bank.env" BASH_RC_FILE="$sandbox/b28.bashrc" "$pos_bank" alias remove "nope"
check_not_contains "pos system bank alias remove missing exits non-zero" "0" "$TR_RC"
check_contains "pos system bank alias remove missing message" "not found" "$TR_OUT"
# B29: bank remove also drops aliases pointing at the removed command
: > "$sandbox/b29.bank.env"
: > "$sandbox/b29.bashrc"
test_run env BANK_FILE="$sandbox/b29.bank.env" BASH_RC_FILE="$sandbox/b29.bashrc" "$pos_bank" add "foo" "Foo" "echo foo"
test_run env BANK_FILE="$sandbox/b29.bank.env" BASH_RC_FILE="$sandbox/b29.bashrc" "$pos_bank" alias "foo" "f"
test_run env BANK_FILE="$sandbox/b29.bank.env" BASH_RC_FILE="$sandbox/b29.bashrc" "$pos_bank" remove "foo"
check_rc "pos system bank remove with alias exits 0" 0 "$TR_RC"
check_contains "pos system bank remove mentions alias cleanup" "Removed alias pointing to 'foo'" "$TR_OUT"
check_contains "pos system bank remove confirms" "Removed: foo" "$TR_OUT"
if ! grep -qF "alias f=" "$sandbox/b29.bashrc" && ! grep -qF "$A_START" "$sandbox/b29.bashrc"; then
printf ' PASS bank remove drops alias and cleans block\n'
else
printf ' FAIL bank remove drops alias and cleans block\n'
fi
# B30: unrelated bashrc content survives an alias add+remove round-trip byte-identically
: > "$sandbox/b30.bank.env"
printf '%s\n' "# user config" "export EDITOR=vim" "PATH=/custom:\$PATH" > "$sandbox/b30.orig"
cp "$sandbox/b30.orig" "$sandbox/b30.bashrc"
test_run env BANK_FILE="$sandbox/b30.bank.env" BASH_RC_FILE="$sandbox/b30.bashrc" "$pos_bank" add "tool" "Tool" "tool-cmd"
test_run env BANK_FILE="$sandbox/b30.bank.env" BASH_RC_FILE="$sandbox/b30.bashrc" "$pos_bank" alias "tool" "tl"
check_rc "pos system bank alias on custom bashrc exits 0" 0 "$TR_RC"
test_run env BANK_FILE="$sandbox/b30.bank.env" BASH_RC_FILE="$sandbox/b30.bashrc" "$pos_bank" alias remove "tl"
check_rc "pos system bank alias remove on custom bashrc exits 0" 0 "$TR_RC"
if cmp -s "$sandbox/b30.orig" "$sandbox/b30.bashrc"; then
printf ' PASS unrelated bashrc content preserved byte-identically\n'
else
printf ' FAIL unrelated bashrc content preserved byte-identically\n'
fi
# B31: malformed block (start marker without end) errors instead of rewriting
printf '%s\n' "$A_START" "alias broken='pos system bank run junk'" > "$sandbox/b31.bashrc"
: > "$sandbox/b31.bank.env"
test_run env BANK_FILE="$sandbox/b31.bank.env" BASH_RC_FILE="$sandbox/b31.bashrc" "$pos_bank" alias list
check_not_contains "pos system bank alias malformed block exits non-zero" "0" "$TR_RC"
check_contains "pos system bank alias malformed block message" "malformed" "$TR_OUT"
# B32: empty block lists cleanly
printf '%s\n' "$A_START" "$A_END" > "$sandbox/b32.bashrc"
: > "$sandbox/b32.bank.env"
test_run env BANK_FILE="$sandbox/b32.bank.env" BASH_RC_FILE="$sandbox/b32.bashrc" "$pos_bank" alias list
check_rc "pos system bank alias empty block exits 0" 0 "$TR_RC"
check_contains "pos system bank alias empty block message" "No aliases" "$TR_OUT"
# B33: alias remove without a name on a non-tty errors with usage
test_run env BANK_FILE="$sandbox/b27.bank.env" BASH_RC_FILE="$sandbox/b27.bashrc" "$pos_bank" alias remove
check_not_contains "pos system bank alias remove no-arg exits non-zero" "0" "$TR_RC"
check_contains "pos system bank alias remove no-arg usage" "pos system bank alias remove" "$TR_OUT"
}
+1 -1
View File
@@ -201,7 +201,7 @@ STUB
for tool in pos-ai pos-ai-hf pos-ai-server pos-communication-matrix-listener \
pos-communication-matrix-sender pos-communication-scrcpy \
pos-communication-telegram-listener pos-communication-telegram-sender \
pos-media-grab pos-network-download; do
pos-media-yt-grab pos-network-download; do
if grep -q "load_env_file" "$ROOT/bin/$tool"; then
printf ' PASS %s uses shared load_env_file\n' "$tool"
else
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env bash
set -euo pipefail
# t-install-version.sh — install.sh version gate contract:
# match→skip with "Already installed", mismatch→proceed, --force bypass,
# no-git (empty) skip, dry-run variant, flag write after run,
# numeric comparison correctness (0.0c10 > 0.0c9).
#
# Seam: INSTALL_VERSION_OVERRIDE env var (presence check → empty = no-git path),
# FLAGS_DIR env var (existing flag store seam).
#
# Proceed-path cases (2/3/4/5/8) use --skip all phases instead of --dry-run
# because postinstall.sh is not fully dry-run safe (cat on non-existent file
# after dry-run copy). Skipping all phases is functionally equivalent — the
# gate fires before phases, and the banner + flag write still execute.
run_test() {
local sandbox stubs flags_dir install_sh
sandbox="$(mksandbox install-version)"
stubs="$sandbox/stubs"
flags_dir="$sandbox/flags"
mkdir -p "$stubs" "$flags_dir"
install_sh="$ROOT/install.sh"
# sudo stub: just exec its arguments (bypasses real sudo in sandbox)
cat > "$stubs/sudo" <<'STUB'
#!/usr/bin/env bash
exec "$@"
STUB
chmod +x "$stubs/sudo"
# install stub: delegate to the real install binary (needed so -d/-m flags
# work; we only shadow 'sudo' to avoid needing a tty/password)
cat > "$stubs/install" <<'STUB'
#!/usr/bin/env bash
exec /usr/bin/install "$@"
STUB
chmod +x "$stubs/install"
# mkdir stub: just exec its arguments
cat > "$stubs/mkdir" <<'STUB'
#!/usr/bin/env bash
exec "$@"
STUB
chmod +x "$stubs/mkdir"
local base_env=(PATH="$stubs:/usr/bin:/bin"
HOME="$sandbox/home"
FLAGS_DIR="$flags_dir")
# Helper: run install.sh with env overrides and capture output+rc.
run_install() {
local envs=() args=()
while [ $# -gt 0 ]; do
if [ "$1" = "--" ]; then shift; break; fi
envs+=("$1"); shift
done
args=("$@")
set +e
TR_OUT="$(env "${base_env[@]}" "${envs[@]}" bash "$install_sh" "${args[@]}" 2>&1)"
TR_RC=$?
set -e
}
# Helper: set a flag value directly (for pre-seeding installed_version)
set_flag() {
local name="$1" value="$2"
mkdir -p "$flags_dir"
printf '%s' "$value" > "$flags_dir/$name"
}
# Helper: read a flag value
get_flag() {
local name="$1"
local f="$flags_dir/$name"
[ -f "$f" ] && cat "$f" || printf ''
}
# ═══════════════════════════════════════════════════════════════
# Case 1: Match → skip ("Already installed")
# Gate exits BEFORE any phase — safe without --dry-run or --skip.
# ═══════════════════════════════════════════════════════════════
set_flag "installed_version" "0.0c10"
run_install INSTALL_VERSION_OVERRIDE=0.0c10
check_rc "C1 match→skip exit code" 0 "$TR_RC"
check_contains "C1 match→skip message" "Already installed (0.0c10)" "$TR_OUT"
# ═══════════════════════════════════════════════════════════════
# Case 2: Mismatch → proceed (gate doesn't match, phases skipped)
# ═══════════════════════════════════════════════════════════════
rm -f "$flags_dir/installed_version"
set_flag "installed_version" "0.0c10"
run_install INSTALL_VERSION_OVERRIDE=0.0c11 -- --skip preinstall,scripts,postinstall,scalepoint
check_rc "C2 mismatch→proceed exit code" 0 "$TR_RC"
check_not_contains "C2 mismatch does not skip" "Already installed" "$TR_OUT"
# ═══════════════════════════════════════════════════════════════
# Case 3: Force bypasses match
# ═══════════════════════════════════════════════════════════════
rm -f "$flags_dir/installed_version"
set_flag "installed_version" "0.0c10"
run_install INSTALL_VERSION_OVERRIDE=0.0c10 -- --skip preinstall,scripts,postinstall,scalepoint --force
check_rc "C3 force bypasses exit code" 0 "$TR_RC"
check_not_contains "C3 force bypasses skip" "Already installed" "$TR_OUT"
# ═══════════════════════════════════════════════════════════════
# Case 4: No git (empty version via presence check)
# INSTALL_VERSION_OVERRIDE="" with ${var+x} → empty → gate skipped
# ═══════════════════════════════════════════════════════════════
rm -f "$flags_dir/installed_version"
set_flag "installed_version" "0.0c10"
run_install INSTALL_VERSION_OVERRIDE="" -- --skip preinstall,scripts,postinstall,scalepoint
check_rc "C4 empty override→proceeds exit code" 0 "$TR_RC"
check_not_contains "C4 empty override does not skip" "Already installed" "$TR_OUT"
# ═══════════════════════════════════════════════════════════════
# Case 5: No installed flag → gate doesn't block
# ═══════════════════════════════════════════════════════════════
rm -f "$flags_dir/installed_version"
run_install INSTALL_VERSION_OVERRIDE=0.0c10 -- --skip preinstall,scripts,postinstall,scalepoint
check_rc "C5 no flag→proceeds exit code" 0 "$TR_RC"
check_not_contains "C5 no flag does not skip" "Already installed" "$TR_OUT"
# ═══════════════════════════════════════════════════════════════
# Case 6: Dry-run + match → "Would skip" (not "Already installed")
# ═══════════════════════════════════════════════════════════════
rm -f "$flags_dir/installed_version"
set_flag "installed_version" "0.0c10"
run_install INSTALL_VERSION_OVERRIDE=0.0c10 -- --dry-run
check_rc "C6 dry-run match exit code" 0 "$TR_RC"
check_contains "C6 dry-run shows Would skip" "Would skip install" "$TR_OUT"
check_not_contains "C6 dry-run not Already installed" "Already installed" "$TR_OUT"
# ═══════════════════════════════════════════════════════════════
# Case 7: Flag written after successful run
# All phases skipped; sudo/install/mkdir stubs on PATH so
# flag_set's `run sudo install` actually creates the flag file.
# ═══════════════════════════════════════════════════════════════
rm -f "$flags_dir/installed_version"
set_flag "installed_version" "0.0c10"
run_install INSTALL_VERSION_OVERRIDE=0.0c11 -- --skip preinstall,scripts,postinstall,scalepoint
check_rc "C7 all-skip exit code" 0 "$TR_RC"
local flag_val
flag_val="$(get_flag installed_version)"
check_eq "C7 flag written with current version" "0.0c11" "$flag_val"
# ═══════════════════════════════════════════════════════════════
# Case 8: Numeric comparison — 0.0c10 > 0.0c9 (not blocked)
# ═══════════════════════════════════════════════════════════════
rm -f "$flags_dir/installed_version"
set_flag "installed_version" "0.0c9"
run_install INSTALL_VERSION_OVERRIDE=0.0c10 -- --skip preinstall,scripts,postinstall,scalepoint
check_rc "C8 numeric 10>9 proceeds exit code" 0 "$TR_RC"
check_not_contains "C8 numeric 10>9 does not skip" "Already installed" "$TR_OUT"
# Also test: 0.0c9 with installed 0.0c10 → NOT a match
rm -f "$flags_dir/installed_version"
set_flag "installed_version" "0.0c10"
run_install INSTALL_VERSION_OVERRIDE=0.0c9 -- --skip preinstall,scripts,postinstall,scalepoint
check_rc "C8 numeric 9≠10 proceeds exit code" 0 "$TR_RC"
check_not_contains "C8 numeric 9≠10 does not skip" "Already installed" "$TR_OUT"
# ═══════════════════════════════════════════════════════════════
# Case 9: Force + match → still writes flag
# ═══════════════════════════════════════════════════════════════
rm -f "$flags_dir/installed_version"
set_flag "installed_version" "0.0c10"
run_install INSTALL_VERSION_OVERRIDE=0.0c10 -- --skip preinstall,scripts,postinstall,scalepoint --force
check_rc "C9 force+match exit code" 0 "$TR_RC"
local flag_val9
flag_val9="$(get_flag installed_version)"
check_eq "C9 force writes flag" "0.0c10" "$flag_val9"
}
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env bash
set -euo pipefail
# t-pos-media-yt.sh — POS--9 verification for the unified `pos media yt`
# suite and its forwarders.
#
# Part 1: dispatcher + forwarder resolution (bin/pos-media-yt, pos-media-mp3/
# mp4/grab forwarders, full chain through bin/pos)
# Part 2: lib/yt-lib.sh shared helpers (deps, url validation, classify)
# Part 3: yt-mp3 behaviors (flags, dry-run, YT_OUT_DIR seam, unsafe-URL guard)
# Part 4: yt-mp4 behaviors (format select flags, mutual exclusions, seam)
# Part 5: yt-grab classification + delegation (dry-run command strings) + config
# Part 6: yt-subtitles (defaults, --lang en,ar single-arg, formats),
# txt conversion (timestamps stripped), unavailable-subs detection
# Part 7: negative controls mandated by the Architect contract
run_test() {
local sandbox stubs cfg
sandbox="$(mksandbox media-yt)"
stubs="$sandbox/stubs"
cfg="$sandbox/cfg"
mkdir -p "$stubs" "$cfg"
local ytdlp_log="$sandbox/ytdlp.log"
: > "$ytdlp_log"
# fake yt-dlp logs its exact argv (one per line) — lets us assert arg
# counting / no-expansion WITHOUT any network or real binary.
cat > "$stubs/yt-dlp" <<STUB
#!/usr/bin/env bash
printf 'yt-dlp %s\\n' "\$*" >> "$ytdlp_log"
STUB
# ffmpeg stub: satisfies the non-dry-run deps guard; not actually used.
printf '#!/usr/bin/env bash\nexit 0\n' > "$stubs/ffmpeg"
chmod +x "$stubs/yt-dlp" "$stubs/ffmpeg"
local RB="$ROOT/bin"
local yt ytd="$RB/pos-media-yt" mp3="$RB/pos-media-yt-mp3" \
mp4="$RB/pos-media-yt-mp4" grab="$RB/pos-media-yt-grab" \
subs="$RB/pos-media-yt-subtitles"
local env_base=(PATH="$stubs:$RB:/usr/bin:/bin" CONFIG_DIR="$cfg" \
YT_OUT_DIR="$sandbox/out")
# ═══ Part 1: dispatcher + forwarders ═══
# 1.0 bare dispatcher → usage listing subcommands (exit 0)
test_run_env "${env_base[@]}" -- "$ytd"
check_rc "dispatcher bare invocation = usage exit 0" 0 "$TR_RC"
check_contains "dispatcher usage lists mp3" "mp3" "$TR_OUT"
check_contains "dispatcher usage lists subtitles" "subtitles" "$TR_OUT"
# 1.1 dispatcher unknown subcommand → hard error, nonzero
test_run_env "${env_base[@]}" -- "$ytd" bogus
[ "$TR_RC" -ne 0 ] && printf ' PASS dispatcher unknown subcommand exits nonzero\n' \
|| printf ' FAIL dispatcher unknown subcommand exited 0\n'
check_contains "dispatcher unknown subcommand message" "unknown yt command" "$TR_OUT"
# 1.2 forwarder helpers: --help passes through to the real yt usage
test_run_env "${env_base[@]}" -- "$RB/pos-media-mp3" --help
check_rc "pos media mp3 --help forwards cleanly" 0 "$TR_RC"
check_contains "mp3 forwarder shows yt-mp3 usage" "pos media yt mp3 [options]" "$TR_OUT"
test_run_env "${env_base[@]}" -- "$RB/pos-media-mp4" --help
check_rc "pos media mp4 --help forwards cleanly" 0 "$TR_RC"
check_contains "mp4 forwarder shows yt-mp4 usage" "pos media yt mp4 [options]" "$TR_OUT"
test_run_env "${env_base[@]}" -- "$RB/pos-media-grab" --help
check_rc "pos media grab --help forwards cleanly" 0 "$TR_RC"
check_contains "grab forwarder shows yt-grab usage" "pos media yt grab [options]" "$TR_OUT"
# 1.3 ytsync forwarder --help forwards to pos media ytsync
test_run_env "${env_base[@]}" -- "$RB/pos-media-yt-ytsync" --help
check_contains "ytsync forwarder references media ytsync" "media ytsync" "$TR_OUT"
# 1.4 full dispatch chain: `pos media mp3 <url>` → yt-mp3 (fake yt-dlp called).
# `media-yt-mp3` is non-interactive so bin/pos execs it without a tee.
: > "$ytdlp_log"
test_run_env "${env_base[@]}" -- "$RB/pos" media mp3 "http://example.com/x"
check_rc "chain pos media mp3 <url> resolves" 0 "$TR_RC"
check_contains "chain reaches yt-dlp with -x (audio path)" "-x" "$(cat "$ytdlp_log")"
# ═══ Part 2: lib/yt-lib.sh helpers ═══
source "$ROOT/lib/common.sh"
source "$ROOT/lib/yt-lib.sh"
# 2.0 yt_validate_url accepts http(s), rejects junk
yt_validate_url "https://youtube.com/watch?v=x" && printf ' PASS url valid http/https accepted\n' \
|| printf ' FAIL valid https url rejected\n'
test_run yt_validate_url "ftp://bad"
[ "$TR_RC" -ne 0 ] && printf ' PASS invalid url rejected\n' || printf ' FAIL invalid url accepted\n'
check_contains "invalid url error message" "not a valid URL" "$TR_OUT"
# 2.1 classify_url: audio / video / unknown-with-GRAB_DEFAULT
check_eq "music.youtube → audio" "audio" "$(classify_url "https://music.youtube.com/watch?v=x")"
check_eq "soundcloud → audio" "audio" "$(classify_url "https://soundcloud.com/a/b")"
check_eq "youtube.com → video" "video" "$(classify_url "https://youtube.com/watch?v=x")"
check_eq "vimeo → video" "video" "$(classify_url "https://vimeo.com/123")"
check_eq "unknown → default video" "video" "$(classify_url "https://example.com/x")"
check_eq "unknown → GRAB_DEFAULT=audio" "audio" "$(GRAB_DEFAULT=audio classify_url "https://example.com/x")"
# ═══ Part 3: yt-mp3 ═══
# 3.0 dry-run requires no deps and prints the yt-dlp command (audio flags)
test_run_env "${env_base[@]}" -- "$mp3" --dry-run "https://youtube.com/watch?v=dQw4w9WgXcQ"
check_rc "mp3 dry-run ok" 0 "$TR_RC"
check_contains "mp3 dry-run has -x --audio-format mp3" "--audio-format mp3" "$TR_OUT"
check_contains "mp3 dry-run has --embed-metadata" "--embed-metadata" "$TR_OUT"
# 3.1 --by-artist template
test_run_env "${env_base[@]}" -- "$mp3" --dry-run --by-artist "https://youtu.be/x"
check_contains "mp3 --by-artist organizes by artist/uploader" "%(artist,uploader)s/" "$TR_OUT"
# 3.2 --no-playlist propagated
test_run_env "${env_base[@]}" -- "$mp3" --dry-run --no-playlist "https://youtube.com/watch?v=x"
check_contains "mp3 --no-playlist passed through" "--no-playlist" "$TR_OUT"
# 3.3 --cookies must exist
test_run_env "${env_base[@]}" -- "$mp3" --dry-run --cookies "$sandbox/nope.txt" "https://youtube.com/watch?v=x"
[ "$TR_RC" -ne 0 ] && printf ' PASS mp3 missing cookies file errors\n' \
|| printf ' FAIL mp3 accepted missing cookies file\n'
# 3.4 YT_OUT_DIR seam overrides default output
test_run_env "${env_base[@]}" -- "$mp3" --dry-run "https://youtube.com/watch?v=x"
check_contains "mp3 uses YT_OUT_DIR seam" "$sandbox/out/%(title)s.%(ext)s" "$TR_OUT"
# 3.5 missing dep in non-dry-run → hard error (no yt-dlp on PATH at all)
test_run_env PATH="/usr/bin:/bin" -- "$mp3" "https://youtube.com/watch?v=x"
[ "$TR_RC" -ne 0 ] && printf ' PASS mp3 without yt-dlp in path errors\n' \
|| printf ' FAIL mp3 ran without yt-dlp present\n'
# 3.6 no URL → usage exit 0
test_run_env "${env_base[@]}" -- "$mp3" --dry-run
check_rc "mp3 no URL → usage exit 0" 0 "$TR_RC"
check_contains "mp3 no URL prints usage" "Usage: pos media yt mp3" "$TR_OUT"
# 3.7 NEGATIVE CONTROL: unsafe URL not expanded — passed as ONE literal arg.
# `$(echo pwned)` stays literal inside the URL token; no second pwned word
# appears in the argv log (which would mean command substitution ran).
: > "$ytdlp_log"
bad='$(echo pwned)'
test_run_env "${env_base[@]}" -- "$mp3" "http://example.com/$bad"
check_rc "unsafe URL run (fake yt-dlp) ok" 0 "$TR_RC"
local logged; logged="$(cat "$ytdlp_log")"
check_contains "url passed literally (single arg)" "\$(echo pwned)" "$logged"
check_not_contains "no separate pwned word from substitution" "example.com/ pwned" "$logged"
# ═══ Part 4: yt-mp4 ═══
# 4.0 --best
test_run_env "${env_base[@]}" -- "$mp4" --dry-run --best "https://youtube.com/watch?v=x"
check_contains "mp4 --best selects bestvideo+bestaudio" "bestvideo*+bestaudio/best" "$TR_OUT"
# 4.1 --worst
test_run_env "${env_base[@]}" -- "$mp4" --dry-run --worst "https://youtube.com/watch?v=x"
check_contains "mp4 --worst selects worst" "-f worst" "$TR_OUT"
# 4.2 -f id skips prompt
test_run_env "${env_base[@]}" -- "$mp4" --dry-run -f 22 "https://youtube.com/watch?v=x"
check_contains "mp4 -f 22 (no prompt)" "-f 22" "$TR_OUT"
# 4.3 mutual exclusions
test_run_env "${env_base[@]}" -- "$mp4" --dry-run --best --worst "https://youtube.com/watch?v=x"
[ "$TR_RC" -ne 0 ] && printf ' PASS mp4 --best/--worst mutually exclusive\n' \
|| printf ' FAIL mp4 allowed --best and --worst together\n'
test_run_env "${env_base[@]}" -- "$mp4" --dry-run -f 22 --best "https://youtube.com/watch?v=x"
[ "$TR_RC" -ne 0 ] && printf ' PASS mp4 --format/--best mutually exclusive\n' \
|| printf ' FAIL mp4 allowed --format and --best together\n'
# 4.4 YT_OUT_DIR seam
test_run_env "${env_base[@]}" -- "$mp4" --dry-run --best "https://youtube.com/watch?v=x"
check_contains "mp4 uses YT_OUT_DIR seam" "$sandbox/out/%(title)s.%(ext)s" "$TR_OUT"
# 4.5 no URL → usage
test_run_env "${env_base[@]}" -- "$mp4" --dry-run --best
check_rc "mp4 no URL → usage exit 0" 0 "$TR_RC"
check_contains "mp4 no URL prints usage" "Usage: pos media yt mp4" "$TR_OUT"
# ═══ Part 5: yt-grab classification + delegation (dry-run) + config ═══
# 5.0 music.youtube → audio route (dry-run prints delegate command, no exec)
test_run_env "${env_base[@]}" -- "$grab" --dry-run "https://music.youtube.com/watch?v=abc"
check_contains "grab music.youtube routes to yt mp3" "pos media yt mp3" "$TR_OUT"
check_not_contains "grab music.youtube not routed to mp4" "pos media yt mp4" "$TR_OUT"
# 5.1 youtube.com → video route (default --best)
test_run_env "${env_base[@]}" -- "$grab" --dry-run "https://youtube.com/watch?v=xyz"
check_contains "grab youtube routes to yt mp4" "pos media yt mp4 --best" "$TR_OUT"
# 5.2 --audio forces audio despite video domain
test_run_env "${env_base[@]}" -- "$grab" --dry-run --audio "https://youtube.com/watch?v=abc"
check_contains "grab --audio forces yt mp3" "pos media yt mp3" "$TR_OUT"
# 5.3 --worst → video --worst
test_run_env "${env_base[@]}" -- "$grab" --dry-run --worst "https://youtube.com/watch?v=abc"
check_contains "grab --worst routes to mp4 --worst" "pos media yt mp4 --worst" "$TR_OUT"
# 5.4 --audio + --video mutually exclusive
test_run_env "${env_base[@]}" -- "$grab" --dry-run --audio --video "https://youtube.com/watch?v=abc"
[ "$TR_RC" -ne 0 ] && printf ' PASS grab --audio/--video mutually exclusive\n' \
|| printf ' FAIL grab allowed --audio and --video together\n'
# 5.5 invalid URL
test_run_env "${env_base[@]}" -- "$grab" "not-a-url"
[ "$TR_RC" -ne 0 ] && printf ' PASS grab rejects invalid url\n' \
|| printf ' FAIL grab accepted invalid url\n'
check_contains "grab error prefixed yt-grab" "yt-grab:" "$TR_OUT"
# 5.6 unknown domain honors GRAB_DEFAULT via config file (audio)
printf 'GRAB_DEFAULT=audio\n' > "$cfg/grab.env"
test_run_env "${env_base[@]}" -- "$grab" --dry-run "https://example.com/x"
check_contains "grab unknown domain honors GRAB_DEFAULT=audio" "pos media yt mp3" "$TR_OUT"
# ═══ Part 6: yt-subtitles ═══
# 6.0 defaults: --write-subs --write-auto-subs --sub-langs best
test_run_env "${env_base[@]}" -- "$subs" --dry-run "https://youtube.com/watch?v=x"
check_contains "subs default writes manual subs" "--write-subs" "$TR_OUT"
check_contains "subs default writes auto subs" "--write-auto-subs" "$TR_OUT"
check_contains "subs default sub-langs best" "--sub-langs best" "$TR_OUT"
# 6.1 NEGATIVE CONTROL: --lang en,ar becomes ONE --sub-langs arg
test_run_env "${env_base[@]}" -- "$subs" --dry-run --lang en,ar "https://youtube.com/watch?v=x"
check_contains "subs --lang en,ar single arg" "--sub-langs en,ar" "$TR_OUT"
check_not_contains "subs does not split --lang (no single 'en')" "--sub-langs en " "$TR_OUT"
# 6.2 --format vtt
test_run_env "${env_base[@]}" -- "$subs" --dry-run --format vtt "https://youtube.com/watch?v=x"
check_contains "subs --format vtt" "--sub-format vtt" "$TR_OUT"
# 6.3 --auto-only drops --write-subs
test_run_env "${env_base[@]}" -- "$subs" --dry-run --auto-only "https://youtube.com/watch?v=x"
check_not_contains "subs --auto-only drops manual subs" "--write-subs" "$TR_OUT"
check_contains "subs --auto-only keeps auto subs" "--write-auto-subs" "$TR_OUT"
# 6.4 --output dir template
test_run_env "${env_base[@]}" -- "$subs" --dry-run --output "$sandbox/subs" "https://youtube.com/watch?v=x"
check_contains "subs --output template" "$sandbox/subs/%(title)s.%(sub_lang)s.%(ext)s" "$TR_OUT"
# 6.5 invalid --format
test_run_env "${env_base[@]}" -- "$subs" --dry-run --format bogus "https://youtube.com/watch?v=x"
[ "$TR_RC" -ne 0 ] && printf ' PASS subs rejects unknown format\n' \
|| printf ' FAIL subs accepted unknown format\n'
# 6.6 unavailable subs: fake yt-dlp fails with no-subtitle stderr → friendly error
mkdir -p "$stubs/subsfail"
cat > "$stubs/subsfail/yt-dlp" <<STUB
#!/usr/bin/env bash
printf 'ERROR: no subtitles found' >&2
exit 1
STUB
chmod +x "$stubs/subsfail/yt-dlp"
test_run_env PATH="$stubs/subsfail:/usr/bin:/bin" -- "$subs" "https://youtube.com/watch?v=x"
[ "$TR_RC" -ne 0 ] && printf ' PASS subs no-subtitles exits nonzero\n' \
|| printf ' FAIL subs no-subtitles exited 0\n'
check_contains "subs unavailable-subs message" "unavailable subtitles" "$TR_OUT"
# 6.7 NEGATIVE CONTROL: txt conversion strips SRT timestamps — run the real
# tool non-dry with a fake yt-dlp that writes an .srt into the cwd; the txt
# converter must strip timestamps/html and keep the words.
local txtdir="$sandbox/txtdir"
mkdir -p "$txtdir" "$stubs/txtsubs"
cat > "$stubs/txtsubs/yt-dlp" <<STUB
#!/usr/bin/env bash
printf '1\\n00:00:01,000 --> 00:00:02,000\\nHello <i>world</i>\\n\\n2\\n00:00:03,000 --> 00:00:04,000\\nSecond line\\n' > foo.srt
exit 0
STUB
chmod +x "$stubs/txtsubs/yt-dlp"
(
cd "$txtdir"
env PATH="$stubs/txtsubs:/usr/bin:/bin" "$subs" --format txt "https://youtube.com/watch?v=x"
) >"$sandbox/txtrun.log" 2>&1
local txt; txt="$(cat "$txtdir/foo.txt" 2>/dev/null || true)"
check_not_contains "txt output strips timestamps" "-->" "$txt"
check_not_contains "txt output strips html tags" "<i>" "$txt"
check_contains "txt output keeps words" "Hello world" "$txt"
# 6.8 subs dry-run skips deps (yt-dlp not in path) — needs no binaries
test_run_env PATH="/usr/bin:/bin" -- "$subs" --dry-run "https://youtube.com/watch?v=x"
check_rc "subs dry-run works without yt-dlp (skips deps)" 0 "$TR_RC"
check_contains "subs dry-run prints yt-dlp cmd" "yt-dlp" "$TR_OUT"
# 6.9 subs no URL → error (not usage), exit 1
test_run_env "${env_base[@]}" -- "$subs" --dry-run
[ "$TR_RC" -ne 0 ] && printf ' PASS subs no URL exits nonzero\n' \
|| printf ' FAIL subs no URL exited 0\n'
check_contains "subs no URL error message" "missing URL (see --help)" "$TR_OUT"
}
+133
View File
@@ -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
}
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env bash
set -euo pipefail
# t-telegram-listener-reap.sh — regression tests for the listener crash-loop:
# (a) a background command exiting non-zero (254) must NOT kill the daemon.
# The old code reaped via `wait "$pid"` under `set -e`, so any non-zero
# child exit aborted the listener and systemd Restart=always crash-looped
# it (offset reset to 0 → duplicate re-delivery). Reply must carry the
# real exit code.
# (b) getUpdates offset persistence: resumed from the state file across
# restarts (no duplicate burst after restart), invalid state falls back
# to 0, and empty batches never rewrite the file.
# (c) negative control: the OLD trap + `wait "$pid"` idiom still dies on 254
# (proves the regression is real and the fix works).
# Hermetic: stubbed curl (no network), stubbed systemctl, real jq/flock/timeout.
run_test() {
require_cmd jq "telegram reap" || return 0
require_cmd timeout "telegram reap" || return 0
local sandbox stubs cfg runtime home listener curl_log batch_file
sandbox="$(mksandbox telegram-reap)"
stubs="$sandbox/stubs"
cfg="$sandbox/cfg"
runtime="$sandbox/runtime"
home="$sandbox/home"
listener="$ROOT/bin/pos-communication-telegram-listener"
curl_log="$sandbox/curl.log"
mkdir -p "$stubs" "$cfg" "$runtime" "$home"
: > "$curl_log"
# ── command map: two commands with non-zero exits, one with output ──
cat > "$cfg/telegram_commands.env" <<'MAP'
/fail254=exit 254
/with_out=echo boom; false
MAP
: > "$cfg/telegram_prefixes.env"
# ── stub curl ──
# Serve one batch with 2 commands, then empty batches (sleep keeps the
# empty-poll loop from spinning while the daemon runs).
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":"/fail254"}},
{"update_id":2,"message":{"message_id":11,"from":{"id":123},"chat":{"id":456},"text":"/with_out"}}
]}
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/reap.served" ]; then
touch "$sandbox/reap.served"
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"
XDG_RUNTIME_DIR="$runtime" HOME="$home"
TELEGRAM_BOT_TOKEN=testbot TELEGRAM_CHAT_ID=456 TELEGRAM_OWNER_ID=123)
# ── (a) non-zero command exit must not kill the daemon ──
# rc=124 → `timeout` killed an alive-and-polling daemon; rc=0 → clean exit.
# With the old code the daemon itself died with 254, which is caught here.
test_run_env "${common[@]}" -- timeout 10 "$listener" --run
if [ "${TR_RC:-0}" -eq 124 ] || [ "${TR_RC:-0}" -eq 0 ]; then
printf ' PASS daemon survived child exit 254 (rc=%s)\n' "${TR_RC}"
else
printf ' FAIL daemon died with rc=%s (crash-loop regression)\n' "${TR_RC:-?}"
fi
check_contains "/fail254 dispatched" "exec: /fail254" "${TR_OUT:-}"
check_contains "/with_out dispatched" "exec: /with_out" "${TR_OUT:-}"
local curl_content
curl_content="$(cat "$curl_log")"
# The stub logs curl's RAW argv (--data-urlencode passes text unencoded
# to the wire, so the log shows "text=exit 254" with a real space).
# /fail254 has no output → reply text is "exit 254\nOK".
check_contains "/fail254 reply carries exit 254" "text=exit 254" "$curl_content"
# /with_out → "exit 1\nboom" — output preserved alongside the exit code.
check_contains "/with_out reply carries exit 1" "text=exit 1" "$curl_content"
check_contains "/with_out reply preserves output" "boom" "$curl_content"
# ── offset persistence: written after processing updates ──
# update 1 → offset 2 persisted (both messages advance the offset).
check_eq "offset persisted after processing" "3" "$(cat "$cfg/telegram-listener.state")"
# ── (b) offset resumed from the state file across restarts ──
# No state file → offset starts at 0; with one present it resumes from it.
local sb2 stubs2 cfg2 curl2
sb2="$sandbox/resume"
stubs2="$sb2/stubs"
cfg2="$sb2/cfg"
curl2="$sb2/curl.log"
mkdir -p "$stubs2" "$cfg2"
: > "$curl2"
printf '99\n' > "$cfg2/telegram-listener.state"
: > "$cfg2/telegram_commands.env"
: > "$cfg2/telegram_prefixes.env"
cat > "$stubs2/curl" <<STUB
#!/usr/bin/env bash
printf 'curl %s\n' "\$*" >> "$curl2"
for a in "\$@"; do
case "\$a" in
*getUpdates*)
sleep 1
printf '%s' '{"ok":true,"result":[]}'
exit 0
;;
esac
done
printf '%s' '{"ok":true}'
STUB
chmod +x "$stubs2/curl"
printf '#!/usr/bin/env bash\nexit 1\n' > "$stubs2/systemctl"
chmod +x "$stubs2/systemctl"
local common2=(PATH="$stubs2:/usr/bin:/bin" CONFIG_DIR="$cfg2"
XDG_RUNTIME_DIR="$sb2/rt" HOME="$sb2/home"
TELEGRAM_BOT_TOKEN=testbot TELEGRAM_CHAT_ID=456 TELEGRAM_OWNER_ID=123)
mkdir -p "$sb2/rt" "$sb2/home"
test_run_env "${common2[@]}" -- timeout 3 "$listener" --run
check_contains "offset resumed from persisted state file" "offset=99" "$(cat "$curl2")"
# Empty batches never rewrite the state file.
check_eq "empty batches do not rewrite state" "99" "$(cat "$cfg2/telegram-listener.state")"
# invalid state file → fall back to 0
printf 'garbage\n' > "$cfg2/telegram-listener.state"
: > "$curl2"
test_run_env "${common2[@]}" -- timeout 3 "$listener" --run
check_contains "invalid state file falls back to offset 0" "offset=0" "$(cat "$curl2")"
# ── (c) negative control: the OLD trap + `wait "$pid"` idiom still dies ──
local old_rc old_out
set +e
old_out="$(bash -s 2>&1 <<'INNER'
set -euo pipefail
declare -A _EXIT_CODES=()
trap 'while _p=$(wait -n 2>/dev/null); do _EXIT_CODES[$_p]=$?; done' CHLD
exit 254 & pid=$!
sleep 0.3
wait "$pid" 2>/dev/null; rc=$?
echo "still-alive rc=$rc"
INNER
)"
old_rc=$?
set -e
check_not_contains "old trap+wait idiom does not survive 254" "still-alive" "$old_out"
check_rc "old trap+wait idiom dies with 254" 254 "$old_rc"
}
+129
View File
@@ -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"
}