feat: menu doors for media-sync/backup/compose/schedule/vbox/download; firewall menu → stderr+/dev/tty mechanics
gates / consistency-and-conventions (push) Successful in 2m10s
gates / consistency-and-conventions (push) Successful in 2m10s
This commit is contained in:
@@ -42,6 +42,7 @@ summary (newest last).
|
||||
|
||||
## Done
|
||||
|
||||
- **2026-08-21** — Share suite interactive layer (`lib/share-lib.sh` + menu modes for all five `pos share *` tools): bare invocation now opens an EOF-safe looping menu instead of printing usage. New `lib/share-lib.sh` (436 lines) owns the shared primitives — `share_menu_guard`/`share_menu_run`/`share_pick`/`share_ask_value` (quit on EOF so non-tty callers can't hang), `share_require_bin`/`share_port_probe`/`share_service_active`/`share_path_probe` rc-only probes, `share_usb_records` (blank-line-record parser for usbsrv listings), `share_smb_shares` (smbclient `-g` Disk enumeration incl. guest→auth retry) + `share_usb_devices`/`share_usb_clients`, `share_nfs_exports` (showmount), `share_folder_candidates` (bounded-probe scan of mounted targets + conventional roots; container overlay/tmpfs/nsfs excluded via findmnt; clients build their own mountpoint pickers on top), and advisories `share_ufw_blocks_ports`+`share_offer_fix`. Tools keep every legacy flag/subcommand byte-compatible (verbatim command bodies, thin menu layer on top): nfs-server gains a client-spec presets picker + inactive-service/UFW offers, nfs-client gains idempotent unmount/unpersist (already-absent = report, rc0) + persist-verify-with-rollback + replace-confirm, smb-server gets UFW offer + menu tree, smb-client gets enumerate→pick→mount with account reuse (`SMB_AUTH_USER` contract), usb-server picker-first with raw-listing manual-entry fallback when the server listing is unreadable. New seams: `EXPORTS_FILE` (nfs-server), `UNIT_DIR` (nfs-client); `bin/pos` INTERACTIVE_CMDS += both stdin-reading share tools; install.sh lib list += share-lib.sh; preinstall.sh += smbclient (smb-client enumeration dep). Docs: POS.md share rows/detail, howto/share.md per-tool Interactive-menu notes, SCRIPTS.md phase table + new `## lib/share-lib.sh` section, DEV.md lib row + env-seam registry, AGENT_Context hand-maintained spots (lib table row 436, Phase-2 prose). Verified: 80-case stub battery vs recaptured deterministic golden — only the 9 documented intentional deltas differ (additive help lines, seam-path strings in messages, missing-dep message delta, unmount-idle FLAGGED→rc0, unpersist round-trip now works, usbs-bare usage→menu guard); 12/12 PTY tests (menus open/quit non-tty, filter/zero-match/default/cancel picker semantics, full nfs-server share flow writes the export line, usb-server share via pickers + down-server fallback, smb-client guest-enumerate→manual-share flow); real `/etc/exports` + `/etc/systemd/system` md5-verified untouched; `make gen && make check` green, `make lint` 0 FAIL / 0 WARN.
|
||||
- **2026-08-21** — Refreshed `AGENTS.md` against the codebase: HOWTO category list corrected to match `DOC/howto/*` (ai/share/schedule, no bare "usb"); CI bullet now states only verifiable facts (`lint.yml` job `gates`, push-to-main/PR, `ci-ok/<sha>`/`ci-fail/<sha>` result tags) instead of the uncheckable act-runner naming; new **Doc conflicts** bullet encoding the `MAINTENANCE.md → Phase 0` authority order and `templates/*.sh` as required starting points. Every other claim re-verified against `scripts/{gen-docs,check-sync,lint-conventions}.sh`, `bin/pos` (dispatch loop, INTERACTIVE_CMDS), `.gitignore`/`.gitmodules`, `lib/config-ui.sh`; gates green before and after.
|
||||
- **2026-08-15** — `pos docker stack` (`bin/pos-docker-stack`) — containers grouped by their Docker Compose project. Each stack is a section (project name, sorted) with lines `container-name status ports`; containers with no compose project land in a `Standalone` section at the end; ends with `Stacks: N containers: N standalone: N`. Running only by default, `-a|--all` includes stopped/exited (like `docker ps -a`). Status colored on a terminal (`Up*` green, `Exited*`/`Dead*`/`Created*` red, `Paused*`/`Restarting*` yellow); exit 0 also when no containers. Data via `docker ps` with `--format '{{.Names}}{{"\u001f"}}{{.Label "com.docker.compose.project"}}{{"\u001f"}}{{.Status}}{{"\u001f"}}{{.Ports}}'` (compose v2 sets the project label; `{{"\u001f"}}` escapes in the Go template), parsed with `awk -F'\x1f'` + `IFS=$'\x1f' read` everywhere — tab/pipe delimiters are IFS whitespace or inside values, so `\x1f` (DEV.md:213 gotcha); dash padding via `sed` not `tr` (tr corrupts multi-byte `─`). Deps guard (`docker`) before `--help`; no stdin → not in `INTERACTIVE_CMDS`; `# POS_FLAGS: -a --all`. Docs: POS.md docker row + detail, howto/docker.md table + section, `bin/pos` usage EXAMPLES, AGENT_Context §14 row. Verified: stub-PATH suite `/tmp/opencode/docker-stack-test/run-tests.sh` 23/23 (grouping, sorted stacks, `-a` shows exited, standalone, empty daemon rc=0, colored status, missing docker rc=1, `--help` after deps guard); live runs against the real daemon (affine/audiobookshelf/convertx/gitea stacks, `affine_migration_job Exited (0)` + `lab1 Exited (137)` under `-a`); dispatch via `pos docker stack`; `make gen && make check`, `make lint` 0 FAIL / 0 WARN.
|
||||
- **2026-08-15** — Fix `pos media sync` offering a Ventoy stick's **EFI partition** as the sync target: with the data partition unmounted, the 32 MB `VTOYEFI` ESP was the only mounted USB partition, `usb_detect` offered it with no context, and `cp` died mid-copy with `No space left on device` (live-box report). `usb_detect` now fetches `FSTYPE`/`PARTTYPENAME` and excludes EFI system partitions (Ventoy `VTOYEFI`, `/boot/efi`) from **both** the mounted list and the mount-offer list; `USB_MOUNTED` entries carry `mp|label|size|model|fs` and `usb_pick_root` shows that in the single-stick confirm and the multi-stick/partition picker (`1) /media/Ventoy (1.1T, Ventoy, exfat)`), while `USB_ROOT` stays a bare mountpoint (`${root%|*}`) so `pos system backup` (`${root%/}/backups`) is unaffected. `pos-media-sync` gained a pre-flight space check (measures exactly what `needs_copy` would copy vs `df -Pk`, `err`/`warn` before any copy) — no more mid-copy ENOSPC. Docs: howto/media.md target-picking note, SCRIPTS.md usb-lib paragraph, AGENT_Context hand-maintained lib row (194→205). Verified: new stub harness `/tmp/opencode/vtoyefi-run.sh` (ESP filtered from mounted + mount-offer, multi-pick shows only the data partition, space fit/too-small/dry-run-warn) green; `/tmp/opencode/backup-test` still green; live check `printf 'n\ns\n' | bash bin/pos-media-sync --mp3` no longer offers VTOYEFI (offers unmounted `sda1` Ventoy instead); `make gen && make check`, `make lint` 0 FAIL / 0 WARN.
|
||||
@@ -130,3 +131,6 @@ summary (newest last).
|
||||
- **2026-08-14** — `pos media sync` (`bin/pos-media-sync`) — incremental Music → USB sync, plus the shared USB layer it builds on. **New lib `lib/usb-lib.sh`** (194 lines, installed by install.sh): `usb_detect` (lsblk JSON, TRAN + lsusb/by-id cross-check → `USB_MOUNTED`/`USB_UNMOUNTED`), `usb_related_present`, `usb_mount_offer` (`/media/<label>` mount-offer, `usb-automount` scheme), `usb_pick_root <prefix> <subfolder> <giveup-msg>` (detect → mount-offer → single/multi picker → `USB_ROOT`); seams `USB_MOUNT_BASE`/`USB_BYID` with `BACKUP_MOUNT_BASE`/`BACKUP_USB_BYID` aliases so existing `system.env` lines keep working; TRAN-fallback warning deduped to once per scan. `pos-system-backup` refactored onto it (216 lines, was 364) — re-ran the backup stub suite: 65/65 green. **Sync tool**: add/update only, never deletes (user choice); `--mp3`/`--mp4` filter (neither = both), `--source <dir>` (default `MEDIA_SYNC_SOURCE`/`$HOME/Music`), `--dry-run` preview with counts; copies missing/changed (size/mtime) files into `<usb>/Music/` (`MEDIA_SYNC_DEST`) preserving the tree via `cp --preserve=timestamps`; result notified via `lib/notify.sh`; `media-sync` added to `INTERACTIVE_CMDS`; deps guards (`lsblk`/`jq`) before `-h|--help`. Docs: POS.md media row, howto/media.md section, SCRIPTS.md lib section + Phase-2 lib list, system.env seams, DEV.md env-seam registry, AGENT_Context Common Tasks + hand-maintained lib row (+usb-lib 194) + gen'd tree/dispatch/filetable/flags. Verified: new stub suite `/tmp/opencode/msync-run.sh` 46/46 green (fresh/no-op/update/filter/dry-run/multi-stick/mount-offer/no-USB skip/never-delete/--source/TRAN-fallback/notify) — caught and fixed an inverted `needs_copy` return; `make gen && make check`, `make lint` 0 FAIL / 0 WARN; dispatch via `pos media sync --help` + `pos media` listing.
|
||||
- **2026-08-22** — `pos media ytsync` (`bin/pos-media-ytsync`) — incremental YouTube channel/playlist sync into `~/Videos`, implemented per the Architect decisions D1–D9 + Designer UX contract (`reportAgents/2026-08-22-*.md`). Subcommands `add [url] / sync [name] / list / remove <name>` + `--dry-run`; bare invocation = interactive menu (`/dev/tty` reads, EOF-safe, NOT in INTERACTIVE_CMDS so dispatcher tee logging is kept; empty state goes straight to the URL prompt). One yt-dlp call per new video (`bestvideo*+bestaudio/best` → MP4, metadata/chapters/thumbnail, `--no-overwrites`, `--windows-filenames --trim-filenames 120`, retries 3), per-video `[n/N] title` heartbeat lines, LF-only logs (spinner TTY-gated); probe = `yt-dlp --flat-playlist -J` parsed with jq, new-list diffed against the per-source `--download-archive` BEFORE downloads (exact counts, exact dry-run plans, zero speculative downloads). State machine-owned outside ~/Videos: `$YTSYNC_STATE_DIR/{registry(\x1f-delimited slug⇥type⇥url⇥subdir⇥playlist_title⇥added_ts), archive/<slug>.txt, history.log}`, atomic temp+mv writes; `remove` keeps files AND archive (re-add resumes incrementally); `?v=`+`&list=` URLs download the single video only. Exit codes: 0 incl. no-op/cancel/non-tty-guard; 1 reserved for missing deps, invalid explicit URL, unknown/ambiguous name, wholesale source failure. Notify digest only when new>0 or failed>0 (+ ERR-trap alarm around download passes) via opt-in `lib/notify.sh`. Config scope `ytsync`: `YTSYNC_VIDEOS_DIR` / `YTSYNC_EXTRA_ARGS` (`pos config ytsync`); automation documented as a `pos system schedule` job (`COMMAND=pos media ytsync sync`, `NOTIFY=never`). Deps guards before `-h|--help` with yt-dlp+jq active under `--dry-run` (the preview IS the probe; ffmpeg skipped there). Docs: `tools-docs/ytsync.md` (new dir), POS.md media row + notes, HOWTO.md row, howto/media.md section + troubleshooting, AGENT_Context Common-Tasks row, `bin/pos` EXAMPLES line. Verified: `make gen && make check && make lint` 0 FAIL / 0 WARN; PATH-stub yt-dlp suite (add happy path, incremental 0-new idempotency, playlist NNN numbering, dry-run zero writes, non-tty guard rc0, remove-keeps-archive) with real $HOME byte-untouched via seams.
|
||||
- **2026-08-23** — ytsync post-review fixes (from `reportAgents/2026-08-23-reviewer-ytsync.md`, ACCEPT_WITH_NITS): `classify_url` now treats `youtu.be/<id>` short links (with or without `&list=`, incl. scheme-less + `?si=` forms) as single videos — same path as `?v=` — so they get type `video` + `--no-playlist` instead of being misfiled as playlists; usage() watch-link note reworded; tools-docs classification table gains the short-link row and the invocation block gains the previously undocumented `--convert-thumbnails jpg`; POS.md/howto media wording extended. Verified: classify_url matrix (6 URL shapes) + stub-PATH end-to-end add (registry type=video, download call carries `--no-playlist` + canonical watch URL); `make gen && make check && make lint` 0 FAIL / 0 WARN.
|
||||
- **2026-08-23** — ytsync menu render bugfix (`bin/pos-media-ytsync`, live-box report): `cut -d'·'` at :292/:301 used U+00B7 = 2 bytes UTF-8 (GNU cut is byte-oriented → "delimiter must be a single character", masked by `|| true` so the `· last run …` suffix and LAST SYNC column never rendered); replaced with grep/tail capture + `${last%% ·*}` parameter expansion (semantics identical incl. empty-string=no-last-run); :1053 `printf '----…\n'` format starting with `-` parsed as invalid option → `printf '%s\n' '----…'`. Chain: Detective root cause (`reportAgents/2026-08-23-detective-ytsync-menu-errors.md`) → Builder 3-site fix (`-builder-ytsync-menu-fix.md`, pty probe: suffix + separator render, zero stderr noise) → Reviewer ACCEPT-WITH-NITS (`-reviewer-ytsync-menu-fix.md` delivered inline). Gates re-run by Orchestrator post-review: `make gen` idempotent, check OK, lint 0 FAIL / 0 WARN.
|
||||
- **2026-08-23** — Menu Phase 1 (user-ratified decision "b"): category-neutral menu library extracted from share-suite Pattern B + four P1 tool menus. New `lib/menu-lib.sh` (169 ln): `menu_guard`/`menu_run`/`menu_pick`/`menu_ask_value` (stderr render, /dev/tty reads, EOF fail-closed rc=1, index/value→stdout); `lib/share-lib.sh` (436→318) keeps its public names as pure delegating shims so all five `pos share *` tools stay untouched; install.sh Phase-2 explicit lib list += menu-lib.sh. Opt-in no-args+tty front doors (or `menu` verb, `# POS_SUBCMDS:` registered, completions regen'd) on `pos media sync` (164→216: Sync-now/Preview/mp3/mp4/source-folder items), `pos system backup` (216→292: typed/service-root/plain variants, every backup behind folder-naming y/N), `pos docker compose` (366→487: ls/up/down/restart/logs/update/config items, down/restart/update confirm-gated naming the stack), `pos system schedule` (81→151: list/status/run-now(confirm)/enable/disable/editor — timer-invoked `run <name>` verb dispatch byte-identical to HEAD). INTERACTIVE_CMDS unchanged; all CLI verbs byte-compatible. Docs: POS.md ×4 rows, DEV.md lib row, SCRIPTS.md sections, AGENT_Context rows + GEN. Chain: Explorer survey (37 tools, `reportAgents/2026-08-23-explorer-pos-menu-survey.md`) → Designer classification (`-designer-pos-menu-suitability.md`: 14 MENU-FIT / 7 CONDITIONAL / 16 NO-FIT) → Builder T1/T2/T3 (`-builder-t1-menu-lib-extraction.md`, `-t2-p1-menus-media-backup.md`, `-t3-p1-menus-compose-schedule.md`; T3 discloses a mid-verify symlink clobber restored+re-verified) → Reviewer ACCEPT_WITH_NOTES over the consolidated diff (`-reviewer-phase1-menu.md`, T3 integrity clean). Verified: bash -n ×7, pty probes (render/quit/EOF/non-tty fail-closed/destructive prompt-abort), gates green after each pass and re-run by Orchestrator post-review (`make gen` idempotent · `make check` OK · `make lint` 0 FAIL / 0 WARN). Open for later phases: P2 (docker-vbox, network-download), firewall style-migration decision, usb-server `menu` in POS_FLAGS nit (owning track).
|
||||
- **2026-08-23** — Menu Phase 2 + firewall style-migration (decision "a" activated: P1 landed, `lib/menu-lib.sh` exists). `pos docker vbox` (157→261): 6-item menu hub over the inline case verbs via a quoted self-invocation `menu_self` (verbs never re-enter the menu → no recursion); `enter` hands over the terminal and returns to the loop; rm/create behind VM-naming y/N. `pos network download` (950→1104): 13-item top-verb map onto existing cmd_* fns — add URL (`menu_ask_value`, optional `--tmux`), gid-pick → info/pause/resume/remove/restart (remove names name+gid before delete), typed-confirm purge, watch handover, daemon start/stop (stop confirmed); non-fatal RPC liveness gate (`-m 3`) keeps queue views alive on a dead daemon; deliberately NOT added to INTERACTIVE_CMDS — menu-lib's tty-guarded reads make membership unnecessary and keep tee-logging for all scripted verbs (survey E-002; Reviewer traced the lint pass as honest through `uses_stdin`). `pos system firewall` (308→325) migrated to repo-standard mechanics ONLY: menu heredoc render → stderr `{ … } >&2` (body byte-preserved), all **38** interactive reads → `/dev/tty` via tool-local `tty_read()` (EOF/no-tty → pointer + rc1, never hangs), `prompt_ipver` de-command-substituted so EOF exits gracefully; root gate / per-cmd confirm / typed RESET / pager / notify / every ufw invocation untouched. Both new tools register `# POS_SUBCMDS:` += `menu`; POS.md rows updated; GEN regen'd. Chain: Builder T4 (`reportAgents/2026-08-23-builder-t4-p2-menus-vbox-download.md`; correctly caught an Orchestrator brief error claiming download was in INTERACTIVE_CMDS) + T5 (`-t5-firewall-menu-migration.md`; pty parity captures vs pre-edit baseline) → Reviewer ACCEPT-WITH-NITS over both (`-reviewer-phase2-menu.md`, transcribed by Orchestrator; recursion/injection analysis, 13/13 mapping proof, four T5 intents verified hunk-by-hunk). Verified: bash -n ×3 + gates green after each pass; final trio re-run by Orchestrator post-T5 — `make check` OK · `make lint` 0 FAIL / 0 WARN (76s under box load ~7; the earlier apparent lint hang was shared-box CPU contention, no code issue). Remaining notes for later sessions: errexit kills whole menu when a backing verb hard-fails (repo-wide pattern, all six menus); `confirm()` EOF hits set-u unbound `yn` (pre-existing common.sh); vbox create EOF at dir prompt degrades to default while name/image prompts abort (cosmetic).
|
||||
|
||||
@@ -11,18 +11,18 @@
|
||||
<!-- GEN:START docmap -->
|
||||
| ## 1. Project Overview | 28–43 |
|
||||
| ## 2. Directory Structure | 44–196 |
|
||||
| ## 3. Installation Flow | 197–248 |
|
||||
| ## 4. The `pos` CLI System | 249–322 |
|
||||
| ## 5. Shared Library — `lib/common.sh` | 323–354 |
|
||||
| ## 6. Docker Compose / ScaleTail | 355–397 |
|
||||
| ## 7. Optional Apps (`apps/`) | 398–427 |
|
||||
| ## 8. Entertainment Module | 428–441 |
|
||||
| ## 9. Systemd Services | 442–453 |
|
||||
| ## 10. Configuration Files | 454–480 |
|
||||
| ## 11. Coding Conventions | 481–513 |
|
||||
| ## 12. Development Workflow | 514–566 |
|
||||
| ## 13. Key File Quick Reference | 567–631 |
|
||||
| ## 14. Common Tasks for Agents | 632–665 |
|
||||
| ## 3. Installation Flow | 197–250 |
|
||||
| ## 4. The `pos` CLI System | 251–324 |
|
||||
| ## 5. Shared Library — `lib/common.sh` | 325–356 |
|
||||
| ## 6. Docker Compose / ScaleTail | 357–399 |
|
||||
| ## 7. Optional Apps (`apps/`) | 400–429 |
|
||||
| ## 8. Entertainment Module | 430–443 |
|
||||
| ## 9. Systemd Services | 444–455 |
|
||||
| ## 10. Configuration Files | 456–482 |
|
||||
| ## 11. Coding Conventions | 483–515 |
|
||||
| ## 12. Development Workflow | 516–568 |
|
||||
| ## 13. Key File Quick Reference | 569–635 |
|
||||
| ## 14. Common Tasks for Agents | 636–669 |
|
||||
<!-- GEN:END docmap -->
|
||||
|
||||
## 1. Project Overview
|
||||
@@ -204,7 +204,9 @@ User runs: ./install.sh [--apps|--full|--feature|--dry-run|--skip <phase>|--step
|
||||
│
|
||||
├─ Phase 2: install.sh (requires root)
|
||||
│ └─ Copies bin/* → /usr/local/bin/ (chmod 755)
|
||||
│ └─ Copies lib/common.sh + lib/flags.sh + lib/notify.sh + lib/entertainment-lib.sh → /usr/local/bin/ (chmod 644)
|
||||
│ └─ Copies lib/*.sh (common, flags, notify, entertainment-lib,
|
||||
│ scheduler-lib, config-ui, user-timers-lib, entertainment-plugin-lib,
|
||||
│ usb-lib, share-lib, menu-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
|
||||
@@ -579,6 +581,8 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| `lib/scheduler-lib.sh` | 760 | Scheduler lib (job parsing, notify policies, per-job user timers via user-timers-lib, legacy migrate) |
|
||||
| `lib/user-timers-lib.sh` | 112 | Shared systemd **user** timer machinery (interval→OnCalendar, unit pair writer, linger) |
|
||||
| `lib/usb-lib.sh` | 205 | Shared USB-storage detection + pick flow (detect/mount-offer/`usb_pick_root`; EFI system partitions excluded; picker shows size/label/fs) — used by `pos system backup` + `pos media sync` |
|
||||
| `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` | 169 | 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; stderr render, fail-closed on non-tty/EOF) — sourced by `share-lib.sh`, open to any category |
|
||||
| `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 |
|
||||
@@ -592,11 +596,11 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| `bin/pos-communication-scrcpy` | 254 | Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info) |
|
||||
| `bin/pos-communication-telegram-listener` | 566 | Telegram bot listener: map /command → bash, run them on chat messages |
|
||||
| `bin/pos-communication-telegram-sender` | 221 | Send Telegram messages/files/links/stickers via Bot API (send, test) |
|
||||
| `bin/pos-docker-compose` | 366 | Docker Compose service manager (ls/up/down/restart/logs/update/config) |
|
||||
| `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) |
|
||||
| `bin/pos-docker-ps` | 125 | Enhanced container overview (health, IPs, ports, uptime) |
|
||||
| `bin/pos-docker-stack` | 101 | Containers grouped by compose stack (project); standalone group; -a/--all includes stopped |
|
||||
| `bin/pos-docker-vbox` | 158 | Disposable Docker-based VMs (create/enter/start/stop/rm/ls) |
|
||||
| `bin/pos-docker-vbox` | 261 | Disposable Docker-based VMs (create/enter/start/stop/rm/ls) |
|
||||
| `bin/pos-entertainment-config` | 143 | Show or edit the entertainment config (ENABLED auto-trigger list, weather location) |
|
||||
| `bin/pos-entertainment-disable` | 32 | Disable a plugin's auto-trigger (remove it from ENABLED) |
|
||||
| `bin/pos-entertainment-enable` | 49 | Enable an auto-trigger for a plugin on a schedule |
|
||||
@@ -604,26 +608,26 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| `bin/pos-entertainment-status` | 62 | Show enabled plugins and scheduler state |
|
||||
| `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-sync` | 164 | Incremental Music → USB sync (mp3/mp4, add/update only) |
|
||||
| `bin/pos-media-ytsync` | 1190 | Incrementally sync YouTube channels/playlists into ~/Videos |
|
||||
| `bin/pos-media-sync` | 216 | Incremental Music → USB sync (mp3/mp4, add/update only) |
|
||||
| `bin/pos-media-ytsync` | 1191 | Incrementally sync YouTube channels/playlists into ~/Videos |
|
||||
| `bin/pos-network-checkport` | 496 | Check TCP/UDP port reachability (nmap, or bash/nc fallback) + local interface view |
|
||||
| `bin/pos-network-download` | 951 | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) |
|
||||
| `bin/pos-network-download` | 1104 | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) |
|
||||
| `bin/pos-network-hotspot` | 93 | Wi-Fi hotspot via create_ap + wihotspot-gui |
|
||||
| `bin/pos-network-ip` | 69 | Show interfaces, routes, public IP + location |
|
||||
| `bin/pos-network-scan` | 272 | Parallel ping sweep of CIDR |
|
||||
| `bin/pos-share-nfs-client` | 138 | Mount NFS shares (ephemeral or persistent systemd mount units) |
|
||||
| `bin/pos-share-nfs-server` | 134 | Manage the NFS kernel server (status, share/unshare exports, enable/disable) |
|
||||
| `bin/pos-share-smb-client` | 440 | Mount SMB/CIFS shares (ephemeral or persistent systemd mount units) |
|
||||
| `bin/pos-share-smb-server` | 311 | Manage the Samba server (status, share/unshare exports, users, enable/disable) |
|
||||
| `bin/pos-share-usb-server` | 218 | USB Redirector server control (--ls, --share; prompts when args omitted) |
|
||||
| `bin/pos-share-nfs-client` | 343 | Mount NFS shares (ephemeral or persistent systemd mount units) |
|
||||
| `bin/pos-share-nfs-server` | 245 | Manage the NFS kernel server (status, share/unshare exports, enable/disable) |
|
||||
| `bin/pos-share-smb-client` | 576 | Mount SMB/CIFS shares (ephemeral or persistent systemd mount units) |
|
||||
| `bin/pos-share-smb-server` | 441 | Manage the Samba server (status, share/unshare exports, users, enable/disable) |
|
||||
| `bin/pos-share-usb-server` | 362 | USB Redirector server control (--ls, --share; prompts when args omitted) |
|
||||
| `bin/pos-ssh-load-keys` | 31 | Load all SSH keys into the agent |
|
||||
| `bin/pos-system-backup` | 216 | Encrypted (AES-256) folder snapshots (tar + gpg) |
|
||||
| `bin/pos-system-firewall` | 308 | Interactive UFW management |
|
||||
| `bin/pos-system-backup` | 292 | Encrypted (AES-256) folder snapshots (tar + gpg) |
|
||||
| `bin/pos-system-firewall` | 325 | Interactive UFW management |
|
||||
| `bin/pos-system-health` | 209 | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL |
|
||||
| `bin/pos-system-schedule` | 81 | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently |
|
||||
| `bin/pos-system-schedule` | 151 | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently |
|
||||
| `bin/pos-config` | 80 | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) |
|
||||
| `bin/pos-tree` | 112 | Show the pos CLI command tree: categories, commands, and subcommands |
|
||||
| `completions/pos.bash` | 298 | Dynamic bash completion |
|
||||
| `completions/pos.bash` | 302 | Dynamic bash completion |
|
||||
<!-- GEN:END filetable -->
|
||||
| `apps/install.sh` | 171 | App install/uninstall picker/orchestrator |
|
||||
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@ Each phase is independent and runs only if the corresponding script exists.
|
||||
|-----------|---------|-------------|
|
||||
| `bin/` | Daily-use CLI tools and wrappers | `/usr/local/bin/` |
|
||||
| `apps/<category>/` | Optional desktop app installers | run on demand |
|
||||
| `lib/` | Shared libraries: `common.sh` (helpers), `flags.sh` (feature flags), `notify.sh` (multi-platform alerting), `entertainment-lib.sh` (entertainment scheduling + last-run state), `entertainment-plugin-lib.sh` (message-safe plugin helpers), `scheduler-lib.sh` (system scheduler), `user-timers-lib.sh` (shared systemd user timer machinery), `config-ui.sh` (interactive config UI) | sourced at build time |
|
||||
| `lib/` | Shared libraries: `common.sh` (helpers), `flags.sh` (feature flags), `notify.sh` (multi-platform alerting), `entertainment-lib.sh` (entertainment scheduling + last-run state), `entertainment-plugin-lib.sh` (message-safe plugin helpers), `scheduler-lib.sh` (system scheduler), `user-timers-lib.sh` (shared systemd user timer machinery), `config-ui.sh` (interactive config UI), `menu-lib.sh` (category-neutral menu primitives: guard/looping menu/filter picker/prompt), `share-lib.sh` (share-suite domain probes/listings + compat shims to menu-lib) | sourced at build time |
|
||||
| `config/` | Gitignored user config files | `~/.config/<app>/` (via postinstall) |
|
||||
| `entertainment/` | Public-API plugins for the entertainment module | `/usr/local/bin` (via install.sh Phase 2) |
|
||||
| `compose/` | ScaleTail templates (git submodule) | `/usr/local/share/linux_post_install/scale-tail` |
|
||||
@@ -193,7 +193,7 @@ make lint # convention gate (scripts/lint-conventions.sh)
|
||||
|
||||
`make check` only proves syntax, exec bits, doc sync and dispatch — not behaviour. For tools that need `sudo`, systemd, or binaries absent from the dev box (samba, usbsrv, …), test them end-to-end with two patterns:
|
||||
|
||||
- **Env-overridable paths.** Anything that touches a system config location gets an env override whose default is the real path — the seam that lets the tool be exercised against temp files. Precedents: `FLAGS_DIR` (`lib/flags.sh`), `SMB_CONF` (`bin/pos-share-smb-server`, default `/etc/samba/smb.conf`), `SMB_CREDS_DIR`/`UNIT_DIR` (`bin/pos-share-smb-client`), `USER_SYSTEMD_DIR` (`bin/pos-network-download`, `bin/pos-communication-{telegram,matrix}-listener`, `lib/scheduler-lib.sh` — write it as `${USER_SYSTEMD_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user}`), the scheduler's `SCHEDULE_DIR`/`SCHEDULE_STATE_DIR`/`SCHEDULE_LOG_DIR`/`SCHED_LEGACY_ENV` (`lib/scheduler-lib.sh`), and the USB layer shared by `pos system backup` + `pos media sync`: `USB_MOUNT_BASE` (default `/media`, keeps `BACKUP_MOUNT_BASE` as an alias) and `USB_BYID` (default `/dev/disk/by-id`, keeps `BACKUP_USB_BYID` as an alias) — both guarded as `VAR="${VAR:-${BACKUP_…:-default}}"` in `lib/usb-lib.sh` so existing config lines keep working. Pick a short tool-specific name and don't advertise it in `usage()` — it's a test seam, not user-facing. **Gotcha (session-learned):** a `VAR="${XDG…:-…}"` without the leading `VAR:-` *overrides* the seam — the stub run then silently writes to the real `$HOME` path and every assertion passes while the bug hides. The override must be written first, then tested with `VAR=/tmp/x …` and a check that the real path is untouched.
|
||||
- **Env-overridable paths.** Anything that touches a system config location gets an env override whose default is the real path — the seam that lets the tool be exercised against temp files. Precedents: `FLAGS_DIR` (`lib/flags.sh`), `SMB_CONF` (`bin/pos-share-smb-server`, default `/etc/samba/smb.conf`), `EXPORTS_FILE` (`bin/pos-share-nfs-server`, default `/etc/exports`), `SMB_CREDS_DIR`/`UNIT_DIR` (`bin/pos-share-smb-client`, and `UNIT_DIR` again in `bin/pos-share-nfs-client`), `USER_SYSTEMD_DIR` (`bin/pos-network-download`, `bin/pos-communication-{telegram,matrix}-listener`, `lib/scheduler-lib.sh` — write it as `${USER_SYSTEMD_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user}`), the scheduler's `SCHEDULE_DIR`/`SCHEDULE_STATE_DIR`/`SCHEDULE_LOG_DIR`/`SCHED_LEGACY_ENV` (`lib/scheduler-lib.sh`), and the USB layer shared by `pos system backup` + `pos media sync`: `USB_MOUNT_BASE` (default `/media`, keeps `BACKUP_MOUNT_BASE` as an alias) and `USB_BYID` (default `/dev/disk/by-id`, keeps `BACKUP_USB_BYID` as an alias) — both guarded as `VAR="${VAR:-${BACKUP_…:-default}}"` in `lib/usb-lib.sh` so existing config lines keep working. Pick a short tool-specific name and don't advertise it in `usage()` — it's a test seam, not user-facing. **Gotcha (session-learned):** a `VAR="${XDG…:-…}"` without the leading `VAR:-` *overrides* the seam — the stub run then silently writes to the real `$HOME` path and every assertion passes while the bug hides. The override must be written first, then tested with `VAR=/tmp/x …` and a check that the real path is untouched.
|
||||
- **Stub PATH.** Create a temp dir with fake binaries, then run the tool with `PATH="$stubs:$PATH"`: fake `sudo` → `exec "$@"`; fake `systemctl`/`smbcontrol`/`mount.cifs` → echo their args; fake `testparm` → `cat` the file back (so validation passes); fake `systemd-escape` → print a fixed name. Assert on output **and** exit codes — happy path plus each failure path (`err` sets rc=1).
|
||||
- **Interactive prompts** (`read … </dev/tty`): drive them with a PTY — `printf 'answer\n' | script -qec "cmd" /dev/null` — then assert the side effect (e.g. the chmod-600 creds file lands with the right mode).
|
||||
|
||||
|
||||
+14
-8
@@ -133,6 +133,8 @@ Runs a persistent `aria2c` JSON-RPC daemon (`localhost:6800`) as a **systemd use
|
||||
| `pos network download retry <gid\|all>` | Smart retry of errored downloads: waits out internet outages (poll `--interval`, give up after `--max-wait`), then re-queues and re-verifies. Sources failing with aria2 error 3 are marked permanent in `~/.config/linux_post_install/download.retry` (id = `url:<uri>` / `bt:<infohash>`) and skipped by `retry all` — manual `restart` overrides. `--once` (healer timer mode) skips the wait and exits 0 even on failure; `--quiet` silences output. Options `--dir`, `--seed`, `--split`, `--tmux` |
|
||||
| `pos network download replace <gid> <url>` | Give a dead download a fresh URL: re-queues with the same `dir` + file name (so `--continue=true` resumes the partial), forgets the old dead source from `download.retry`, and verifies the new link — a dead replacement is diagnosed and marked permanent. `status` lists downloads needing this. Single-file HTTP/FTP only (torrents: `restart`); options `--dir`, `--split`, `--tmux` |
|
||||
|
||||
**`pos network download menu`** — bare invocation on a terminal (or the explicit `menu` subcommand) opens an interactive hub over the top verbs: daemon status, overview (status + queue snapshot), list, add URL (asked via a prompt, with an optional `--tmux` handover), gid-pick → info / pause / resume / remove / restart (remove behind an explicit y/N confirm naming the download), purge (typed-`purge` confirm), live watch (Ctrl-C leaves the menu), and daemon start/stop (stop behind a y/N confirm). Queue views are gated on a non-fatal RPC liveness probe — with the daemon down you get a graceful hint and stay in the menu instead of an error exit. Arguments stay scriptable; without a terminal the menu fails closed with a pointer to these subcommands.
|
||||
|
||||
**`--tmux`:** after enqueueing, `add`/`torrent`/`metalink` open a detached tmux session `dl-<name>` running `watch <gid>` (name from `--out` or the URL basename, sanitized and truncated to 40 chars; `-2` suffix on collision). The session closes itself when the download finishes — attach with `tmux attach -t dl-<name>`.
|
||||
|
||||
**Outage resilience:** `watch <gid>` auto-restarts its download when the network comes back (it polls `NET_PROBE`, default `timeout 3 bash -c '</dev/tcp/8.8.8.8/53'`). For unattended machines the **retry healer** timer (`pos-aria2-retry.timer`, systemd **user** scope) runs `retry all --once --quiet` every 2 min; it arms automatically whenever a download starts (`add`/`torrent`/`metalink`/`restart`) and disables itself when no active, waiting, or errored downloads remain. When a source is genuinely gone (aria2 error 3, e.g. a 404), the download is marked permanent — `status` prints a `needs fresh link: <name> (<gid>)` line and `replace <gid> <url>` resumes it with a new URL. Both are dry-run aware.
|
||||
@@ -172,6 +174,8 @@ Runs a persistent `aria2c` JSON-RPC daemon (`localhost:6800`) as a **systemd use
|
||||
|
||||
**`pos docker compose config edit`** — open the global config in `$EDITOR` (creates a default file first).
|
||||
|
||||
**`pos docker compose menu`** — bare invocation on a terminal (or the explicit `menu` subcommand) opens an interactive hub wrapping these commands: list templates / deployed stacks (+ status), `up` (pick a template — first-deploy `.env`/TS_AUTHKEY prompts included), `down`/`restart` (each behind an explicit y/N confirm naming the stack), follow logs (`-f`; Ctrl-C returns to the menu), `update` (y/N confirm naming `$SERVICES_BASE`; `.env` never touched), and global config show/edit. Arguments stay scriptable; without a terminal the menu fails closed with a pointer to these subcommands.
|
||||
|
||||
Configuration (three layers, most specific wins):
|
||||
|
||||
| Layer | File | Notes |
|
||||
@@ -201,6 +205,8 @@ Global config keys:
|
||||
| `pos docker vbox start/stop/rm <name>` | Start, stop, or force-remove the container |
|
||||
| `pos docker vbox ls` | List vbox containers only (label filter) |
|
||||
|
||||
**`pos docker vbox menu`** — bare invocation on a terminal (or the explicit `menu` subcommand) opens an interactive hub wrapping these verbs: list, create (asks name/image/host directory, y/N before anything is pulled), enter (hands the terminal to the container shell — `exit` returns to the menu), start/stop (pick a VM), and remove (y/N confirm naming the VM; `rm -f` removes the container, the host folder is kept). Arguments stay scriptable; without a terminal the menu fails closed with a pointer to these subcommands.
|
||||
|
||||
The standalone `vbox` command still works and forwards to `pos docker vbox` (see [Legacy wrappers](#legacy-wrappers)).
|
||||
|
||||
### media
|
||||
@@ -209,7 +215,7 @@ The standalone `vbox` command still works and forwards to `pos docker vbox` (see
|
||||
|---------|------|---------|---------------|
|
||||
| `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 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` |
|
||||
| `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`) |
|
||||
|
||||
A watch link carrying **both** `?v=` and `&list=` downloads only that single video
|
||||
@@ -228,10 +234,10 @@ reported as "N videos require sign-in — skipped" (escape hatch:
|
||||
| Command | File | Purpose | Configuration |
|
||||
|---------|------|---------|---------------|
|
||||
| `sudo pos system firewall` | `bin/pos-system-firewall` | Interactive UFW ("UFW POWER") menu: add/delete rules, status, enable/disable/reset, default policies | Must run as root. Every command is previewed and confirmed before execution; supports `--dry-run`; keeps a history of executed commands. Executed mutating changes are announced via `lib/notify.sh` |
|
||||
| `pos system backup <folder-path>` | `bin/pos-system-backup` | Create a gpg-encrypted (AES-256) `tar.gz` snapshot of a folder and verify it | Prompts twice for a password (never stored). Uses `sudo tar`; needs `gnupg` (in `preinstall.sh` PACKAGES) only when encrypting. Artifact `<name>_<date>.tar.gz[.gpg]` in the current directory, `chmod 600`; `--no-encrypt` (or `BACKUP_ENCRYPT=0`) keeps a plain `.tar.gz` with no password prompt (headless/cron safe). Once the archive verifies, connected USB storage is offered (detected via `lsblk` TRAN with `lsusb`/by-id cross-check; unmounted sticks get a mount offer first — see `DOC/howto/system.md`; or pinned with `BACKUP_USB_ROOT`): the copy lands in `<usb>/backups/` and is proven 100% by sha256 before it is announced. Success/failure are announced via `lib/notify.sh` |
|
||||
| `pos system backup <folder-path>` | `bin/pos-system-backup` | Create a gpg-encrypted (AES-256) `tar.gz` snapshot of a folder and verify it | Prompts twice for a password (never stored). Uses `sudo tar`; needs `gnupg` (in `preinstall.sh` PACKAGES) only when encrypting. Artifact `<name>_<date>.tar.gz[.gpg]` in the current directory, `chmod 600`; `--no-encrypt` (or `BACKUP_ENCRYPT=0`) keeps a plain `.tar.gz` with no password prompt (headless/cron safe). Once the archive verifies, connected USB storage is offered (detected via `lsblk` TRAN with `lsusb`/by-id cross-check; unmounted sticks get a mount offer first — see `DOC/howto/system.md`; or pinned with `BACKUP_USB_ROOT`): the copy lands in `<usb>/backups/` and is proven 100% by sha256 before it is announced. Success/failure are announced via `lib/notify.sh`. Bare invocation on a terminal (or the `menu` subcommand) opens an interactive menu wrapping these modes (encrypted backup with typed folder, encrypted backup picked from the service roots, unencrypted variant) — each runs only after an explicit y/N confirm naming the folder; arguments stay scriptable |
|
||||
| `pos system backup --service` | `bin/pos-system-backup` | Lists folders under `/srv` and `~/srv`, lets you pick one, then runs the same backup | Roots via `BACKUP_SERVICE_ROOTS` (space-separated, default `/srv $HOME/srv`) or `~/.config/linux_post_install/system.env` |
|
||||
| `pos system health` | `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 |
|
||||
| `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 |
|
||||
|
||||
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).
|
||||
|
||||
@@ -264,14 +270,14 @@ Share files and devices over the network (USB over network, NFS, SMB/Samba).
|
||||
| `pos share usb server --port [num]` | Set the TCP port (restart server to apply) |
|
||||
| `pos share usb server --info` / `--version` | Show server info / version |
|
||||
|
||||
Subcommands that need input prompt interactively when args are omitted.
|
||||
Subcommands that need input prompt interactively when args are omitted. Bare invocation (`pos share usb server`, no args) opens an interactive menu wrapping all of the above — device/client pickers parse the server listing, and when the listing can't be read it is shown raw with manual ID entry as fallback.
|
||||
|
||||
| Command | File | Purpose | Configuration |
|
||||
|---------|------|---------|---------------|
|
||||
| `pos share nfs server <cmd>` | `bin/pos-share-nfs-server` | Manage the NFS kernel server: `status`, `share <path> [client]`, `unshare <path>`, `list`, `reload`, `enable`, `disable` | Requires `nfs-kernel-server` (added to `preinstall.sh` PACKAGES). Exports live in `/etc/exports`; `share` is idempotent (replaces any existing line for the path) and runs `exportfs -ra`. Default client `*(rw,sync,no_subtree_check)` — the tool warns you to restrict it; help prints Tailscale CGNAT (`100.64.0.0/10`), WireGuard (`10.10.0.0/24`) and LAN examples. Mutating commands announce via `lib/notify.sh` |
|
||||
| `pos share nfs client <cmd>` | `bin/pos-share-nfs-client` | Mount and manage NFS shares: `mount <server:export> <local-dir>`, `unmount <local-dir>`, `list`, `persist <server:export> <local-dir>`, `unpersist <local-dir>` | Requires `nfs-common` (added to `preinstall.sh` PACKAGES). `persist` writes a systemd `.mount` unit (`systemd-escape --path --suffix=mount`) with `After=network-online.target` / `Wants=network-online.target` — mounts only once all interfaces are up, no fstab edits to break boot — then `daemon-reload` + `enable --now`. `unpersist` stops/disables/removes the unit. `mount`/`persist` announce via `lib/notify.sh` |
|
||||
| `pos share smb server <cmd>` | `bin/pos-share-smb-server` | Manage the Samba server: `status`, `share <path> [name] [--read-only|--guest|--users u1,u2]`, `unshare <name>`, `list`, `adduser <user>`, `deluser <user>`, `reload`, `enable`, `disable` | Requires `samba` (added to `preinstall.sh` PACKAGES). Shares are idempotent marker blocks (`# >>> pos-managed share: <name>` … `# <<< end pos-managed share`) in `/etc/samba/smb.conf` — hand edits outside the markers survive; `share` validates with `testparm` before applying and hot-reloads via `smbcontrol smbd reload-config`. Defaults rw + browsable; warns when unrestricted (guest or no `valid users`). `adduser`/`deluser` manage Samba accounts via `smbpasswd`. Mutating commands announce via `lib/notify.sh` |
|
||||
| `pos share smb client <cmd>` | `bin/pos-share-smb-client` | Mount and manage SMB/CIFS shares: `mount <//server/share> <local-dir> [user]`, `unmount <local-dir>`, `list`, `persist <//server/share> <local-dir> [user]`, `unpersist <local-dir>` | Requires `cifs-utils` (added to `preinstall.sh` PACKAGES). With a user you are prompted for the Samba password — one-shot mounts use a throwaway chmod-600 credentials file, `persist` keeps one at `/etc/samba/credentials/<name>` (chmod 600). `persist` writes systemd `.mount` **and** `.automount` units (`systemd-escape --path --suffix=mount`) with `_netdev` — the automount defers the actual mount until first access, never blocks boot — then `daemon-reload` + `enable --now` the automount. `unpersist` stops/disables/removes both units + credentials. `list` shows active mounts (`findmnt -t cifs`) **and** persistent units (as automount shares aren't mounted until first access, they'd otherwise be invisible). `mount`/`persist` announce via `lib/notify.sh` |
|
||||
| `pos share nfs server <cmd>` | `bin/pos-share-nfs-server` | Manage the NFS kernel server: `status`, `share <path> [client]`, `unshare <path>`, `list`, `reload`, `enable`, `disable` | Requires `nfs-kernel-server` (added to `preinstall.sh` PACKAGES). Exports live in `/etc/exports`; `share` is idempotent (replaces any existing line for the path) and runs `exportfs -ra`. Default client `*(rw,sync,no_subtree_check)` — the tool warns you to restrict it; help prints Tailscale CGNAT (`100.64.0.0/10`), WireGuard (`10.10.0.0/24`) and LAN examples. Mutating commands announce via `lib/notify.sh`. Bare invocation opens an interactive menu (share/unshare/list/reload/enable/disable) — the share flow picks a folder from mounted candidates, names it, and offers client-spec presets (open/WireGuard/LAN/single-IP); inactive-service and UFW conflicts are surfaced as optional fixes |
|
||||
| `pos share nfs client <cmd>` | `bin/pos-share-nfs-client` | Mount and manage NFS shares: `mount <server:export> <local-dir>`, `unmount <local-dir>`, `list`, `persist <server:export> <local-dir>`, `unpersist <local-dir>` | Requires `nfs-common` (added to `preinstall.sh` PACKAGES). `persist` writes a systemd `.mount` unit (`systemd-escape --path --suffix=mount`) with `After=network-online.target` / `Wants=network-online.target` — mounts only once all interfaces are up, no fstab edits to break boot — then `daemon-reload` + `enable --now`. `unpersist` stops/disables/removes the unit. `mount`/`persist` announce via `lib/notify.sh`. Bare invocation opens an interactive menu (mount/persist/unmount/unpersist/list) with mountpoint candidates + manual entry; unmount/persist removals are idempotent (already-absent targets are reported, not errors) |
|
||||
| `pos share smb server <cmd>` | `bin/pos-share-smb-server` | Manage the Samba server: `status`, `share <path> [name] [--read-only|--guest|--users u1,u2]`, `unshare <name>`, `list`, `adduser <user>`, `deluser <user>`, `reload`, `enable`, `disable` | Requires `samba` (added to `preinstall.sh` PACKAGES). Shares are idempotent marker blocks (`# >>> pos-managed share: <name>` … `# <<< end pos-managed share`) in `/etc/samba/smb.conf` — hand edits outside the markers survive; `share` validates with `testparm` before applying and hot-reloads via `smbcontrol smbd reload-config`. Defaults rw + browsable; warns when unrestricted (guest or no `valid users`). `adduser`/`deluser` manage Samba accounts via `smbpasswd`. Mutating commands announce via `lib/notify.sh`. Bare invocation opens an interactive menu (share/unshare/list/users/reload/enable/disable) — the share flow picks a folder from mounted candidates and walks through read-only/guest/valid-users confirms; UFW conflicts are surfaced as an optional fix |
|
||||
| `pos share smb client <cmd>` | `bin/pos-share-smb-client` | Mount and manage SMB/CIFS shares: `mount <//server/share> <local-dir> [user]`, `unmount <local-dir>`, `list`, `persist <//server/share> <local-dir> [user]`, `unpersist <local-dir>` | Requires `cifs-utils` (added to `preinstall.sh` PACKAGES). With a user you are prompted for the Samba password — one-shot mounts use a throwaway chmod-600 credentials file, `persist` keeps one at `/etc/samba/credentials/<name>` (chmod 600). `persist` writes systemd `.mount` **and** `.automount` units (`systemd-escape --path --suffix=mount`) with `_netdev` — the automount defers the actual mount until first access, never blocks boot — then `daemon-reload` + `enable --now` the automount. `unpersist` stops/disables/removes both units + credentials. `list` shows active mounts (`findmnt -t cifs`) **and** persistent units (as automount shares aren't mounted until first access, they'd otherwise be invisible). `mount`/`persist` announce via `lib/notify.sh`. Bare invocation opens an interactive menu (enumerate/mount/persist/unmount/unpersist/list): it can enumerate Disk shares via smbclient (empty user = guest try, auth retry on denial), pick a share + mountpoint from candidates (manual entry fallback), and reuses the authenticated account for the mount |
|
||||
|
||||
### communication
|
||||
|
||||
|
||||
+15
-1
@@ -38,7 +38,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` → `/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` → `/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) |
|
||||
@@ -214,6 +214,20 @@ Sourced by `bin/pos-entertainment-send|config|enable|disable|status` (after `lib
|
||||
|
||||
---
|
||||
|
||||
## lib/share-lib.sh — share-suite domain layer + compat shims
|
||||
|
||||
**File:** `lib/share-lib.sh` (installed to `/usr/local/bin/share-lib.sh`)
|
||||
**Purpose:** the domain probes/listings behind the five share tools (`pos share nfs server`, `pos share nfs client`, `pos share smb client`, `pos share smb server`, `pos share usb server`) — rc-only probes `share_require_bin`/`share_port_probe`/`share_service_active`/`share_path_probe`, remote listings `share_nfs_exports` (showmount) / `share_smb_shares` (smbclient `-g` Disk enumeration incl. guest→auth retry) / `share_usb_records` + `share_usb_devices`/`share_usb_clients` (blank-line-record usbsrv parsers), `share_folder_candidates` (bounded-probe scan — findmnt targets minus pseudo-fs/ro plus immediate dirs under `/mnt` `/srv` `/media` `/export`, annotated + byte-order sorted; the client tools layer their own mountpoint pickers on top), and advisories (`share_ufw_blocks_ports` for firewall conflicts + `share_offer_fix`, which also uses `share_service_active` to offer starting inactive services). Also sources `lib/menu-lib.sh` and re-exports its primitives under the historical names (`share_menu_guard`, `share_menu_run`, `share_pick`, `share_ask_value`) so the five tools' menus need no changes. Defines only `share_*`; never exits, writes nothing of its own, display→stderr/result→stdout; no seams of its own — the tools own their file/service seams.
|
||||
|
||||
---
|
||||
|
||||
## lib/menu-lib.sh — category-neutral menu primitives
|
||||
|
||||
**File:** `lib/menu-lib.sh` (installed to `/usr/local/bin/menu-lib.sh`)
|
||||
**Purpose:** the generic interactive half extracted from the former share-lib interactive layer, open to any category's tool — `menu_guard` (non-tty guard: one-line hint instead of a hanging menu), `menu_run` (looping numbered boxed menu, quits on EOF), `menu_pick` (numbered picker with type-to-filter `/filter`, `0` cancel, EOF-safe), and `menu_ask_value` (prompted string with optional default). Sourced by `lib/share-lib.sh` (compat shims keep the `share_*` names working); defines only `menu_*`. Display→stderr / result→stdout, reads fail closed on EOF or non-tty, so the functions are safe under the dispatcher's logging tee and inside command substitution; standalone-sourced it degrades to plain text via guarded `CYAN`/`RESET` fallbacks.
|
||||
|
||||
---
|
||||
|
||||
## features/autostart.sh — boot-time feature
|
||||
|
||||
**File:** `features/autostart.sh` (installed to `/usr/local/bin/autostart.sh` by `./install.sh --feature`)
|
||||
|
||||
+122
-1
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: docker compose — Docker Compose service manager (ls/up/down/restart/logs/update/config)
|
||||
# POS_SUBCMDS: ls installed up down restart logs update config
|
||||
# POS_SUBCMDS: ls installed up down restart logs update config menu
|
||||
# POS_CONFIG: compose | compose.env | TS_AUTHKEY=secret:Tailscale auth key for the sidecar | TZ=:Service timezone (default Europe/Amsterdam) | DNS_SERVER=:Custom DNS server (default 9.9.9.9) | SERVICES_BASE=:Deployment root (default /srv)
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh"
|
||||
|
||||
SCALE_DIR="${SCALE_DIR:-/usr/local/share/linux_post_install/scale-tail/services}"
|
||||
CONFIG_ENV="${CONFIG_ENV:-${HOME}/.config/linux_post_install/compose.env}"
|
||||
@@ -12,6 +13,9 @@ usage() {
|
||||
cat <<EOF
|
||||
Usage: pos docker compose <command> [args]
|
||||
|
||||
Bare \`pos docker compose\` on a terminal (or \`pos docker compose menu\`) opens
|
||||
an interactive menu wrapping these commands; arguments stay scriptable.
|
||||
|
||||
Commands:
|
||||
ls List all available ScaleTail service templates
|
||||
installed List services already deployed on this machine
|
||||
@@ -340,10 +344,127 @@ EOF
|
||||
esac
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# Interactive menu (opt-in front door, Pattern B via lib/menu-lib.sh)
|
||||
###############################################################################
|
||||
|
||||
menu_list_templates() { # available ScaleTail template names, one per line
|
||||
local d
|
||||
for d in "$SCALE_DIR"/*/; do
|
||||
[ -d "$d" ] || continue
|
||||
basename "$d"
|
||||
done | LC_ALL=C sort
|
||||
}
|
||||
|
||||
menu_list_deployed() { # deployed stack names under $SERVICES_BASE, one per line
|
||||
local d
|
||||
[ -d "$SERVICES_BASE" ] || return 0
|
||||
for d in "$SERVICES_BASE"/*/; do
|
||||
[ -d "$d" ] || continue
|
||||
basename "$d"
|
||||
done | LC_ALL=C sort
|
||||
}
|
||||
|
||||
# $1 = prompt · $2 = listing function · $3 = empty-state hint → picked name
|
||||
menu_pick_stack() {
|
||||
local -a items=()
|
||||
mapfile -t items < <("$2")
|
||||
if [ ${#items[@]} -eq 0 ]; then
|
||||
warn "$3"
|
||||
return 1
|
||||
fi
|
||||
local idx
|
||||
idx="$(menu_pick "$1" "${items[@]}")" || return 1
|
||||
printf '%s\n' "${items[$((idx - 1))]}"
|
||||
}
|
||||
|
||||
menu_up() {
|
||||
local svc
|
||||
svc="$(menu_pick_stack "Deploy / start which template?" menu_list_templates \
|
||||
"No ScaleTail templates found at $SCALE_DIR — run install.sh to set them up")" || return 0
|
||||
cmd_up "$svc" # first-deploy .env/TS_AUTHKEY prompts fold in here
|
||||
}
|
||||
|
||||
menu_down() {
|
||||
local svc
|
||||
load_global_config
|
||||
svc="$(menu_pick_stack "Stop and remove which stack?" menu_list_deployed \
|
||||
"Nothing deployed under $SERVICES_BASE — deploy a template first ('up')")" || return 0
|
||||
confirm "Stop and remove stack '$svc' (docker compose down)?" n || { log "Cancelled"; return 0; }
|
||||
cmd_down "$svc"
|
||||
}
|
||||
|
||||
menu_restart() {
|
||||
local svc
|
||||
load_global_config
|
||||
svc="$(menu_pick_stack "Restart which stack?" menu_list_deployed \
|
||||
"Nothing deployed under $SERVICES_BASE — deploy a template first ('up')")" || return 0
|
||||
confirm "Restart stack '$svc'?" n || { log "Cancelled"; return 0; }
|
||||
cmd_restart "$svc"
|
||||
}
|
||||
|
||||
menu_logs() {
|
||||
local svc
|
||||
load_global_config
|
||||
svc="$(menu_pick_stack "Logs of which stack?" menu_list_deployed \
|
||||
"Nothing deployed under $SERVICES_BASE — deploy a template first ('up')")" || return 0
|
||||
log "Following logs of '$svc' — Ctrl-C returns to the menu"
|
||||
if ! cmd_logs "$svc" -f; then
|
||||
log "Returned from logs of '$svc'"
|
||||
fi
|
||||
}
|
||||
|
||||
menu_update() {
|
||||
load_global_config
|
||||
confirm "Update ALL stacks: pull latest ScaleTail templates and overwrite deployed compose files under $SERVICES_BASE (.env preserved)?" n \
|
||||
|| { log "Cancelled"; return 0; }
|
||||
cmd_update
|
||||
}
|
||||
|
||||
run_menu() {
|
||||
menu_guard || exit 1
|
||||
while true; do
|
||||
local choice
|
||||
choice="$(menu_run "Docker Compose (ScaleTail)" \
|
||||
"List available service templates" \
|
||||
"List deployed stacks (+ status)" \
|
||||
"Deploy / start a stack (up)" \
|
||||
"Stop and remove a deployed stack (down)" \
|
||||
"Restart a deployed stack" \
|
||||
"Follow a stack's logs (-f; Ctrl-C returns)" \
|
||||
"Update templates + refresh deployed compose files" \
|
||||
"Show global config" \
|
||||
"Edit global config (\$EDITOR)")" || return 0
|
||||
case "$choice" in
|
||||
1) cmd_ls ;;
|
||||
2) cmd_installed ;;
|
||||
3) menu_up ;;
|
||||
4) menu_down ;;
|
||||
5) menu_restart ;;
|
||||
6) menu_logs ;;
|
||||
7) menu_update ;;
|
||||
8) cmd_config show ;;
|
||||
9) cmd_config edit ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# CLI dispatch
|
||||
###############################################################################
|
||||
|
||||
# Menu door: explicit verb, or zero args on a terminal. Everything below —
|
||||
# including zero args without a terminal — stays byte-compatible with the
|
||||
# pre-menu CLI.
|
||||
if [ "${1:-}" = "menu" ]; then
|
||||
run_menu
|
||||
exit 0
|
||||
fi
|
||||
if [ $# -eq 0 ] && [ -t 0 ]; then
|
||||
run_menu
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[ $# -eq 0 ] && usage
|
||||
|
||||
case "${1:-}" in
|
||||
|
||||
+104
-1
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: docker vbox — Disposable Docker-based VMs (create/enter/start/stop/rm/ls)
|
||||
# POS_SUBCMDS: create enter stop start rm ls
|
||||
# POS_SUBCMDS: create enter stop start rm ls menu
|
||||
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"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -16,6 +17,9 @@ Usage:
|
||||
|
||||
Manage disposable Docker containers as lightweight VMs.
|
||||
|
||||
Bare \`pos docker vbox\` on a terminal (or \`pos docker vbox menu\`) opens an
|
||||
interactive menu wrapping these verbs; arguments stay scriptable.
|
||||
|
||||
Each container gets a bind-mounted host directory so files persist
|
||||
on the host even after the container is removed.
|
||||
|
||||
@@ -37,6 +41,105 @@ EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── Interactive menu (opt-in front door, Pattern B via lib/menu-lib.sh) ──
|
||||
# Every item re-enters the existing CLI verbs below unchanged — the menu adds
|
||||
# picks/prompts/confirms only (same self-invocation the tool itself uses for
|
||||
# tmux handovers). 'enter' hands the terminal to the container shell and
|
||||
# returns to the loop when the shell exits.
|
||||
menu_self() { # run this tool's own verb as a child process
|
||||
"$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" "$@"
|
||||
}
|
||||
|
||||
menu_list_vms() { # vbox VM names, one per line (same label filter as 'ls')
|
||||
docker ps -a --filter label=linux_post_install.vbox=true --format '{{.Names}}' | LC_ALL=C sort
|
||||
}
|
||||
|
||||
menu_pick_vm() { # $1 = prompt → picked VM name on stdout · rc 1 = cancelled / none
|
||||
local -a vms=()
|
||||
mapfile -t vms < <(menu_list_vms 2>/dev/null)
|
||||
if [ ${#vms[@]} -eq 0 ]; then
|
||||
if docker info &>/dev/null; then
|
||||
warn "no vbox VMs found — create one first ('pos docker vbox create <name>')"
|
||||
else
|
||||
warn "docker daemon not reachable — start Docker first"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
local idx
|
||||
idx="$(menu_pick "$1" "${vms[@]}")" || return 1
|
||||
printf '%s\n' "${vms[$((idx - 1))]}"
|
||||
}
|
||||
|
||||
menu_vbox_create() {
|
||||
local name image dir
|
||||
name="$(menu_ask_value "VM name")" || return 0
|
||||
image="$(menu_ask_value "Image" "ubuntu:22.04")" || return 0
|
||||
dir="$(menu_ask_value "Host directory (empty = ~/$name)")" || true
|
||||
if [ -n "${dir:-}" ]; then
|
||||
confirm "Create VM '$name' from $image (host dir: $dir)?" n \
|
||||
|| { log "Cancelled"; return 0; }
|
||||
menu_self create "$name" "$image" --dir "$dir"
|
||||
else
|
||||
confirm "Create VM '$name' from $image?" n || { log "Cancelled"; return 0; }
|
||||
menu_self create "$name" "$image"
|
||||
fi
|
||||
}
|
||||
|
||||
menu_vbox_enter() {
|
||||
local vm
|
||||
vm="$(menu_pick_vm "Enter which VM?")" || return 0
|
||||
log "Entering '$vm' — exit its shell to return to the menu"
|
||||
menu_self enter "$vm"
|
||||
}
|
||||
|
||||
menu_vbox_verb() { # $1 = start|stop — pick a VM, run the verb
|
||||
local verb="$1" vm
|
||||
vm="$(menu_pick_vm "${verb} which VM?")" || return 0
|
||||
menu_self "$verb" "$vm"
|
||||
}
|
||||
|
||||
menu_vbox_remove() {
|
||||
local vm
|
||||
vm="$(menu_pick_vm "Remove which VM?")" || return 0
|
||||
confirm "Permanently remove VM '$vm' (docker rm -f; the host folder is kept)?" n \
|
||||
|| { log "Cancelled — '$vm' kept"; return 0; }
|
||||
menu_self rm "$vm"
|
||||
}
|
||||
|
||||
run_menu() {
|
||||
menu_guard || exit 1
|
||||
while true; do
|
||||
local choice
|
||||
choice="$(menu_run "Docker VBox — disposable VMs" \
|
||||
"List VMs" \
|
||||
"Create a VM" \
|
||||
"Enter a VM (interactive shell)" \
|
||||
"Start a VM" \
|
||||
"Stop a VM" \
|
||||
"Remove a VM (permanent)")" || return 0
|
||||
case "$choice" in
|
||||
1) menu_self ls ;;
|
||||
2) menu_vbox_create ;;
|
||||
3) menu_vbox_enter ;;
|
||||
4) menu_vbox_verb start ;;
|
||||
5) menu_vbox_verb stop ;;
|
||||
6) menu_vbox_remove ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Menu door: explicit verb, or zero args on a terminal. Everything below —
|
||||
# including zero args without a terminal — stays byte-compatible with the
|
||||
# pre-menu CLI.
|
||||
if [ "${1:-}" = "menu" ]; then
|
||||
run_menu
|
||||
exit 0
|
||||
fi
|
||||
if [ $# -eq 0 ] && [ -t 0 ]; then
|
||||
run_menu
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help) usage ;;
|
||||
esac
|
||||
|
||||
+146
-94
@@ -2,10 +2,12 @@
|
||||
set -euo pipefail
|
||||
# POS: media sync — Incremental Music → USB sync (mp3/mp4, add/update only)
|
||||
# POS_FLAGS: --mp3 --mp4 --source --dry-run
|
||||
# POS_SUBCMDS: menu
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh"
|
||||
source "$(dirname "$0")/../lib/usb-lib.sh" 2>/dev/null || source "$(dirname "$0")/usb-lib.sh"
|
||||
source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh"
|
||||
|
||||
load_system_env
|
||||
|
||||
@@ -29,6 +31,9 @@ USB storage is detected like \`pos system backup\` (lsblk TRAN + lsusb/by-id
|
||||
corroboration; unmounted sticks get a mount offer first) and you pick which
|
||||
one to sync to. The source tree is mirrored under <usb>/$DEST_DIR.
|
||||
|
||||
Bare \`pos media sync\` on a terminal (or \`pos media sync menu\`) opens an
|
||||
interactive menu wrapping these same actions; flags stay scriptable.
|
||||
|
||||
Options:
|
||||
--mp3 Sync only *.mp3 files
|
||||
--mp4 Sync only *.mp4 files
|
||||
@@ -53,6 +58,146 @@ EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── Interactive menu (opt-in front door, Pattern B via lib/menu-lib.sh) ──
|
||||
menu_change_source() {
|
||||
local val
|
||||
val="$(menu_ask_value "Source folder [current: $SRC]")" || return 0
|
||||
if [ ! -d "$val" ]; then
|
||||
warn "Not a folder: $val"
|
||||
return 0
|
||||
fi
|
||||
SRC="$val"
|
||||
log "Source set to $SRC"
|
||||
}
|
||||
|
||||
run_menu() {
|
||||
menu_guard || exit 1
|
||||
while true; do
|
||||
local choice
|
||||
choice="$(menu_run "Music sync" \
|
||||
"Sync now — mp3 + mp4" \
|
||||
"Preview only (--dry-run)" \
|
||||
"Sync mp3 only" \
|
||||
"Sync mp4 only" \
|
||||
"Change source folder (current: $SRC)")" || return 0
|
||||
case "$choice" in
|
||||
1) MP3=1; MP4=1; DRY_RUN=0; cmd_sync ;;
|
||||
2) MP3=1; MP4=1; DRY_RUN=1; cmd_sync ;;
|
||||
3) MP3=1; MP4=0; DRY_RUN=0; cmd_sync ;;
|
||||
4) MP3=0; MP4=1; DRY_RUN=0; cmd_sync ;;
|
||||
5) menu_change_source ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
cmd_sync() {
|
||||
[ -d "$SRC" ] || err "Source not found: $SRC"
|
||||
|
||||
trap 'notify_send "Music sync FAILED"' ERR
|
||||
|
||||
section "Music sync"
|
||||
echo "Source : $SRC"
|
||||
if [ "$MP3" -eq 1 ] && [ "$MP4" -eq 1 ]; then
|
||||
echo "Filter : mp3 + mp4"
|
||||
find_expr=(-type f \( -iname '*.mp3' -o -iname '*.mp4' \))
|
||||
elif [ "$MP3" -eq 1 ]; then
|
||||
echo "Filter : mp3 only"
|
||||
find_expr=(-type f -iname '*.mp3')
|
||||
else
|
||||
echo "Filter : mp4 only"
|
||||
find_expr=(-type f -iname '*.mp4')
|
||||
fi
|
||||
|
||||
usb_pick_root "Sync to" "$DEST_DIR" "no sync performed" || {
|
||||
log "Skipped — no sync performed"
|
||||
return 0
|
||||
}
|
||||
dest_root="${USB_ROOT%/}/$DEST_DIR"
|
||||
echo "Target : $dest_root"
|
||||
|
||||
# Does the destination need this file copied? Missing, or size/mtime differs.
|
||||
needs_copy() {
|
||||
local src="$1" dst="$2" ss="" ds="" sm="" dm=""
|
||||
[ -f "$dst" ] || return 0
|
||||
ss="$(stat -c %s "$src" 2>/dev/null || printf 0)"
|
||||
ds="$(stat -c %s "$dst" 2>/dev/null || printf 0)"
|
||||
sm="$(stat -c %Y "$src" 2>/dev/null || printf 0)"
|
||||
dm="$(stat -c %Y "$dst" 2>/dev/null || printf 0)"
|
||||
[ "$ss" = "$ds" ] && [ "$sm" -le "$dm" ] && return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# Pre-flight: fail early on "won't fit" instead of a mid-copy ENOSPC that
|
||||
# leaves a half-written target. Measures exactly what needs_copy would copy.
|
||||
space_path="$USB_ROOT"
|
||||
if [ "$DRY_RUN" -eq 0 ]; then
|
||||
mkdir -p "$dest_root"
|
||||
space_path="$dest_root"
|
||||
fi
|
||||
need_kb=0
|
||||
while IFS= read -r f; do
|
||||
rel="${f#"$SRC/"}"
|
||||
if needs_copy "$f" "$dest_root/$rel"; then
|
||||
sz="$(stat -c %s "$f" 2>/dev/null || printf 0)"
|
||||
need_kb=$((need_kb + (sz + 1023) / 1024))
|
||||
fi
|
||||
done < <(find -H "$SRC" "${find_expr[@]}")
|
||||
have_kb="$(df -Pk "$space_path" 2>/dev/null | awk 'NR==2 {print $4}')"
|
||||
have_kb="${have_kb:-0}"
|
||||
if [ "$need_kb" -gt "$have_kb" ]; then
|
||||
msg="Not enough free space on ${USB_ROOT%/}: need ~${need_kb}K, have ${have_kb}K"
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
warn "$msg"
|
||||
else
|
||||
err "$msg"
|
||||
fi
|
||||
fi
|
||||
|
||||
added=0
|
||||
updated=0
|
||||
unchanged=0
|
||||
while IFS= read -r f; do
|
||||
rel="${f#"$SRC/"}"
|
||||
dest="$dest_root/$rel"
|
||||
|
||||
if needs_copy "$f" "$dest"; then
|
||||
if [ -f "$dest" ]; then
|
||||
updated=$((updated + 1))
|
||||
else
|
||||
added=$((added + 1))
|
||||
fi
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
log "would copy $rel"
|
||||
else
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
cp --preserve=timestamps "$f" "$dest"
|
||||
echo " + $rel"
|
||||
fi
|
||||
else
|
||||
unchanged=$((unchanged + 1))
|
||||
fi
|
||||
done < <(find -H "$SRC" "${find_expr[@]}" | sort)
|
||||
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "DRY RUN — nothing copied. Would sync: ${added} new, ${updated} updated, ${unchanged} unchanged → $dest_root"
|
||||
else
|
||||
ok "Sync complete: ${added} added, ${updated} updated, ${unchanged} unchanged → $dest_root"
|
||||
notify_send "Music sync completed: ${added} added, ${updated} updated → $dest_root"
|
||||
fi
|
||||
}
|
||||
|
||||
# Menu door: explicit verb, or zero args on a terminal. Everything below —
|
||||
# including zero args without a terminal — stays byte-compatible with the
|
||||
# pre-menu CLI.
|
||||
if [ "${1:-}" = "menu" ]; then
|
||||
run_menu
|
||||
exit 0
|
||||
fi
|
||||
if [ $# -eq 0 ] && [ -t 0 ]; then
|
||||
run_menu
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help) usage ;;
|
||||
@@ -68,97 +213,4 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
[ "$MP3" -eq 1 ] || [ "$MP4" -eq 1 ] || { MP3=1; MP4=1; }
|
||||
|
||||
[ -d "$SRC" ] || err "Source not found: $SRC"
|
||||
|
||||
trap 'notify_send "Music sync FAILED"' ERR
|
||||
|
||||
section "Music sync"
|
||||
echo "Source : $SRC"
|
||||
if [ "$MP3" -eq 1 ] && [ "$MP4" -eq 1 ]; then
|
||||
echo "Filter : mp3 + mp4"
|
||||
find_expr=(-type f \( -iname '*.mp3' -o -iname '*.mp4' \))
|
||||
elif [ "$MP3" -eq 1 ]; then
|
||||
echo "Filter : mp3 only"
|
||||
find_expr=(-type f -iname '*.mp3')
|
||||
else
|
||||
echo "Filter : mp4 only"
|
||||
find_expr=(-type f -iname '*.mp4')
|
||||
fi
|
||||
|
||||
usb_pick_root "Sync to" "$DEST_DIR" "no sync performed" || {
|
||||
log "Skipped — no sync performed"
|
||||
exit 0
|
||||
}
|
||||
dest_root="${USB_ROOT%/}/$DEST_DIR"
|
||||
echo "Target : $dest_root"
|
||||
|
||||
# Does the destination need this file copied? Missing, or size/mtime differs.
|
||||
needs_copy() {
|
||||
local src="$1" dst="$2" ss="" ds="" sm="" dm=""
|
||||
[ -f "$dst" ] || return 0
|
||||
ss="$(stat -c %s "$src" 2>/dev/null || printf 0)"
|
||||
ds="$(stat -c %s "$dst" 2>/dev/null || printf 0)"
|
||||
sm="$(stat -c %Y "$src" 2>/dev/null || printf 0)"
|
||||
dm="$(stat -c %Y "$dst" 2>/dev/null || printf 0)"
|
||||
[ "$ss" = "$ds" ] && [ "$sm" -le "$dm" ] && return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# Pre-flight: fail early on "won't fit" instead of a mid-copy ENOSPC that
|
||||
# leaves a half-written target. Measures exactly what needs_copy would copy.
|
||||
space_path="$USB_ROOT"
|
||||
if [ "$DRY_RUN" -eq 0 ]; then
|
||||
mkdir -p "$dest_root"
|
||||
space_path="$dest_root"
|
||||
fi
|
||||
need_kb=0
|
||||
while IFS= read -r f; do
|
||||
rel="${f#"$SRC/"}"
|
||||
if needs_copy "$f" "$dest_root/$rel"; then
|
||||
sz="$(stat -c %s "$f" 2>/dev/null || printf 0)"
|
||||
need_kb=$((need_kb + (sz + 1023) / 1024))
|
||||
fi
|
||||
done < <(find -H "$SRC" "${find_expr[@]}")
|
||||
have_kb="$(df -Pk "$space_path" 2>/dev/null | awk 'NR==2 {print $4}')"
|
||||
have_kb="${have_kb:-0}"
|
||||
if [ "$need_kb" -gt "$have_kb" ]; then
|
||||
msg="Not enough free space on ${USB_ROOT%/}: need ~${need_kb}K, have ${have_kb}K"
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
warn "$msg"
|
||||
else
|
||||
err "$msg"
|
||||
fi
|
||||
fi
|
||||
|
||||
added=0
|
||||
updated=0
|
||||
unchanged=0
|
||||
while IFS= read -r f; do
|
||||
rel="${f#"$SRC/"}"
|
||||
dest="$dest_root/$rel"
|
||||
|
||||
if needs_copy "$f" "$dest"; then
|
||||
if [ -f "$dest" ]; then
|
||||
updated=$((updated + 1))
|
||||
else
|
||||
added=$((added + 1))
|
||||
fi
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
log "would copy $rel"
|
||||
else
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
cp --preserve=timestamps "$f" "$dest"
|
||||
echo " + $rel"
|
||||
fi
|
||||
else
|
||||
unchanged=$((unchanged + 1))
|
||||
fi
|
||||
done < <(find -H "$SRC" "${find_expr[@]}" | sort)
|
||||
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "DRY RUN — nothing copied. Would sync: ${added} new, ${updated} updated, ${unchanged} unchanged → $dest_root"
|
||||
else
|
||||
ok "Sync complete: ${added} added, ${updated} updated, ${unchanged} unchanged → $dest_root"
|
||||
notify_send "Music sync completed: ${added} added, ${updated} updated → $dest_root"
|
||||
fi
|
||||
cmd_sync
|
||||
|
||||
+154
-1
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: network download — aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits)
|
||||
# POS_SUBCMDS: start stop status add torrent metalink list info files peers pause resume remove purge move limit set watch restart retry replace
|
||||
# POS_SUBCMDS: start stop status add torrent metalink list info files peers pause resume remove purge move limit set watch restart retry replace menu
|
||||
# POS_FLAGS: --dir --out --split --seed --force --upload --gid --tmux
|
||||
|
||||
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"
|
||||
|
||||
command -v aria2c &>/dev/null || err "aria2c not found (install aria2)"
|
||||
command -v jq &>/dev/null || err "jq not found (install jq)"
|
||||
@@ -45,6 +46,9 @@ Usage: pos network download <command> [args]
|
||||
aria2 download daemon + queue control. Runs a persistent aria2c with JSON-RPC
|
||||
(systemd user service on localhost:$RPC_PORT) and drives it via the RPC API.
|
||||
|
||||
Bare \`pos network download\` on a terminal (or \`pos network download menu\`)
|
||||
opens an interactive menu wrapping these commands; arguments stay scriptable.
|
||||
|
||||
Commands:
|
||||
start Start the daemon (installs the systemd user service, generates RPC secret)
|
||||
stop Stop the daemon and remove the service
|
||||
@@ -922,6 +926,142 @@ tmux_watch() {
|
||||
log "tmux session '$sname' started — attach: tmux attach -t '$sname'"
|
||||
}
|
||||
|
||||
# ── Interactive menu (opt-in front door, Pattern B via lib/menu-lib.sh) ──
|
||||
# Top-verb map over the existing cmd_* implementations — picks/prompts/gates
|
||||
# only, no new RPC logic. Queue views are gated on a non-fatal RPC liveness
|
||||
# probe first: rpc() itself err-exits, so a dead daemon must be caught before
|
||||
# it can end the menu; the graceful hint points at the start-daemon item.
|
||||
# This tool deliberately stays OUTSIDE the dispatcher's INTERACTIVE_CMDS (its
|
||||
# verbs are pipe-friendly one-shots that keep their tee logs), so every prompt
|
||||
# here is a lib/menu-lib.sh primitive behind menu_guard's tty proof — no raw
|
||||
# stdin-read helpers.
|
||||
menu_ask_yn() { # $1 = question · rc 0 iff answered yes (default n; EOF cancels)
|
||||
local ans
|
||||
ans="$(menu_ask_value "$1" "N")" || return 1
|
||||
[[ "$ans" =~ ^[Yy] ]]
|
||||
}
|
||||
|
||||
menu_rpc_ok() { # cheap non-fatal liveness probe (same request shape as rpc())
|
||||
curl -fsS --noproxy '*' -m 3 -H 'Content-Type: application/json' \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"aria2.getVersion\",\"params\":[\"token:${RPC_SECRET}\"]}" \
|
||||
"$RPC_URL" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
menu_gate_daemon() { # rc 0 iff the RPC answers · else graceful pointer
|
||||
if menu_rpc_ok; then return 0; fi
|
||||
warn "aria2 RPC unreachable on $RPC_URL — start the daemon first (menu item below, or 'pos network download start')"
|
||||
return 1
|
||||
}
|
||||
|
||||
menu_list_gids() { # "full_gid<tab>status<tab>name" for active + waiting + stopped
|
||||
{ rpc aria2.tellActive; rpc aria2.tellWaiting 0 200; rpc aria2.tellStopped 0 200; } \
|
||||
| jq -r --arg na '?' '.result[]? |
|
||||
[ .gid, .status,
|
||||
(.bittorrent.info.name // (.files[0].path // $na | split("/") | last)) ] | @tsv'
|
||||
}
|
||||
|
||||
menu_pick_gid_row() { # $1 = prompt → "<gid><tab>label" on stdout · rc 1 = cancelled / none
|
||||
local -a rows=() gids=() labels=()
|
||||
mapfile -t rows < <(menu_list_gids)
|
||||
[ ${#rows[@]} -gt 0 ] || { warn "queue is empty — nothing to pick"; return 1; }
|
||||
local r
|
||||
for r in "${rows[@]}"; do
|
||||
gids+=("${r%%$'\t'*}")
|
||||
labels+=("$(printf '%s' "$r" | cut -f2- | tr '\t' ' ')")
|
||||
done
|
||||
local idx
|
||||
idx="$(menu_pick "$1" "${labels[@]}")" || return 1
|
||||
printf '%s\t%s\n' "${gids[$((idx - 1))]}" "${labels[$((idx - 1))]}"
|
||||
}
|
||||
|
||||
menu_pick_gid() { # $1 = prompt → full gid on stdout · rc 1 = cancelled / none
|
||||
local row
|
||||
row="$(menu_pick_gid_row "$1")" || return 1
|
||||
printf '%s\n' "${row%%$'\t'*}"
|
||||
}
|
||||
|
||||
menu_gid_action() { # $1 = info|pause|resume|restart — pick a download, run the verb
|
||||
local verb="$1" gid
|
||||
menu_gate_daemon || return 0
|
||||
gid="$(menu_pick_gid "$verb which download?")" || return 0
|
||||
"cmd_$verb" "$gid"
|
||||
}
|
||||
|
||||
menu_download_add() { # ask for a URL, hand cmd_add the existing flags
|
||||
local url
|
||||
url="$(menu_ask_value "URL to add (download dir: $DOWNLOAD_DIR)")" || return 0
|
||||
[ -n "$url" ] || return 0
|
||||
if menu_ask_yn "Hand live progress to a tmux session (--tmux)?"; then
|
||||
cmd_add --tmux "$url"
|
||||
else
|
||||
log "(watch it later with: pos network download watch)"
|
||||
cmd_add "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
menu_download_remove() {
|
||||
menu_gate_daemon || return 0
|
||||
local row gid label name
|
||||
row="$(menu_pick_gid_row "Remove which download?")" || return 0
|
||||
gid="${row%%$'\t'*}"
|
||||
label="${row#*$'\t'}"
|
||||
name="${label#* }"
|
||||
menu_ask_yn "Remove download '$name' (${gid:0:8})? Its progress is discarded." \
|
||||
|| { log "Cancelled — kept"; return 0; }
|
||||
cmd_remove "$gid"
|
||||
}
|
||||
|
||||
menu_purge() {
|
||||
menu_gate_daemon || return 0
|
||||
warn "Purge clears ALL finished/error history from aria2."
|
||||
local word
|
||||
word="$(menu_ask_value "Type purge to clear finished/error history")" || return 0
|
||||
[ "$word" = "purge" ] || { log "Cancelled — history kept"; return 0; }
|
||||
cmd_purge
|
||||
}
|
||||
|
||||
menu_daemon_stop() {
|
||||
menu_ask_yn "Stop the aria2 daemon ($SERVICE)? Active downloads pause until it runs again." \
|
||||
|| { log "Cancelled"; return 0; }
|
||||
cmd_stop
|
||||
}
|
||||
|
||||
run_menu() {
|
||||
menu_guard || exit 1
|
||||
while true; do
|
||||
local choice
|
||||
choice="$(menu_run "Downloads — aria2 RPC queue" \
|
||||
"Daemon status" \
|
||||
"Overview — status + queue snapshot" \
|
||||
"List downloads" \
|
||||
"Add a download URL" \
|
||||
"Download details (info)" \
|
||||
"Pause a download" \
|
||||
"Resume a download" \
|
||||
"Remove a download" \
|
||||
"Re-queue / restart a download" \
|
||||
"Purge finished/error history (type purge)" \
|
||||
"Watch live progress (Ctrl-C leaves the menu)" \
|
||||
"Start the daemon" \
|
||||
"Stop the daemon")" || return 0
|
||||
case "$choice" in
|
||||
1) cmd_status ;;
|
||||
2) menu_gate_daemon && overview ;;
|
||||
3) menu_gate_daemon && cmd_list ;;
|
||||
4) menu_download_add ;;
|
||||
5) menu_gid_action info ;;
|
||||
6) menu_gid_action pause ;;
|
||||
7) menu_gid_action resume ;;
|
||||
8) menu_download_remove ;;
|
||||
9) menu_gid_action restart ;;
|
||||
10) menu_purge ;;
|
||||
11) menu_gate_daemon && cmd_watch ;;
|
||||
12) cmd_start ;;
|
||||
13) menu_daemon_stop ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# ── Dispatch ───────────────────────────────────────────────────
|
||||
overview() {
|
||||
cmd_status
|
||||
@@ -948,4 +1088,17 @@ main() {
|
||||
esac
|
||||
}
|
||||
|
||||
# Menu door: explicit verb, or zero args on a terminal. Everything below —
|
||||
# including zero args without a terminal — stays byte-compatible with the
|
||||
# pre-menu CLI; scripted verbs (and the healer timer's `retry … --once`)
|
||||
# never enter the menu.
|
||||
if [ "${1:-}" = "menu" ]; then
|
||||
run_menu
|
||||
exit 0
|
||||
fi
|
||||
if [ $# -eq 0 ] && [ -t 0 ]; then
|
||||
run_menu
|
||||
exit 0
|
||||
fi
|
||||
|
||||
main "$@"
|
||||
|
||||
+132
-56
@@ -2,11 +2,13 @@
|
||||
set -euo pipefail
|
||||
# POS: system backup — Encrypted (AES-256) folder snapshots (tar + gpg)
|
||||
# POS_FLAGS: --service --no-encrypt
|
||||
# POS_SUBCMDS: menu
|
||||
# POS_CONFIG: notify | notify.env | NOTIFY_PLATFORM=:Comma-separated notify platforms (default telegram) — shared by backup, firewall, share nfs client/server
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh"
|
||||
source "$(dirname "$0")/../lib/usb-lib.sh" 2>/dev/null || source "$(dirname "$0")/usb-lib.sh"
|
||||
source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh"
|
||||
|
||||
load_system_env
|
||||
EFF_ROOTS="${BACKUP_SERVICE_ROOTS:-/srv $HOME/srv}"
|
||||
@@ -29,6 +31,9 @@ Modes:
|
||||
--no-encrypt Skip encryption (no password prompt, artifact stays .tar.gz).
|
||||
--service List folders under /srv and ~/srv, pick one, back it up.
|
||||
|
||||
Bare \`pos system backup\` on a terminal (or \`pos system backup menu\`)
|
||||
opens an interactive menu wrapping these modes; arguments stay scriptable.
|
||||
|
||||
The final artifact <name>_<date>.tar.gz[.gpg] is written to the current directory.
|
||||
After it verifies, connected USB storage is offered: the copy lands in
|
||||
<usb>/backups/ and is sha256-verified 100% before it is announced. A stick
|
||||
@@ -112,17 +117,10 @@ usb_copy_offer() {
|
||||
SERVICE=0
|
||||
ENCRYPT=1
|
||||
[ "${BACKUP_ENCRYPT:-1}" = "0" ] && ENCRYPT=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-h|--help) usage ;;
|
||||
--service) SERVICE=1 ;;
|
||||
--no-encrypt) ENCRYPT=0 ;;
|
||||
*) FOLDER="$arg" ;;
|
||||
esac
|
||||
done
|
||||
{ [ "$SERVICE" -eq 1 ] || [ -n "${FOLDER:-}" ]; } || err "Missing folder path (or use --service)"
|
||||
|
||||
if [ "$SERVICE" -eq 1 ]; then
|
||||
# ── Folder picker over the --service roots ──────────────────────
|
||||
# Sets $FOLDER; errors out when nothing can be offered (same as --service).
|
||||
pick_service_folder() {
|
||||
if [ -n "${BACKUP_SERVICE_ROOTS:-}" ]; then
|
||||
read -r -a roots <<< "$BACKUP_SERVICE_ROOTS"
|
||||
else
|
||||
@@ -156,61 +154,139 @@ if [ "$SERVICE" -eq 1 ]; then
|
||||
err "Invalid selection: $choice"
|
||||
fi
|
||||
FOLDER="${names[$choice]}"
|
||||
fi
|
||||
}
|
||||
|
||||
[ -d "$FOLDER" ] || err "Folder not found: $FOLDER"
|
||||
# ── Backup flow ($FOLDER → timestamped archive, then USB copy offer) ──
|
||||
run_backup() {
|
||||
[ -d "$FOLDER" ] || err "Folder not found: $FOLDER"
|
||||
|
||||
NAME="$(basename "$FOLDER")"
|
||||
DATE="$(date +%Y-%m-%d_%H-%M-%S)"
|
||||
ARCHIVE="${NAME}_${DATE}.tar.gz"
|
||||
NAME="$(basename "$FOLDER")"
|
||||
DATE="$(date +%Y-%m-%d_%H-%M-%S)"
|
||||
ARCHIVE="${NAME}_${DATE}.tar.gz"
|
||||
|
||||
echo
|
||||
log "Creating backup..."
|
||||
echo "Source : $FOLDER"
|
||||
echo "Output : $ARCHIVE"
|
||||
echo
|
||||
log "Creating backup..."
|
||||
echo "Source : $FOLDER"
|
||||
echo "Output : $ARCHIVE"
|
||||
|
||||
sudo tar -czvf "$ARCHIVE" -C "$(dirname "$FOLDER")" "$NAME"
|
||||
sudo tar -czvf "$ARCHIVE" -C "$(dirname "$FOLDER")" "$NAME"
|
||||
|
||||
log "Verifying archive..."
|
||||
tar -tzf "$ARCHIVE" > /dev/null
|
||||
log "Archive verified"
|
||||
log "Verifying archive..."
|
||||
tar -tzf "$ARCHIVE" > /dev/null
|
||||
log "Archive verified"
|
||||
|
||||
if [ "$ENCRYPT" -eq 1 ]; then
|
||||
command -v gpg &>/dev/null || err "gpg not found (install gnupg)"
|
||||
if [ "$ENCRYPT" -eq 1 ]; then
|
||||
command -v gpg &>/dev/null || err "gpg not found (install gnupg)"
|
||||
|
||||
while true; do
|
||||
read -s -rp "Enter backup password: " PASS
|
||||
echo
|
||||
read -s -rp "Confirm backup password: " CONFIRM
|
||||
echo
|
||||
if [ -n "$PASS" ] && [ "$PASS" = "$CONFIRM" ]; then
|
||||
break
|
||||
fi
|
||||
warn "Passwords are empty or do not match — try again"
|
||||
done
|
||||
unset CONFIRM
|
||||
|
||||
log "Encrypting backup..."
|
||||
gpg --batch --yes --passphrase "$PASS" --symmetric --cipher-algo AES256 "$ARCHIVE"
|
||||
|
||||
rm -f "$ARCHIVE"
|
||||
ARCHIVE="${ARCHIVE}.gpg"
|
||||
chmod 600 "$ARCHIVE"
|
||||
|
||||
log "Verifying encrypted backup..."
|
||||
gpg --batch --quiet --passphrase "$PASS" --decrypt "$ARCHIVE" | tar -tzf - > /dev/null
|
||||
|
||||
unset PASS
|
||||
else
|
||||
chmod 600 "$ARCHIVE"
|
||||
log "No encryption requested — keeping $ARCHIVE"
|
||||
fi
|
||||
echo
|
||||
log "Backup completed: $ARCHIVE"
|
||||
notify_send "Backup completed: $ARCHIVE"
|
||||
|
||||
# Optional: detect a USB stick connected after the backup finished, offer to
|
||||
# copy the archive to <usb>/backups/, and prove the transfer 100%. From here
|
||||
# on a failure is a USB-copy problem, not a backup problem.
|
||||
trap 'notify_send "USB copy FAILED: ${ARCHIVE:-unknown}"' ERR
|
||||
usb_copy_offer "$ARCHIVE"
|
||||
}
|
||||
|
||||
# ── Interactive menu (opt-in front door, Pattern B via lib/menu-lib.sh) ──
|
||||
menu_backup_folder() {
|
||||
local enc="$1" val prompt
|
||||
val="$(menu_ask_value "Folder to back up")" || return 0
|
||||
if [ ! -d "$val" ]; then
|
||||
warn "Not a folder: $val"
|
||||
return 0
|
||||
fi
|
||||
FOLDER="$val"
|
||||
if [ "$enc" -eq 1 ]; then
|
||||
prompt="Create ENCRYPTED backup of $FOLDER?"
|
||||
else
|
||||
prompt="Create UNENCRYPTED backup of $FOLDER (plain .tar.gz, no password)?"
|
||||
fi
|
||||
confirm "$prompt" n || { log "Cancelled"; return 0; }
|
||||
ENCRYPT="$enc"
|
||||
run_backup
|
||||
}
|
||||
|
||||
menu_backup_service() {
|
||||
if ! pick_service_folder; then
|
||||
warn "Cancelled — no folder selected"
|
||||
return 0
|
||||
fi
|
||||
confirm "Create ENCRYPTED backup of $FOLDER?" n || { log "Cancelled"; return 0; }
|
||||
ENCRYPT=1
|
||||
run_backup
|
||||
}
|
||||
|
||||
run_menu() {
|
||||
menu_guard || exit 1
|
||||
while true; do
|
||||
read -s -rp "Enter backup password: " PASS
|
||||
echo
|
||||
read -s -rp "Confirm backup password: " CONFIRM
|
||||
echo
|
||||
if [ -n "$PASS" ] && [ "$PASS" = "$CONFIRM" ]; then
|
||||
break
|
||||
fi
|
||||
warn "Passwords are empty or do not match — try again"
|
||||
local choice
|
||||
choice="$(menu_run "System backup" \
|
||||
"New encrypted backup (type/paste folder)" \
|
||||
"New encrypted backup — pick from ${EFF_ROOTS}" \
|
||||
"New backup WITHOUT encryption (type/paste folder)")" || return 0
|
||||
case "$choice" in
|
||||
1) menu_backup_folder 1 ;;
|
||||
2) menu_backup_service ;;
|
||||
3) menu_backup_folder 0 ;;
|
||||
esac
|
||||
done
|
||||
unset CONFIRM
|
||||
}
|
||||
|
||||
log "Encrypting backup..."
|
||||
gpg --batch --yes --passphrase "$PASS" --symmetric --cipher-algo AES256 "$ARCHIVE"
|
||||
|
||||
rm -f "$ARCHIVE"
|
||||
ARCHIVE="${ARCHIVE}.gpg"
|
||||
chmod 600 "$ARCHIVE"
|
||||
|
||||
log "Verifying encrypted backup..."
|
||||
gpg --batch --quiet --passphrase "$PASS" --decrypt "$ARCHIVE" | tar -tzf - > /dev/null
|
||||
|
||||
unset PASS
|
||||
else
|
||||
chmod 600 "$ARCHIVE"
|
||||
log "No encryption requested — keeping $ARCHIVE"
|
||||
# Menu door: explicit verb, or zero args on a terminal. Everything below —
|
||||
# including zero args without a terminal — stays byte-compatible with the
|
||||
# pre-menu CLI.
|
||||
if [ "${1:-}" = "menu" ]; then
|
||||
run_menu
|
||||
exit 0
|
||||
fi
|
||||
echo
|
||||
log "Backup completed: $ARCHIVE"
|
||||
notify_send "Backup completed: $ARCHIVE"
|
||||
|
||||
# Optional: detect a USB stick connected after the backup finished, offer to
|
||||
# copy the archive to <usb>/backups/, and prove the transfer 100%. From here
|
||||
# on a failure is a USB-copy problem, not a backup problem.
|
||||
trap 'notify_send "USB copy FAILED: ${ARCHIVE:-unknown}"' ERR
|
||||
usb_copy_offer "$ARCHIVE"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-h|--help) usage ;;
|
||||
--service) SERVICE=1 ;;
|
||||
--no-encrypt) ENCRYPT=0 ;;
|
||||
*) FOLDER="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ $# -eq 0 ] && [ -t 0 ]; then
|
||||
run_menu
|
||||
exit 0
|
||||
fi
|
||||
|
||||
{ [ "$SERVICE" -eq 1 ] || [ -n "${FOLDER:-}" ]; } || err "Missing folder path (or use --service)"
|
||||
|
||||
if [ "$SERVICE" -eq 1 ]; then
|
||||
pick_service_folder
|
||||
fi
|
||||
|
||||
run_backup
|
||||
|
||||
+59
-42
@@ -39,10 +39,25 @@ log() { echo "[+] $*"; }
|
||||
warn() { echo "[!] $*"; }
|
||||
err() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
# Interactive input seam: every prompt reads the controlling terminal
|
||||
# (/dev/tty), so the menu survives stdout redirection / command substitution,
|
||||
# and fails closed on EOF or a missing TTY — it prints a pointer to the CLI
|
||||
# instead of hanging under cron/pipes (repo-standard menu mechanics; see
|
||||
# lib/menu-lib.sh contracts).
|
||||
tty_read() {
|
||||
local prompt="$1"
|
||||
shift
|
||||
if ! read -rp "$prompt" "$@" < /dev/tty; then
|
||||
printf '[!] Terminal closed or unavailable (EOF) — stopping; nothing more was executed.\n' >&2
|
||||
printf '[!] Re-open interactively with: sudo pos system firewall (see --help)\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_cmd() {
|
||||
local -a cmd=("$@")
|
||||
printf "\n>>> %s\n" "${cmd[*]}"
|
||||
read -rp "Execute this command? [y/N]: " confirm
|
||||
tty_read "Execute this command? [y/N]: " confirm
|
||||
if [[ "$confirm" =~ ^[Yy]$ ]]; then
|
||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||
echo "(dry-run) skipping execution"
|
||||
@@ -101,9 +116,9 @@ build_ufw_cmd() {
|
||||
}
|
||||
|
||||
prompt_ipver() {
|
||||
local ver
|
||||
read -rp "IP version (4 / 6 / both): " ver
|
||||
echo "$ver"
|
||||
# Assigns `ipver` in the caller's scope (bash dynamic scoping); direct call
|
||||
# instead of command substitution so an EOF exits the whole tool gracefully.
|
||||
tty_read "IP version (4 / 6 / both): " ipver
|
||||
}
|
||||
|
||||
apply_for_versions() {
|
||||
@@ -111,7 +126,7 @@ apply_for_versions() {
|
||||
local port="$6" onif="$7" logmode="$8" comment="$9"
|
||||
local insert_pos="${10:-}"
|
||||
local ipver
|
||||
ipver=$(prompt_ipver)
|
||||
prompt_ipver
|
||||
|
||||
case "$ipver" in
|
||||
4) build_ufw_cmd "$action" "$direction" "$proto" "$from" "$to" "$port" "$onif" "$logmode" "$comment" "$insert_pos" "" ;;
|
||||
@@ -130,26 +145,26 @@ add_rule() {
|
||||
echo "1) Port/service (eg: port 8080 or 'ssh')"
|
||||
echo "2) IP-based (from X to Y)"
|
||||
echo "3) Directional port rule (in/out to any port ...)"
|
||||
read -rp "Choice: " rtype
|
||||
tty_read "Choice: " rtype
|
||||
|
||||
case "$rtype" in
|
||||
1)
|
||||
read -rp "Action (allow/deny/reject/limit) [allow]: " action
|
||||
tty_read "Action (allow/deny/reject/limit) [allow]: " action
|
||||
action=${action:-allow}
|
||||
read -rp "Enter port number or service name (eg 'ssh' or '8080'): " port_or_svc
|
||||
tty_read "Enter port number or service name (eg 'ssh' or '8080'): " port_or_svc
|
||||
|
||||
if [[ "$port_or_svc" =~ ^[0-9]+$ ]]; then
|
||||
read -rp "Protocol (tcp/udp/any) [tcp]: " proto
|
||||
tty_read "Protocol (tcp/udp/any) [tcp]: " proto
|
||||
proto=${proto:-tcp}
|
||||
[[ "$proto" == "any" ]] && proto=""
|
||||
read -rp "Interface (leave empty for any): " onif
|
||||
read -rp "Log? (none/log/log-all) [none]: " logmode
|
||||
tty_read "Interface (leave empty for any): " onif
|
||||
tty_read "Log? (none/log/log-all) [none]: " logmode
|
||||
[[ "$logmode" == "none" ]] && logmode=""
|
||||
read -rp "Comment (optional): " comment
|
||||
tty_read "Comment (optional): " comment
|
||||
|
||||
apply_for_versions "$action" "" "$proto" "" "any" "$port_or_svc" "$onif" "$logmode" "$comment"
|
||||
else
|
||||
read -rp "IP version (4 / 6 / both) [4]: " ipver
|
||||
tty_read "IP version (4 / 6 / both) [4]: " ipver
|
||||
ipver=${ipver:-4}
|
||||
case "$ipver" in
|
||||
4) run_cmd ufw "$action" "$port_or_svc" ;;
|
||||
@@ -162,41 +177,41 @@ add_rule() {
|
||||
;;
|
||||
|
||||
2)
|
||||
read -rp "Action (allow/deny/reject) [deny]: " action
|
||||
tty_read "Action (allow/deny/reject) [deny]: " action
|
||||
action=${action:-deny}
|
||||
read -rp "From address/CIDR (eg 192.168.1.5 or 10.0.0.0/24): " from
|
||||
read -rp "To address (leave empty for 'any') [any]: " to
|
||||
tty_read "From address/CIDR (eg 192.168.1.5 or 10.0.0.0/24): " from
|
||||
tty_read "To address (leave empty for 'any') [any]: " to
|
||||
to=${to:-any}
|
||||
read -rp "Direction (in/out) [in]: " direction
|
||||
tty_read "Direction (in/out) [in]: " direction
|
||||
direction=${direction:-in}
|
||||
read -rp "Port (leave empty if not applicable): " port
|
||||
read -rp "Protocol (tcp/udp/any) [any]: " proto
|
||||
tty_read "Port (leave empty if not applicable): " port
|
||||
tty_read "Protocol (tcp/udp/any) [any]: " proto
|
||||
[[ "$proto" == "any" ]] && proto=""
|
||||
read -rp "Interface (leave empty for any): " onif
|
||||
read -rp "Log? (none/log/log-all) [none]: " logmode
|
||||
tty_read "Interface (leave empty for any): " onif
|
||||
tty_read "Log? (none/log/log-all) [none]: " logmode
|
||||
[[ "$logmode" == "none" ]] && logmode=""
|
||||
read -rp "Comment (optional): " comment
|
||||
tty_read "Comment (optional): " comment
|
||||
|
||||
apply_for_versions "$action" "$direction" "$proto" "$from" "$to" "$port" "$onif" "$logmode" "$comment"
|
||||
;;
|
||||
|
||||
3)
|
||||
read -rp "Action (allow/deny/reject/limit) [allow]: " action
|
||||
tty_read "Action (allow/deny/reject/limit) [allow]: " action
|
||||
action=${action:-allow}
|
||||
read -rp "Direction (in/out) [in]: " direction
|
||||
tty_read "Direction (in/out) [in]: " direction
|
||||
direction=${direction:-in}
|
||||
read -rp "Port number: " port
|
||||
read -rp "Protocol (tcp/udp/any) [tcp]: " proto
|
||||
tty_read "Port number: " port
|
||||
tty_read "Protocol (tcp/udp/any) [tcp]: " proto
|
||||
[[ "$proto" == "any" ]] && proto=""
|
||||
read -rp "On interface (leave empty for any): " onif
|
||||
read -rp "From address (optional): " from
|
||||
tty_read "On interface (leave empty for any): " onif
|
||||
tty_read "From address (optional): " from
|
||||
from=${from:-}
|
||||
read -rp "To address [any]: " to
|
||||
tty_read "To address [any]: " to
|
||||
to=${to:-any}
|
||||
read -rp "Log? (none/log/log-all) [none]: " logmode
|
||||
tty_read "Log? (none/log/log-all) [none]: " logmode
|
||||
[[ "$logmode" == "none" ]] && logmode=""
|
||||
read -rp "Comment (optional): " comment
|
||||
read -rp "Insert position (number/prepend/empty): " insert_pos
|
||||
tty_read "Comment (optional): " comment
|
||||
tty_read "Insert position (number/prepend/empty): " insert_pos
|
||||
|
||||
apply_for_versions "$action" "$direction" "$proto" "$from" "$to" "$port" "$onif" "$logmode" "$comment" "$insert_pos"
|
||||
;;
|
||||
@@ -210,16 +225,16 @@ delete_rule() {
|
||||
echo "Delete rule by:"
|
||||
echo "1) rule number (use 'ufw status numbered' to see numbers)"
|
||||
echo "2) rule text (eg: 'allow 22/tcp')"
|
||||
read -rp "Choice: " dch
|
||||
tty_read "Choice: " dch
|
||||
|
||||
case "$dch" in
|
||||
1)
|
||||
ufw status numbered
|
||||
read -rp "Number to delete: " num
|
||||
tty_read "Number to delete: " num
|
||||
run_cmd ufw delete "$num"
|
||||
;;
|
||||
2)
|
||||
read -rp "Exact rule text to delete (eg: deny 80/tcp): " ruletext
|
||||
tty_read "Exact rule text to delete (eg: deny 80/tcp): " ruletext
|
||||
run_cmd ufw delete $ruletext
|
||||
;;
|
||||
*) echo "Unknown choice." ;;
|
||||
@@ -231,7 +246,7 @@ show_status() {
|
||||
echo "1) Simple status"
|
||||
echo "2) Verbose status"
|
||||
echo "3) Numbered status (useful for delete)"
|
||||
read -rp "Choice: " sc
|
||||
tty_read "Choice: " sc
|
||||
case "$sc" in
|
||||
1) run_cmd ufw status ;;
|
||||
2) run_cmd ufw status verbose ;;
|
||||
@@ -241,7 +256,8 @@ show_status() {
|
||||
}
|
||||
|
||||
while true; do
|
||||
cat <<'MENU'
|
||||
{
|
||||
cat <<'MENU'
|
||||
|
||||
==============================
|
||||
UFW POWER — human friendly
|
||||
@@ -257,7 +273,8 @@ while true; do
|
||||
0) Exit
|
||||
------------------------------
|
||||
MENU
|
||||
read -rp "Choose: " opt
|
||||
} >&2
|
||||
tty_read "Choose: " opt
|
||||
|
||||
case "$opt" in
|
||||
1) add_rule ;;
|
||||
@@ -267,13 +284,13 @@ MENU
|
||||
5) run_cmd ufw disable ;;
|
||||
6)
|
||||
echo "WARNING: ufw reset will disable and remove all rules."
|
||||
read -rp "Type 'RESET' to confirm: " c
|
||||
tty_read "Type 'RESET' to confirm: " c
|
||||
[[ "$c" == "RESET" ]] && run_cmd ufw reset || echo "Reset aborted."
|
||||
;;
|
||||
7)
|
||||
read -rp "Default incoming policy (allow/deny/reject) [deny]: " defin
|
||||
tty_read "Default incoming policy (allow/deny/reject) [deny]: " defin
|
||||
defin=${defin:-deny}
|
||||
read -rp "Default outgoing policy (allow/deny/reject) [allow]: " defout
|
||||
tty_read "Default outgoing policy (allow/deny/reject) [allow]: " defout
|
||||
defout=${defout:-allow}
|
||||
run_cmd ufw default "$defin" incoming
|
||||
run_cmd ufw default "$defout" outgoing
|
||||
@@ -303,6 +320,6 @@ MENU
|
||||
esac
|
||||
|
||||
echo
|
||||
read -rp "Press Enter to continue..."
|
||||
tty_read "Press Enter to continue..." REPLY
|
||||
clear
|
||||
done
|
||||
|
||||
+71
-1
@@ -1,12 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: system schedule — Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently
|
||||
# POS_SUBCMDS: run list config enable disable status migrate
|
||||
# POS_SUBCMDS: run list config enable disable status migrate menu
|
||||
# POS_FLAGS: --dry-run
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
source "$(dirname "$0")/../lib/scheduler-lib.sh" 2>/dev/null || source "$(dirname "$0")/scheduler-lib.sh"
|
||||
source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh"
|
||||
source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -29,6 +30,10 @@ NOTIFY policies:
|
||||
never run only — no notification (side-effect jobs)
|
||||
Default: threshold if RULE is set, otherwise onchange.
|
||||
|
||||
Bare \`pos system schedule\` on a terminal (or \`pos system schedule menu\`)
|
||||
opens an interactive menu wrapping these subcommands; arguments stay
|
||||
scriptable — the systemd timers keep calling 'run <name>' directly.
|
||||
|
||||
Subcommands:
|
||||
run [name|all] Execute job(s) now (the systemd timers call 'run <name>')
|
||||
list Jobs + notify policy + interval + last run
|
||||
@@ -53,6 +58,71 @@ EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── Interactive menu (opt-in front door, Pattern B via lib/menu-lib.sh) ──
|
||||
# Every item maps onto an existing subcommand implementation. Run-now goes
|
||||
# through the same sched_run the systemd timers invoke (`run <name>`),
|
||||
# behind an explicit y/N confirm.
|
||||
|
||||
menu_pick_job() { # $1 = prompt → picked job name on stdout · rc 1 = cancelled
|
||||
local -a jobs=()
|
||||
mapfile -t jobs < <(sched_list_jobs)
|
||||
if [ ${#jobs[@]} -eq 0 ]; then
|
||||
warn "no jobs in $SCHEDULE_DIR — add one with 'pos system schedule config'"
|
||||
return 1
|
||||
fi
|
||||
local idx
|
||||
idx="$(menu_pick "$1" "${jobs[@]}")" || return 1
|
||||
printf '%s\n' "${jobs[$((idx - 1))]}"
|
||||
}
|
||||
|
||||
menu_job_action() { # $1 = run|enable|disable — pick a job, call the verb
|
||||
local name
|
||||
name="$(menu_pick_job "${1} which job?")" || return 0
|
||||
case "$1" in
|
||||
run)
|
||||
confirm "Run job '$name' now (executes its COMMAND, applies its NOTIFY policy)?" n \
|
||||
|| { log "Cancelled"; return 0; }
|
||||
sched_run "$name"
|
||||
;;
|
||||
enable) sched_enable "$name" ;;
|
||||
disable) sched_disable "$name" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
run_menu() {
|
||||
menu_guard || exit 1
|
||||
while true; do
|
||||
local choice
|
||||
choice="$(menu_run "Scheduled jobs ($SCHEDULE_DIR)" \
|
||||
"List jobs" \
|
||||
"Timer status (+ next run)" \
|
||||
"Run a job now" \
|
||||
"Enable a job" \
|
||||
"Disable a job" \
|
||||
"Open the interactive job editor")" || return 0
|
||||
case "$choice" in
|
||||
1) sched_list ;;
|
||||
2) sched_status ;;
|
||||
3) menu_job_action run ;;
|
||||
4) menu_job_action enable ;;
|
||||
5) menu_job_action disable ;;
|
||||
6) sched_config_editor ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Menu door: explicit verb, or zero args on a terminal. Everything below —
|
||||
# including zero args without a terminal — stays byte-compatible with the
|
||||
# pre-menu CLI; timer invocations (`run <name>`) never enter the menu.
|
||||
if [ "${1:-}" = "menu" ]; then
|
||||
run_menu
|
||||
exit 0
|
||||
fi
|
||||
if [ $# -eq 0 ] && [ -t 0 ]; then
|
||||
run_menu
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help|"") usage ;;
|
||||
esac
|
||||
|
||||
+11
-7
@@ -16,7 +16,7 @@ _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"
|
||||
_pos_flags[network-hotspot]="--foreground"
|
||||
_pos_flags[share-usb-server]="--ls --ls-shared --share --unshare --auto-share --callback --close-callback --auto-connect --disconnect --nickname --timeout --port --info --version"
|
||||
_pos_flags[share-usb-server]="--ls --ls-shared --share --unshare --auto-share --callback --close-callback --auto-connect --disconnect --nickname --timeout --port --info --version menu"
|
||||
_pos_flags[system-backup]="--service --no-encrypt"
|
||||
_pos_flags[system-schedule]="--dry-run"
|
||||
_pos_flags[tree]="--depth"
|
||||
@@ -27,13 +27,17 @@ _pos_subcmds[ai-gemini]="ask chat models sessions"
|
||||
_pos_subcmds[communication-matrix-sender]="send test login"
|
||||
_pos_subcmds[communication-scrcpy]="devices record tcpip connect push pull screenshot info"
|
||||
_pos_subcmds[communication-telegram-sender]="send test"
|
||||
_pos_subcmds[docker-compose]="ls installed up down restart logs update config"
|
||||
_pos_subcmds[docker-vbox]="create enter stop start rm ls"
|
||||
_pos_subcmds[docker-compose]="ls installed up down restart logs update config menu"
|
||||
_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[network-download]="start stop status add torrent metalink list info files peers pause resume remove purge move limit set watch restart retry replace"
|
||||
_pos_subcmds[share-smb-client]="mount unmount list persist unpersist"
|
||||
_pos_subcmds[share-smb-server]="status share unshare list adduser deluser reload enable disable"
|
||||
_pos_subcmds[system-schedule]="run list config enable disable status migrate"
|
||||
_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-schedule]="run list config enable disable status migrate menu"
|
||||
# GEN:END possubcmds
|
||||
# GEN:START posconfigscopes
|
||||
declare -a _pos_config_scopes=(ai compose entertainment matrix notify scrcpy system telegram ytsync)
|
||||
|
||||
Reference in New Issue
Block a user