Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59a4c0e1df | |||
| fcfa2a569a | |||
| 287f0b75b7 | |||
| 59d834a71c | |||
| 11b4a679a8 | |||
| e23d57e551 | |||
| c5d501ad25 | |||
| 73d15a26b4 | |||
| 41efc91cf0 | |||
| 0b76d4d29e | |||
| 2c77e73799 | |||
| df1cca478d | |||
| f14d24950a | |||
| 9a164462e7 |
@@ -33,3 +33,6 @@ dream.md
|
||||
|
||||
# Agent process reports — local-only shared memory (not tracked)
|
||||
AgentsReport/
|
||||
|
||||
# Task breakdown notes — never committed
|
||||
.tasks/
|
||||
|
||||
@@ -42,6 +42,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.
|
||||
|
||||
@@ -10,19 +10,19 @@
|
||||
|
||||
<!-- GEN:START docmap -->
|
||||
| ## 1. Project Overview | 28–43 |
|
||||
| ## 2. Directory Structure | 44–210 |
|
||||
| ## 3. Installation Flow | 211–264 |
|
||||
| ## 4. The `pos` CLI System | 265–346 |
|
||||
| ## 5. Shared Library — `lib/common.sh` | 347–378 |
|
||||
| ## 6. Docker Compose / ScaleTail | 379–421 |
|
||||
| ## 7. Optional Apps (`apps/`) | 422–451 |
|
||||
| ## 8. Entertainment Module | 452–465 |
|
||||
| ## 9. Systemd Services | 466–477 |
|
||||
| ## 10. Configuration Files | 478–504 |
|
||||
| ## 11. Coding Conventions | 505–537 |
|
||||
| ## 12. Development Workflow | 538–590 |
|
||||
| ## 13. Key File Quick Reference | 591–666 |
|
||||
| ## 14. Common Tasks for Agents | 667–700 |
|
||||
| ## 2. Directory Structure | 44–217 |
|
||||
| ## 3. Installation Flow | 218–276 |
|
||||
| ## 4. The `pos` CLI System | 277–365 |
|
||||
| ## 5. Shared Library — `lib/common.sh` | 366–397 |
|
||||
| ## 6. Docker Compose / ScaleTail | 398–440 |
|
||||
| ## 7. Optional Apps (`apps/`) | 441–470 |
|
||||
| ## 8. Entertainment Module | 471–484 |
|
||||
| ## 9. Systemd Services | 485–496 |
|
||||
| ## 10. Configuration Files | 497–523 |
|
||||
| ## 11. Coding Conventions | 524–556 |
|
||||
| ## 12. Development Workflow | 557–609 |
|
||||
| ## 13. Key File Quick Reference | 610–694 |
|
||||
| ## 14. Common Tasks for Agents | 695–728 |
|
||||
<!-- GEN:END docmap -->
|
||||
|
||||
## 1. Project Overview
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 "$@"
|
||||
|
||||
Executable
+33
@@ -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')"
|
||||
Executable
+208
@@ -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
|
||||
Executable
+86
@@ -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
|
||||
Executable
+132
@@ -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
|
||||
Executable
+169
@@ -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
|
||||
Executable
+5
@@ -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 "$@"
|
||||
Executable
+647
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env bash
|
||||
# lib/bank-lib.sh — shared storage helpers for the Command Bank.
|
||||
# Sourced by bin/pos-system-bank. Uses err() from lib/common.sh.
|
||||
#
|
||||
# Storage: ~/.config/linux_post_install/bank.env
|
||||
# Format: name|description|command
|
||||
# v2: commands with real newlines are saved with \\ (backslash) and \n
|
||||
# (newline) escapes; files written by v1 have no marker and load raw.
|
||||
#
|
||||
# Contracts:
|
||||
# * Defines ONLY bank_* functions — sourcing never clobbers a tool's helpers.
|
||||
# * Requires common.sh to be sourced by the CALLER.
|
||||
# * NEVER exits — return codes only.
|
||||
# * Performs NO interactive prompts (those stay in bin/pos-system-bank).
|
||||
|
||||
CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}"
|
||||
BANK_FILE="${BANK_FILE:-${CONFIG_DIR}/bank.env}"
|
||||
|
||||
# ── Load ────────────────────────────────────────────────────────
|
||||
# Populate parallel arrays: BANK_NAMES, BANK_DESCS, BANK_CMDS.
|
||||
bank_load() {
|
||||
BANK_NAMES=(); BANK_DESCS=(); BANK_CMDS=()
|
||||
[ -f "$BANK_FILE" ] || return 0
|
||||
|
||||
local line name desc cmd v2=0
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" =~ ^[[:space:]]*# ]] && { [[ "$line" == *"BANK_VERSION: 2"* ]] && v2=1; continue; }
|
||||
[[ -z "${line// /}" ]] && continue
|
||||
IFS='|' read -r name desc cmd <<< "$line"
|
||||
[[ -z "$name" ]] && continue
|
||||
[ "$v2" -eq 1 ] && cmd="$(printf '%b' "$cmd")"
|
||||
BANK_NAMES+=("$name")
|
||||
BANK_DESCS+=("${desc:-}")
|
||||
BANK_CMDS+=("${cmd:-}")
|
||||
done < "$BANK_FILE"
|
||||
}
|
||||
|
||||
# ── Save ────────────────────────────────────────────────────────
|
||||
# Atomic overwrite from parallel arrays.
|
||||
bank_save() {
|
||||
local dir
|
||||
dir="$(dirname "$BANK_FILE")"
|
||||
mkdir -p "$dir"
|
||||
local tmp
|
||||
tmp="$(mktemp "${dir}/.bank.XXXXXX")"
|
||||
{
|
||||
printf '%s\n' "# Command Bank — managed by pos bank (do not hand-edit)"
|
||||
printf '%s\n' "# Format: name|description|command (\\n = escaped newline in command)"
|
||||
printf '%s\n' "# BANK_VERSION: 2"
|
||||
local i
|
||||
for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do
|
||||
local cmd="${BANK_CMDS[$i]}"
|
||||
# Escape: every backslash → \\, every real newline → \n (keeps one record per physical line)
|
||||
cmd="${cmd//\\/\\\\}"
|
||||
cmd="${cmd//$'\n'/\\n}"
|
||||
printf '%s|%s|%s\n' "${BANK_NAMES[$i]}" "${BANK_DESCS[$i]}" "$cmd"
|
||||
done
|
||||
} > "$tmp"
|
||||
chmod 600 "$tmp"
|
||||
mv "$tmp" "$BANK_FILE"
|
||||
}
|
||||
|
||||
# ── Add ─────────────────────────────────────────────────────────
|
||||
bank_add() {
|
||||
local name="$1" desc="$2" cmd="$3"
|
||||
bank_load
|
||||
local i
|
||||
for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do
|
||||
[[ "${BANK_NAMES[$i]}" == "$name" ]] && return 1
|
||||
done
|
||||
BANK_NAMES+=("$name")
|
||||
BANK_DESCS+=("$desc")
|
||||
BANK_CMDS+=("$cmd")
|
||||
bank_save
|
||||
}
|
||||
|
||||
# ── Remove ──────────────────────────────────────────────────────
|
||||
bank_remove() {
|
||||
local name="$1"
|
||||
bank_load
|
||||
local found=0 i
|
||||
for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do
|
||||
if [[ "${BANK_NAMES[$i]}" == "$name" ]]; then
|
||||
unset 'BANK_NAMES[i]'
|
||||
unset 'BANK_DESCS[i]'
|
||||
unset 'BANK_CMDS[i]'
|
||||
found=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
[[ "$found" -eq 1 ]] || return 1
|
||||
# Re-index arrays (unset leaves gaps)
|
||||
BANK_NAMES=("${BANK_NAMES[@]}")
|
||||
BANK_DESCS=("${BANK_DESCS[@]}")
|
||||
BANK_CMDS=("${BANK_CMDS[@]}")
|
||||
bank_save
|
||||
}
|
||||
|
||||
# ── Update ──────────────────────────────────────────────────────
|
||||
bank_update() {
|
||||
local name="$1" desc="$2" cmd="$3"
|
||||
bank_load
|
||||
local i
|
||||
for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do
|
||||
if [[ "${BANK_NAMES[$i]}" == "$name" ]]; then
|
||||
BANK_DESCS[$i]="$desc"
|
||||
BANK_CMDS[$i]="$cmd"
|
||||
bank_save
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Find (by name) ──────────────────────────────────────────────
|
||||
# Sets BANK_IDX. Returns 0 if found, 1 if not.
|
||||
bank_find() {
|
||||
local name="$1"
|
||||
bank_load
|
||||
local i
|
||||
for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do
|
||||
if [[ "${BANK_NAMES[$i]}" == "$name" ]]; then
|
||||
BANK_IDX=$i
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Get (by name) ───────────────────────────────────────────────
|
||||
# Outputs: name\tdescription\tcommand (tab-separated).
|
||||
bank_get() {
|
||||
local name="$1"
|
||||
bank_find "$name" || return 1
|
||||
printf '%s\t%s\t%s\n' "${BANK_NAMES[$BANK_IDX]}" "${BANK_DESCS[$BANK_IDX]}" "${BANK_CMDS[$BANK_IDX]}"
|
||||
}
|
||||
|
||||
# ── List names ──────────────────────────────────────────────────
|
||||
bank_list_names() {
|
||||
bank_load
|
||||
local i
|
||||
for ((i = 0; i < ${#BANK_NAMES[@]}; i++)); do
|
||||
printf '%s\n' "${BANK_NAMES[$i]}"
|
||||
done
|
||||
}
|
||||
|
||||
# ── Count ───────────────────────────────────────────────────────
|
||||
bank_count() {
|
||||
bank_load
|
||||
echo "${#BANK_NAMES[@]}"
|
||||
}
|
||||
|
||||
# ── Validate name ───────────────────────────────────────────────
|
||||
bank_valid_name() {
|
||||
[[ "$1" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]]
|
||||
}
|
||||
|
||||
# ── Extract {param} names from a command template ───────────────
|
||||
_extract_params() {
|
||||
local cmd="$1"
|
||||
grep -oE '\{[a-zA-Z_][a-zA-Z0-9_]*\}' <<< "$cmd" 2>/dev/null | \
|
||||
sed 's/[{}]//g' | \
|
||||
awk '!seen[$0]++' || true
|
||||
}
|
||||
|
||||
# ── Substitute {param} with quoted values ───────────────────────
|
||||
# Usage: _substitute_params "cmd template" "key1=val1" "key2=val2" ...
|
||||
# Uses awk to build quoted replacements safely, avoiding bash quote-nesting.
|
||||
_substitute_params() {
|
||||
local cmd="$1"; shift
|
||||
# Build a sed expression: for each key=val, replace {key} with "val"
|
||||
local sed_expr=""
|
||||
while [ $# -gt 0 ]; do
|
||||
local key="${1%%=*}"
|
||||
local val="${1#*=}"
|
||||
# Escape sed special chars in the value
|
||||
val="${val//\\/\\\\}"
|
||||
val="${val//\//\\/}"
|
||||
sed_expr+="s/{${key}}/\"${val}\"/g; "
|
||||
shift
|
||||
done
|
||||
[ -n "$sed_expr" ] && printf '%s' "$cmd" | sed "$sed_expr" || printf '%s' "$cmd"
|
||||
}
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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
@@ -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) |
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/bank-test-helper.sh — minimal shim that sources bank-lib.sh for unit
|
||||
# testing. Provides a stub `err()` so bank-lib.sh can load without the full
|
||||
# common.sh / menu-lib.sh dependency chain.
|
||||
set -euo pipefail
|
||||
|
||||
# Stub err() — bank-lib.sh is contractually "never exits" but bank_valid_name
|
||||
# callers sometimes chain via `|| err …`. This version returns 1 so the test
|
||||
# harness can assert rc.
|
||||
err() { printf 'ERROR: %s\n' "$*" >&2; return 1; }
|
||||
|
||||
# Source the library under test.
|
||||
source "$ROOT/lib/bank-lib.sh"
|
||||
Executable
+653
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
Executable
+279
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Executable
+129
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# t-telegram-listener-singleton.sh — single-instance guard for the Telegram
|
||||
# listener daemon (flock on ${XDG_RUNTIME_DIR:-/tmp}/pos-telegram-listener.lock):
|
||||
# (a) the first --run acquires the lock and reaches its poll loop;
|
||||
# (b) a second --run on the same runtime dir fails fast (exit 1) with the
|
||||
# exact single-instance message — no 409/getUpdates race;
|
||||
# (c) the flock auto-releases when the first instance exits, so the next
|
||||
# --run starts cleanly (systemd Restart=always path);
|
||||
# (d) --status reports the lock through the same primitives.
|
||||
# Hermetic: stubbed curl (no network) + systemctl (no user bus), real
|
||||
# jq/flock/timeout, sandboxed XDG_RUNTIME_DIR + CONFIG_DIR.
|
||||
|
||||
run_test() {
|
||||
require_cmd jq "telegram singleton guard" || return 0
|
||||
require_cmd flock "telegram singleton guard" || return 0
|
||||
require_cmd timeout "telegram singleton guard" || return 0
|
||||
|
||||
local sandbox stubs cfg runtime home listener curl_log marker first_log
|
||||
sandbox="$(mksandbox telegram-singleton)"
|
||||
stubs="$sandbox/stubs"
|
||||
cfg="$sandbox/cfg"
|
||||
runtime="$sandbox/runtime"
|
||||
home="$sandbox/home"
|
||||
listener="$ROOT/bin/pos-communication-telegram-listener"
|
||||
curl_log="$sandbox/curl.log"
|
||||
marker="$sandbox/loop.started"
|
||||
first_log="$sandbox/first.log"
|
||||
mkdir -p "$stubs" "$cfg" "$runtime" "$home"
|
||||
: > "$curl_log"
|
||||
|
||||
# Stub curl: never touches the network. getUpdates serves an empty batch
|
||||
# forever (first call touches $marker so the test knows the daemon reached
|
||||
# its poll loop — which only happens AFTER the lock was acquired and the
|
||||
# config checks passed); everything else returns {ok:true}. The small
|
||||
# sleep keeps the empty-poll loop from spinning while the test runs.
|
||||
cat > "$stubs/curl" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
printf 'curl %s\n' "\$*" >> "$curl_log"
|
||||
for a in "\$@"; do
|
||||
case "\$a" in
|
||||
*getUpdates*)
|
||||
touch "$marker"
|
||||
sleep 1
|
||||
printf '%s' '{"ok":true,"result":[]}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
printf '%s' '{"ok":true}'
|
||||
STUB
|
||||
chmod +x "$stubs/curl"
|
||||
|
||||
# Stub systemctl: deterministic exit 1 — --status must not reach the real
|
||||
# user bus; the autostart line is not what this test asserts.
|
||||
printf '#!/usr/bin/env bash\nexit 1\n' > "$stubs/systemctl"
|
||||
chmod +x "$stubs/systemctl"
|
||||
|
||||
: > "$cfg/telegram_commands.env"
|
||||
: > "$cfg/telegram_prefixes.env"
|
||||
|
||||
local common=(PATH="$stubs:/usr/bin:/bin" CONFIG_DIR="$cfg"
|
||||
XDG_RUNTIME_DIR="$runtime" HOME="$home"
|
||||
TELEGRAM_BOT_TOKEN=testbot TELEGRAM_CHAT_ID=456 TELEGRAM_OWNER_ID=123)
|
||||
|
||||
# ── (a) first instance acquires the lock and runs ──
|
||||
rm -f "$marker"
|
||||
env "${common[@]}" timeout 10 "$listener" --run >"$first_log" 2>&1 &
|
||||
local first_pid=$!
|
||||
|
||||
local waited=0
|
||||
until [ -e "$marker" ]; do
|
||||
sleep 0.1
|
||||
waited=$((waited + 1))
|
||||
if [ "$waited" -ge 100 ]; then
|
||||
printf ' FAIL first listener never reached the poll loop (log below)\n'
|
||||
cat "$first_log"
|
||||
kill "$first_pid" 2>/dev/null || true
|
||||
wait "$first_pid" 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
printf ' PASS first listener acquired lock and reached the poll loop\n'
|
||||
|
||||
test_run_env "${common[@]}" -- "$listener" --status
|
||||
check_rc "status while daemon up exits 0" 0 "$TR_RC"
|
||||
check_contains "status reports lock held while running" \
|
||||
"listener: running (single instance lock held)" "$TR_OUT"
|
||||
|
||||
# ── (b) second instance fails fast with the exact message ──
|
||||
test_run_env "${common[@]}" -- timeout 3 "$listener" --run
|
||||
check_rc "second instance fails fast (exit 1)" 1 "$TR_RC"
|
||||
check_contains "second instance prints exact single-instance message" \
|
||||
"ERROR: listener already running (single instance) — check: systemctl --user status pos-telegram-listener" \
|
||||
"$TR_OUT"
|
||||
|
||||
# ── (c) lock releases when the first instance ends ──
|
||||
kill "$first_pid" 2>/dev/null || true
|
||||
wait "$first_pid" 2>/dev/null || true
|
||||
|
||||
test_run_env "${common[@]}" -- "$listener" --status
|
||||
check_contains "status reports not running after first exits" \
|
||||
"listener: not running" "$TR_OUT"
|
||||
|
||||
rm -f "$marker"
|
||||
env "${common[@]}" timeout 10 "$listener" --run >"$sandbox/third.log" 2>&1 &
|
||||
local third_pid=$!
|
||||
|
||||
waited=0
|
||||
until [ -e "$marker" ]; do
|
||||
sleep 0.1
|
||||
waited=$((waited + 1))
|
||||
if [ "$waited" -ge 100 ]; then
|
||||
printf ' FAIL third listener never reached the poll loop (log below)\n'
|
||||
cat "$sandbox/third.log"
|
||||
kill "$third_pid" 2>/dev/null || true
|
||||
wait "$third_pid" 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
printf ' PASS third listener starts cleanly after the lock was released\n'
|
||||
|
||||
kill "$third_pid" 2>/dev/null || true
|
||||
wait "$third_pid" 2>/dev/null || true
|
||||
|
||||
test_run_env "${common[@]}" -- "$listener" --status
|
||||
check_contains "status reports not running after third exits" \
|
||||
"listener: not running" "$TR_OUT"
|
||||
}
|
||||
Reference in New Issue
Block a user