Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0ef13827b | |||
| 4af097f5eb | |||
| 476173ba83 | |||
| 0aaa25150c | |||
| 4173fc61e3 | |||
| 692cb6b362 | |||
| 5b2a0304e1 | |||
| f61766b074 | |||
| b144fe8853 | |||
| 010e067935 | |||
| 5de7a331fe | |||
| a0152fa87c | |||
| 364c5c3687 | |||
| a09f9cfafa |
@@ -10,17 +10,18 @@ CRITICAL: real guidance lives in DOC/. When you encounter a reference below, use
|
||||
- @DOC/DEV.md — conventions, verification, and the "Adding a new Feature/App/Tool" checklists. Read before creating or changing code/docs.
|
||||
- @DOC/POS.md — `pos` CLI reference (dispatcher + every command). Read when working on `bin/pos*` scripts or their docs.
|
||||
- @DOC/README.md — index of all docs. Read to find the right doc.
|
||||
- @DOC/HOWTO.md — hands-on per-category guides (network, docker, media, system, ssh, usb, communication, entertainment). Read when a task is about *using* `pos` day-to-day rather than extending it.
|
||||
- @DOC/HOWTO.md — hands-on per-category guides (ai, network, docker, media, system, ssh, share, communication, entertainment). Read when a task is about *using* `pos` day-to-day rather than extending it.
|
||||
|
||||
## Quick facts
|
||||
|
||||
- **Tool model:** `bin/pos-<category>-<command>`, or **category-less** `bin/pos-<cmd>` for dispatcher/dev-level commands that fit no category (`pos-config`, `pos-tree`) — they dispatch like any tool and show with an empty category in the generated tables. `bin/pos` dispatches by longest-prefix arg matching. New tools are auto-discovered but must be executable (`100755`) and carry a `# POS: <cat> <cmd> — <desc>` header right after the shebang; `make gen` only uses the text after the first `— ` (the leading words are convention-only), so keep the one-line description concise. `# POS_FLAGS:` / `# POS_SUBCMDS:` / `# POS_CONFIG:` headers feed tab-completion and the `pos config` scope registry. A missing `# POS:` header hard-fails `make gen`. Legacy `bin/wr-*`, `mp3`, `mp4`, `vbox`, `ssh-load-all` are thin forwarders to `pos` — keep them that way.
|
||||
- **Categories:** `ai`, `communication`, `docker`, `entertainment`, `media`, `network`, `share` (usb, nfs, smb), `ssh`, `system`, plus category-less `config`/`tree`. `pos tree` (bin/pos-tree) is the authoritative structure — it derives the hierarchy from `bin/pos-*` filenames + `# POS:`/`# POS_SUBCMDS:` headers.
|
||||
- **Generated code:** blocks between `GEN:START`/`GEN:END` markers in `DOC/AGENT_Context_Project.md` (tree, dispatch, selfcontained, filetable, docmap) and `completions/pos.bash` (flags, subcmds, config scopes) are `make gen` output — never hand-edit them. Generators must be **byte-order deterministic** (sort with `LC_ALL=C`, as `scripts/gen-docs.sh` does) or CI's `git diff --exit-code` trips on a locale that collates differently. After touching `bin/pos-*`, run `make gen`, then `make check`, then `make lint` (definition of done: check green + lint ends `0 FAIL, 0 WARN`). `make check` (`scripts/check-sync.sh`) is the self-consistency gate — bash -n + exec-bit check + doc-sync + dispatch smoke; `make lint` (`scripts/lint-conventions.sh`) is the convention gate — it enforces every rule in this file (shebang/strict-mode, exec bits, `# POS:` headers, `-h|--help` present and after deps guards, stdin-readers in `INTERACTIVE_CMDS`, POS.md coverage, plugin/app/unit/wrapper/secrets/env-seam classes — see `DOC/DEV.md → Convention Lint Gate`). Hand-maintained, not gen-checked: `DOC/POS.md`, the line-count rows above the filetable marker in `DOC/AGENT_Context_Project.md` (the non-`pos-*` files — `install.sh`, `preinstall.sh`, `postinstall.sh`, `lib/*`, `features/*`; bump a row's count only when that file's length changes), `bin/pos` usage() EXAMPLES, root README. CI: `.gitea/workflows/lint.yml` runs `make gen` + `git diff --exit-code` + `make check` + `make lint` on every push/PR via the **live** Gitea act_runner (`linux-post-install`, registered on the Gitea host) — a red run means gen drift or a gate failure and is a merge-blocker; still run the gates locally too (lint isn't in the pre-commit hook).
|
||||
- **Generated code:** blocks between `GEN:START`/`GEN:END` markers in `DOC/AGENT_Context_Project.md` (tree, dispatch, selfcontained, filetable, docmap) and `completions/pos.bash` (flags, subcmds, config scopes) are `make gen` output — never hand-edit them. Generators must be **byte-order deterministic** (sort with `LC_ALL=C`, as `scripts/gen-docs.sh` does) or CI's `git diff --exit-code` trips on a locale that collates differently. After touching `bin/pos-*`, run `make gen`, then `make check`, then `make lint` (definition of done: check green + lint ends `0 FAIL, 0 WARN`). `make check` (`scripts/check-sync.sh`) is the self-consistency gate — bash -n + exec-bit check + doc-sync + dispatch smoke; `make lint` (`scripts/lint-conventions.sh`) is the convention gate — it enforces every rule in this file (shebang/strict-mode, exec bits, `# POS:` headers, `-h|--help` present and after deps guards, stdin-readers in `INTERACTIVE_CMDS`, POS.md coverage, plugin/app/unit/wrapper/secrets/env-seam classes — see `DOC/DEV.md → Convention Lint Gate`). Hand-maintained, not gen-checked: `DOC/POS.md`, the line-count rows above the filetable marker in `DOC/AGENT_Context_Project.md` (the non-`pos-*` files — `install.sh`, `preinstall.sh`, `postinstall.sh`, `lib/*`, `features/*`; bump a row's count only when that file's length changes), `bin/pos` usage() EXAMPLES, root README. CI (`.gitea/workflows/lint.yml`, job `gates`) runs the same four commands on every push to main and PR, then records the result as a git tag on the commit: `ci-ok/<sha>` or `ci-fail/<sha>` (pushes only — query with `scripts/ci-status.sh [--wait] [<sha>]`; exit 0 green / 1 red / 2 pending). A red run means gen drift or a gate failure and is a merge-blocker; still run the gates locally too (lint isn't in the pre-commit hook).
|
||||
- **Stdin gotcha:** any tool that reads stdin must be added to `INTERACTIVE_CMDS` in `bin/pos` — otherwise the logging `tee` pipe hangs on (or swallows) the prompt.
|
||||
- **Deps:** apt packages → `PACKAGES` array in `preinstall.sh`; non-apt/manual installers (e.g. `usbsrv`) → `command -v <bin> || err "…"` guard inside the tool, never in PACKAGES.
|
||||
- **Deps:** apt packages → `PACKAGES` array in `preinstall.sh`; non-apt/manual installers (e.g. `usbsrv`) → `command -v <bin> || err "…"` guard inside the tool, never in PACKAGES. Hotspot binaries (`create_ap`, `wihotspot*`) are prebuilt in `x64_bin/` (or `arm64_bin/`) and copied by `install.sh` — not apt packages.
|
||||
- **Secrets:** never commit keys/tokens. `config/authorized_keys` and `config/rclone.conf` are gitignored; runtime tool config is `~/.config/linux_post_install/<tool>.env` (chmod 600, env-var precedence). Mask tokens in `config` output.
|
||||
- **entertainment plugins:** standalone scripts in `entertainment/` that must NOT source `lib/common.sh` — stdout is the message that gets sent to Telegram (helper chatter would leak into it). Markers: `# POS_PLUGIN: <name>` + `# POS_KEYS:` declarations. They aren't `pos-*` tools, so `make gen` skips them (no headers/doc tables) — verify with `bash -n` + a live `pos entertainment send <name> --print`.
|
||||
- **ScaleTail templates** are a git submodule (`compose/scale-tail`), absent on fresh clones — run `git submodule update --init` first (only needed for `pos docker compose *`).
|
||||
- **Doc conflicts:** resolve by the authority order in `MAINTENANCE.md → Phase 0`: `templates/*.sh` are the codified current convention and the required starting point for new work (`cp templates/pos-tool.sh bin/pos-<cat>-<cmd>`, likewise `app.sh` / `feature.sh`); then DEV.md wins on detail, AGENTS.md on process facts; code + `# POS:` headers are ground truth for behavior and all `GEN:` blocks; drift in POS/HOWTO/README/SCRIPTS/SYSTEMD/APPS is a doc bug — fix the doc.
|
||||
- **Conventions:** `set -euo pipefail`, `-h|--help` via case, idempotent writes, use `run`/`spawn` helpers (respect `$DRY_RUN`), `make hook` installs the opt-in pre-commit gate. `command -v` deps guards sit **before** the `-h|--help` dispatch — help also errors on a box missing the dependency (matches all existing deps-gated tools). Tools must run standalone from `/usr/local/bin` after install (source `lib/common.sh` via the `$(dirname "$0")/../lib/common.sh` fallback chain). Commits use conventional prefixes (`feat:`/`fix:`/`docs:`/`chore:`/`refactor:`).
|
||||
- Maintain `AGENT_TODO.md` (Now / Next / Later / Done): when you finish a task, move it to **Done** (dated) in the same commit.
|
||||
|
||||
@@ -42,6 +42,10 @@ 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.
|
||||
- **2026-08-15** — Fix `pos media sync` reporting success with 0 files when the source is a symlink: it enumerated with plain `find "$SRC"`, and GNU find (default `-P`) does not descend a command-line symlink to a directory — `~/Music -> /mnt/hdd/…/music` therefore yielded zero matches, the loop never ran, and the tool printed `0 added, 0 updated, 0 unchanged` without creating the target dir (live-box report). Switched to `find -H "$SRC"` (follows only command-line symlinks; inner-symlink semantics unchanged). howto/media.md sync section notes symlinked sources are followed. Caught live, not by the 46-case stub suite (which used a real temp dir source — lesson: add a symlink-root fixture). Verified: `printf 'y\n' | bash bin/pos-media-sync --mp3 --dry-run` now lists all 31 mp3s as "would copy"; `make gen && make check` green.
|
||||
- **2026-08-14** — `pos system backup` — smart USB detection: lsblk TRAN (lsusb/by-id cross-check), mount offer for plugged-in-but-unmounted sticks, sha256-verified copy (stub-suite 54/54).
|
||||
- **2026-08-05** — `pos communication telegram` — `--parse-mode` (plain/markdown/html).
|
||||
@@ -125,3 +129,8 @@ summary (newest last).
|
||||
- **2026-08-14** — `pos system backup` optional encryption (`--no-encrypt` flag + `BACKUP_ENCRYPT=0` env, flag-or-env — user chose "Flag + env only"): plain path keeps a verified `.tar.gz` with no password prompt (headless/cron safe); encrypt path unchanged (prompt → gpg AES-256 → decrypt-verify; the gpg dep-guard moved into the encrypt branch so plain backups no longer require `gnupg`). Arg parsing rewritten as a loop over `"$@"` so `pos system backup <folder> --no-encrypt` works with the flag after the folder; usage() documents all three forms + the plain artifact name; `# POS_FLAGS: --service --no-encrypt`; `config/system.env` template gains `#BACKUP_ENCRYPT=0`; POS.md row + howto/system.md section updated. Verified: stub suite +2 cases (T18 flag / T19 env: plain .tar.gz artifact, gpg never called via `$GPG_CALLED`, USB copy + sha256 of the plain archive, notify wording) — 65/65 green; `bash -n`, `make gen && make check`, `make lint` 0 FAIL / 0 WARN.
|
||||
- **2026-08-14** — CI green-check via plain git (no SSH to the runner, no API tokens — user chose "CI tags + git ls-remote" + "scripts/ci-status.sh helper"): `.gitea/workflows/lint.yml` scoped to `on: push: branches: [main]` (tag pushes no longer re-trigger it) and the gate step now reports its own outcome as a lightweight tag — `ci-ok/$GITHUB_SHA` on success / `ci-fail/$GITHUB_SHA` on failure, pushed over HTTP with the jobs automatic `GITEA_TOKEN` to `http://oauth2:${GITEA_TOKEN}@gitea.skink-platy.ts.net:3000/admin/Linux_post_install.git` (runner container already host-maps that hostname to 100.111.241.54); `steps.gates.conclusion` decides ok/fail, `if: always()` (guarded to `push` events) covers failed gate runs, and an existing-tag guard makes re-runs idempotent. New executable `scripts/ci-status.sh [--wait] [<sha>]` reads the tags via `git ls-remote` (origin, `CI_STATUS_REMOTE` override): GREEN (0) / RED (1) / PENDING (2); `--wait` polls every 10s up to 10 min. DEV.md §CI gains a "Checking green without SSH" bullet. Verified: `bash -n`, yaml-parse OK, `make gen && make check`, `make lint` 0 FAIL / 0 WARN; first live-tag verification pending the push (fallback if Gitea clamps token-push: PAT as workflow secret).
|
||||
- **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).
|
||||
|
||||
@@ -10,19 +10,19 @@
|
||||
|
||||
<!-- GEN:START docmap -->
|
||||
| ## 1. Project Overview | 28–43 |
|
||||
| ## 2. Directory Structure | 44–194 |
|
||||
| ## 3. Installation Flow | 195–246 |
|
||||
| ## 4. The `pos` CLI System | 247–318 |
|
||||
| ## 5. Shared Library — `lib/common.sh` | 319–350 |
|
||||
| ## 6. Docker Compose / ScaleTail | 351–393 |
|
||||
| ## 7. Optional Apps (`apps/`) | 394–423 |
|
||||
| ## 8. Entertainment Module | 424–437 |
|
||||
| ## 9. Systemd Services | 438–449 |
|
||||
| ## 10. Configuration Files | 450–476 |
|
||||
| ## 11. Coding Conventions | 477–509 |
|
||||
| ## 12. Development Workflow | 510–562 |
|
||||
| ## 13. Key File Quick Reference | 563–625 |
|
||||
| ## 14. Common Tasks for Agents | 626–657 |
|
||||
| ## 2. Directory Structure | 44–197 |
|
||||
| ## 3. Installation Flow | 198–251 |
|
||||
| ## 4. The `pos` CLI System | 252–326 |
|
||||
| ## 5. Shared Library — `lib/common.sh` | 327–358 |
|
||||
| ## 6. Docker Compose / ScaleTail | 359–401 |
|
||||
| ## 7. Optional Apps (`apps/`) | 402–431 |
|
||||
| ## 8. Entertainment Module | 432–445 |
|
||||
| ## 9. Systemd Services | 446–457 |
|
||||
| ## 10. Configuration Files | 458–484 |
|
||||
| ## 11. Coding Conventions | 485–517 |
|
||||
| ## 12. Development Workflow | 518–570 |
|
||||
| ## 13. Key File Quick Reference | 571–638 |
|
||||
| ## 14. Common Tasks for Agents | 639–672 |
|
||||
<!-- GEN:END docmap -->
|
||||
|
||||
## 1. Project Overview
|
||||
@@ -61,7 +61,8 @@ Linux_post_install/
|
||||
├── bin/ # CLI tools — installed to /usr/local/bin/
|
||||
│ ├── pos # Main dispatcher — smart arg matching to pos-* scripts
|
||||
<!-- GEN:START tree -->
|
||||
│ ├── pos-ai-gemini # Chat with Google Gemini (ask, chat, models, sessions)
|
||||
│ ├── pos-ai-gemini # Chat with Google Gemini (ask, capture, chat, models, sessions)
|
||||
│ ├── pos-ai-openrouter # Chat with OpenRouter models (ask, capture, chat, models, sessions)
|
||||
│ ├── pos-communication-matrix-listener # Matrix listener: map /command → bash, run them on room messages
|
||||
│ ├── pos-communication-matrix-sender # Send messages to a Matrix room via the client-server API (send, test, login)
|
||||
│ ├── pos-communication-scrcpy # Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info)
|
||||
@@ -70,6 +71,7 @@ Linux_post_install/
|
||||
│ ├── pos-docker-compose # Docker Compose service manager (ls/up/down/restart/logs/update/config)
|
||||
│ ├── pos-docker-health # One-glance container health dashboard (exits 1 if unhealthy)
|
||||
│ ├── pos-docker-ps # Enhanced container overview (health, IPs, ports, uptime)
|
||||
│ ├── pos-docker-stack # Containers grouped by compose stack (project); standalone group; -a/--all includes stopped
|
||||
│ ├── pos-docker-vbox # Disposable Docker-based VMs (create/enter/start/stop/rm/ls)
|
||||
│ ├── pos-entertainment-config # Show or edit the entertainment config (ENABLED auto-trigger list, weather location)
|
||||
│ ├── pos-entertainment-disable # Disable a plugin's auto-trigger (remove it from ENABLED)
|
||||
@@ -79,6 +81,7 @@ Linux_post_install/
|
||||
│ ├── pos-media-mp3 # Download audio as MP3 (yt-dlp)
|
||||
│ ├── pos-media-mp4 # Download video as MP4 (smart/interactive format select)
|
||||
│ ├── pos-media-sync # Incremental Music → USB sync (mp3/mp4, add/update only)
|
||||
│ ├── pos-media-ytsync # Incrementally sync YouTube channels/playlists into ~/Videos
|
||||
│ ├── pos-network-checkport # Check TCP/UDP port reachability (nmap, or bash/nc fallback) + local interface view
|
||||
│ ├── pos-network-download # aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits)
|
||||
│ ├── pos-network-hotspot # Wi-Fi hotspot via create_ap + wihotspot-gui
|
||||
@@ -202,7 +205,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
|
||||
@@ -263,7 +268,8 @@ All non-interactive `pos` commands log output to `~/.local/share/linux_post_inst
|
||||
| Category | Command | Script | Description |
|
||||
|----------|---------|--------|-------------|
|
||||
<!-- GEN:START dispatch -->
|
||||
| ai | gemini | `pos-ai-gemini` | Chat with Google Gemini (ask, chat, models, sessions) |
|
||||
| ai | gemini | `pos-ai-gemini` | Chat with Google Gemini (ask, capture, chat, models, sessions) |
|
||||
| ai | openrouter | `pos-ai-openrouter` | Chat with OpenRouter models (ask, capture, chat, models, sessions) |
|
||||
| communication | matrix-listener | `pos-communication-matrix-listener` | Matrix listener: map /command → bash, run them on room messages |
|
||||
| communication | matrix-sender | `pos-communication-matrix-sender` | Send messages to a Matrix room via the client-server API (send, test, login) |
|
||||
| communication | scrcpy | `pos-communication-scrcpy` | Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info) |
|
||||
@@ -272,6 +278,7 @@ All non-interactive `pos` commands log output to `~/.local/share/linux_post_inst
|
||||
| docker | compose | `pos-docker-compose` | Docker Compose service manager (ls/up/down/restart/logs/update/config) |
|
||||
| docker | health | `pos-docker-health` | One-glance container health dashboard (exits 1 if unhealthy) |
|
||||
| docker | ps | `pos-docker-ps` | Enhanced container overview (health, IPs, ports, uptime) |
|
||||
| docker | stack | `pos-docker-stack` | Containers grouped by compose stack (project); standalone group; -a/--all includes stopped |
|
||||
| docker | vbox | `pos-docker-vbox` | Disposable Docker-based VMs (create/enter/start/stop/rm/ls) |
|
||||
| entertainment | config | `pos-entertainment-config` | Show or edit the entertainment config (ENABLED auto-trigger list, weather location) |
|
||||
| entertainment | disable | `pos-entertainment-disable` | Disable a plugin's auto-trigger (remove it from ENABLED) |
|
||||
@@ -281,6 +288,7 @@ All non-interactive `pos` commands log output to `~/.local/share/linux_post_inst
|
||||
| media | mp3 | `pos-media-mp3` | Download audio as MP3 (yt-dlp) |
|
||||
| media | mp4 | `pos-media-mp4` | Download video as MP4 (smart/interactive format select) |
|
||||
| media | sync | `pos-media-sync` | Incremental Music → USB sync (mp3/mp4, add/update only) |
|
||||
| media | ytsync | `pos-media-ytsync` | Incrementally sync YouTube channels/playlists into ~/Videos |
|
||||
| network | checkport | `pos-network-checkport` | Check TCP/UDP port reachability (nmap, or bash/nc fallback) + local interface view |
|
||||
| network | download | `pos-network-download` | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) |
|
||||
| network | hotspot | `pos-network-hotspot` | Wi-Fi hotspot via create_ap + wihotspot-gui |
|
||||
@@ -574,24 +582,28 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| `lib/entertainment-plugin-lib.sh` | 67 | Message-safe helpers for plugins (config load, require, fetch+retry) — plugins MAY source it |
|
||||
| `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` | 194 | Shared USB-storage detection + pick flow (detect/mount-offer/`usb_pick_root`) — used by `pos system backup` + `pos media sync` |
|
||||
| `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 |
|
||||
| `features/autostart.sh` | 50 | Boot-time feature (moved from `bin/`, flag-gated service) |
|
||||
| `features/usb-automount.sh` | 138 | USB automount feature (udev rule + flag-gated service) |
|
||||
<!-- GEN:START filetable -->
|
||||
| `bin/pos` | 292 | CLI dispatcher with smart arg matching + logging + category help |
|
||||
| `bin/pos-ai-gemini` | 311 | Chat with Google Gemini (ask, chat, models, sessions) |
|
||||
| `bin/pos` | 295 | CLI dispatcher with smart arg matching + logging + category help |
|
||||
| `bin/pos-ai-gemini` | 586 | Chat with Google Gemini (ask, capture, chat, models, sessions) |
|
||||
| `bin/pos-ai-openrouter` | 587 | Chat with OpenRouter models (ask, capture, chat, models, sessions) |
|
||||
| `bin/pos-communication-matrix-listener` | 568 | Matrix listener: map /command → bash, run them on room messages |
|
||||
| `bin/pos-communication-matrix-sender` | 224 | Send messages to a Matrix room via the client-server API (send, test, login) |
|
||||
| `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-vbox` | 158 | Disposable Docker-based VMs (create/enter/start/stop/rm/ls) |
|
||||
| `bin/pos-docker-stack` | 101 | Containers grouped by compose stack (project); standalone group; -a/--all includes stopped |
|
||||
| `bin/pos-docker-vbox` | 1125 | 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 |
|
||||
@@ -599,25 +611,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` | 138 | Incremental Music → USB sync (mp3/mp4, add/update only) |
|
||||
| `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` | 217 | Mount SMB/CIFS shares (ephemeral or persistent systemd mount units) |
|
||||
| `bin/pos-share-smb-server` | 253 | 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` | 504 | 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` | 764 | 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` | 293 | Dynamic bash completion |
|
||||
| `completions/pos.bash` | 305 | Dynamic bash completion |
|
||||
<!-- GEN:END filetable -->
|
||||
| `apps/install.sh` | 171 | App install/uninstall picker/orchestrator |
|
||||
|
||||
@@ -641,6 +654,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| Add bash completion | Edit `completions/pos.bash` |
|
||||
| Modify Docker Compose logic | Edit `bin/pos-docker-compose` |
|
||||
| Modify Docker health check | Edit `bin/pos-docker-health` |
|
||||
| Modify Docker stack (grouped container) view | Edit `bin/pos-docker-stack` |
|
||||
| Modify vbox (Docker VM) logic | Edit `bin/pos-docker-vbox` |
|
||||
| Modify USB forwarding logic | Edit `bin/pos-share-usb-server` |
|
||||
| Modify aria2 download daemon / queue logic | Edit `bin/pos-network-download` |
|
||||
@@ -648,6 +662,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| Modify SMB share logic | Edit `bin/pos-share-smb-server` / `bin/pos-share-smb-client` |
|
||||
| Modify scrcpy mirroring logic | Edit `bin/pos-communication-scrcpy` (config scope `scrcpy` via `pos config scrcpy`; `SCRCPY_*` keys in `~/.config/linux_post_install/scrcpy.env`) |
|
||||
| Modify Music→USB sync logic | Edit `bin/pos-media-sync` / shared USB layer `lib/usb-lib.sh` (seams `MEDIA_SYNC_SOURCE`/`MEDIA_SYNC_DEST`/`USB_MOUNT_BASE`/`USB_BYID` in `~/.config/linux_post_install/system.env`) |
|
||||
| Modify YouTube channel sync logic | Edit `bin/pos-media-ytsync` (state in `~/.local/share/linux_post_install/ytsync`; config scope `ytsync` via `pos config ytsync`; research notes `tools-docs/ytsync.md`) |
|
||||
| Modify the scheduler / scheduled jobs | Edit `bin/pos-system-schedule` / `lib/scheduler-lib.sh` (jobs in `~/.config/linux_post_install/schedule.d/`) |
|
||||
| Modify AI/Gemini logic | Edit `bin/pos-ai-gemini` (config scope `ai` via `pos config ai`; `AI_GEMINI_API_KEY`/`AI_GEMINI_MODEL` in `~/.config/linux_post_install/ai.env`) |
|
||||
| Modify UFW/firewall logic | Edit `bin/pos-system-firewall` |
|
||||
|
||||
+7
-3
@@ -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` |
|
||||
@@ -72,7 +72,7 @@ Sourced by most scripts. Key functions:
|
||||
| `run cmd` | Executes command, respects `$DRY_RUN` |
|
||||
| `spawn "msg" cmd` | Animated braille spinner + elapsed time |
|
||||
| `timer_start` / `timer_stop` | Elapsed time tracking |
|
||||
| `confirm "prompt"` | y/N prompt with optional default |
|
||||
| `confirm "prompt" [default]` | y/n prompt; Enter accepts the default (`y` when omitted) |
|
||||
|
||||
---
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -428,6 +428,10 @@ pos-communication-<platform> send <value> [--markdown] # exit 0 on delivery
|
||||
|
||||
then listing it in `NOTIFY_PLATFORM`. Platform keys map to tool names via `notify_sender_name()` in `lib/notify.sh` — the telegram platform key stays `telegram` but its tool is `pos-communication-telegram-sender`. `pos-communication-telegram-sender` already follows this (`--markdown` is an alias for `--parse-mode markdown`). No changes to `lib/notify.sh` are needed for a new platform.
|
||||
|
||||
### Confirmation prompts
|
||||
|
||||
`confirm()` rule: Enter accepts the displayed default; destructive call sites pass explicit `'n'`.
|
||||
|
||||
### Idempotency
|
||||
|
||||
Check before creating, use `>>` with grep guards, don't overwrite user configs.
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ authoritative one-line reference (every command + flag), see
|
||||
| `pos ai` | Chat with Google Gemini from CLI or Telegram | [ai](howto/ai.md) |
|
||||
| `pos network` | IP info, hotspot, scan, port check, aria2 download daemon | [network](howto/network.md) |
|
||||
| `pos docker` | Compose services, container dashboards, disposable VMs | [docker](howto/docker.md) |
|
||||
| `pos media` | Download audio/video via yt-dlp | [media](howto/media.md) |
|
||||
| `pos media` | Download audio/video via yt-dlp; incremental YouTube channel sync | [media](howto/media.md) |
|
||||
| `pos system` | Backups, firewall, health dashboard | [system](howto/system.md) |
|
||||
| `pos system schedule` | Scheduled jobs: run a command on a timer, notify on threshold/change/error or silently | [schedule](howto/schedule.md) |
|
||||
| `pos ssh` | Load keys into the agent | [ssh](howto/ssh.md) |
|
||||
|
||||
+57
-12
@@ -56,12 +56,14 @@ Category-less tools (`config`, `tree`) live outside any category and are documen
|
||||
### ai
|
||||
|
||||
**File:** `bin/pos-ai-gemini`
|
||||
**Purpose:** chat with Google Gemini via the REST API (`generativelanguage.googleapis.com`). One tool, three subcommands: `ask` (one-shot, scriptable), `chat` (interactive multi-turn REPL), and `models` (list `generateContent`-capable ids).
|
||||
**Purpose:** chat with Google Gemini via the REST API (`generativelanguage.googleapis.com`). One tool, four subcommands: `ask` (scriptable, persistent session), `capture` (run a command and save its output for `--last`), `chat` (interactive multi-turn REPL), and `models` (list `generateContent`-capable ids).
|
||||
|
||||
| Command | Behavior |
|
||||
|---------|----------|
|
||||
| `pos ai gemini ask "<prompt>"` | One-shot; POSTs `generateContent` and prints **only** the answer text to stdout (pipe/script/Telegram-friendly). The prompt may also be piped in via stdin when no argument is given |
|
||||
| `pos ai gemini chat` | Interactive REPL with multi-turn history (the `contents[]` array is appended per turn); `q`/`quit`/`exit` or Ctrl+C quit, `/reset` clears the history, empty input re-prompts |
|
||||
| `pos ai gemini ask "<prompt>"` | POSTs `generateContent` and prints the answer text to stdout. The prompt may also be piped in via stdin when no argument is given. Runs in the persistent `default` session (`~/.local/share/linux_post_install/ai/default.json`, capped at 40 turns; `--session <name>` picks another). Terse by default: a built-in system instruction asks for commands-first minimal prose and to diagnose pasted errors/output with the fix first (`--system "<text>"` replaces it wholesale, `--full` skips it). With `--last`, the output of the most recent logged pos command or captured output (tail, max 4096 chars) is appended to the question. On a tty the answer is rendered as markdown (`glow` if installed, else a built-in renderer); non-tty stdout gets the raw markdown bytes unchanged |
|
||||
| `pos ai gemini capture <cmd..>` | Run a command, capture its stdout+stderr to screen and to `~/.local/share/linux_post_install/last_cmd_output` for `--last`. Each capture overwrites the previous one. Returns the command's exit code |
|
||||
| `pos ai gemini chat` | Interactive REPL with multi-turn history (the `contents[]` array is appended per turn and persisted to the session file — `default` unless `--session`); replies are rendered like `ask` on a tty; `q`/`quit`/`exit` or Ctrl+C quit, `/reset` clears the history, empty input re-prompts |
|
||||
| `pos ai gemini sessions` | Lists session files with turn counts; `sessions reset <name>` clears one (e.g. `reset default`) |
|
||||
| `pos ai gemini models` | Lists models that support `generateContent` and flags the configured default |
|
||||
| `pos ai gemini --model <id> …` | Overrides the model for one invocation |
|
||||
|
||||
@@ -78,6 +80,28 @@ Precedence: `--model` flag > `AI_GEMINI_MODEL` env > config file > `gemini-2.5-f
|
||||
|
||||
**Messaging bridges:** the Telegram and Matrix listeners forward non-command messages starting with `ai ` (case-insensitive) to `pos ai gemini ask` and reply with the model's answer — see [communication → listener](#communication). The Telegram bridge uses one session per chat (`telegram-<chat id>`), the Matrix bridge one per room (`matrix-<room>`).
|
||||
|
||||
**File:** `bin/pos-ai-openrouter`
|
||||
**Purpose:** chat with OpenRouter models via the REST API (`openrouter.ai`). One tool, four subcommands: `ask` (scriptable, persistent session), `capture` (run a command and save its output for `--last`), `chat` (interactive multi-turn REPL), and `sessions` (list/clear sessions). OpenRouter provides access to hundreds of models from different providers through a single OpenAI-compatible API.
|
||||
|
||||
| Command | Behavior |
|
||||
|---------|----------|
|
||||
| `pos ai openrouter ask "<prompt>"` | POSTs a chat completion and prints the answer text to stdout. The prompt may also be piped in via stdin when no argument is given. Runs in the persistent `default` session (`~/.local/share/linux_post_install/ai-openrouter/default.json`, capped at 40 turns; `--session <name>` picks another). Terse by default: a built-in system instruction asks for commands-first minimal prose and to diagnose pasted errors/output with the fix first (`--system "<text>"` replaces it wholesale, `--full` skips it). With `--last`, the output of the most recent logged pos command or captured output (tail, max 4096 chars) is appended to the question. On a tty the answer is rendered as markdown (`glow` if installed, else a built-in renderer); non-tty stdout gets the raw markdown bytes unchanged |
|
||||
| `pos ai openrouter capture <cmd..>` | Run a command, capture its stdout+stderr to screen and to `~/.local/share/linux_post_install/last_cmd_output` for `--last`. Each capture overwrites the previous one. Returns the command's exit code |
|
||||
| `pos ai openrouter chat` | Interactive REPL with multi-turn history (the `messages[]` array is appended per turn and persisted to the session file — `default` unless `--session`); replies are rendered like `ask` on a tty; `q`/`quit`/`exit` or Ctrl+C quit, `/reset` clears the history, empty input re-prompts |
|
||||
| `pos ai openrouter sessions` | Lists session files with turn counts; `sessions reset <name>` clears one (e.g. `reset default`) |
|
||||
| `pos ai openrouter --model <id> …` | Overrides the model for one invocation |
|
||||
|
||||
`pos ai openrouter` with no subcommand prints usage (never blocks on stdin). `ask`/`chat` time out after 60s per request; on a non-2xx response the API's `error.message` is shown and the tool exits non-zero.
|
||||
|
||||
**Configuration** (`~/.config/linux_post_install/ai-openrouter.env`, edit with `pos config ai-openrouter`):
|
||||
|
||||
| Key | Required | Default | Purpose |
|
||||
|-----|----------|---------|---------|
|
||||
| `OPENROUTER_API_KEY` | yes | — | API key from openrouter.ai (secret — masked in `pos config ai-openrouter`) |
|
||||
| `OPENROUTER_MODEL` | no | `openrouter/auto` | Model id used by `ask`/`chat`/`models` |
|
||||
|
||||
Precedence: `--model` flag > `OPENROUTER_MODEL` env > config file > `openrouter/auto`. Dependencies: `curl` + `jq` (both in `preinstall.sh` PACKAGES).
|
||||
|
||||
### network
|
||||
|
||||
| Command | File | Purpose | Configuration |
|
||||
@@ -133,6 +157,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.
|
||||
@@ -143,8 +169,11 @@ Runs a persistent `aria2c` JSON-RPC daemon (`localhost:6800`) as a **systemd use
|
||||
|---------|------|---------|---------------|
|
||||
| `pos docker ps` | `bin/pos-docker-ps` | Enhanced container list: name, image, health, uptime, IPs, ports, ID, plus a healthy/unhealthy summary | None. Requires Docker + Python 3 |
|
||||
| `pos docker health` | `bin/pos-docker-health` | One-glance health dashboard; **exits 1** if any container is unhealthy | None. Checks all containers including stopped ones |
|
||||
| `pos docker stack [-a]` | `bin/pos-docker-stack` | Containers grouped by compose stack (project); `-a` includes stopped; non-compose containers under `Standalone` | None. Requires Docker |
|
||||
| `pos docker compose …` | `bin/pos-docker-compose` | ScaleTail service manager | See [Docker Compose / ScaleTail](#docker-compose--scaletail) below |
|
||||
|
||||
**`pos docker stack [-a|--all]`** — containers grouped by their Docker Compose project (stack). Each stack is a section (project name, sorted) whose lines show container name, status, and port mappings (`-` when none — e.g. ScaleTail sidecar containers). Containers with no compose project land in a `Standalone` section at the end. Default shows running containers; `-a|--all` includes stopped/exited (like `docker ps -a`). Status is colored when output goes to a terminal: `Up*` green, `Exited*`/`Dead*`/`Created*` red, `Paused*`/`Restarting*` yellow. Ends with a summary line (`Stacks: N containers: N standalone: N`); exit 0 even when no containers exist.
|
||||
|
||||
#### Docker Compose / ScaleTail
|
||||
|
||||
**`pos docker compose ls`** — list available ScaleTail service templates.
|
||||
@@ -169,6 +198,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 |
|
||||
@@ -193,11 +224,13 @@ Global config keys:
|
||||
|
||||
| Command | Behavior |
|
||||
|---------|----------|
|
||||
| `pos docker vbox create <name> [image] [--dir <path>]` | Creates a container from `ubuntu:22.04` (or the given image), bind-mounting `~/<name>` (or `--dir`, or `.` for cwd) as the working directory; prompts to enter immediately |
|
||||
| `pos docker vbox create <name> [image] [--dir <path>]… [--device </dev/node>]… [--gpu] [--port H:C]… [--cpus N] [--memory SIZE] [--network MODE]` | Creates a container from `ubuntu:22.04` (or the given image), bind-mounting `~/<name>` (or the first `--dir`; repeatable for extra same-path mounts) as the working directory; optional flags add GPU (`--gpus all`), device passthrough, port publishes and cpu/memory limits; prompts to enter immediately |
|
||||
| `pos docker vbox enter <name>` | Shell into the container (auto-starts it if stopped); detects the working dir from the container mounts |
|
||||
| `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 (categorized flow: name → category hub with live basket counts — image quick-picks, GPU/Nvidia with automatic toolkit/device-node detection, host devices, dir mounts, ports, CPU/RAM → review screen rendering the exact `docker create` plan before anything runs; 'n' returns to the hub with edits preserved), 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
|
||||
@@ -206,17 +239,29 @@ 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
|
||||
(`--no-playlist`) — nobody accidentally backfills a 500-video playlist from a watch
|
||||
link; `youtu.be/<id>` short links count as watch links too. A pure playlist link
|
||||
becomes a tracked playlist source with numbered
|
||||
`<NNN> - <title>.mp4` files. `pos media ytsync remove <name>` stops tracking but
|
||||
keeps the downloaded files AND the archive — re-adding the same source later
|
||||
resumes incrementally instead of re-downloading. Members-only/age-gated videos are
|
||||
reported as "N videos require sign-in — skipped" (escape hatch:
|
||||
`YTSYNC_EXTRA_ARGS="--cookies …"` in `ytsync.env`). Research details:
|
||||
`tools-docs/ytsync.md`.
|
||||
|
||||
### system
|
||||
|
||||
| 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).
|
||||
|
||||
@@ -249,14 +294,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
|
||||
|
||||
|
||||
+16
-2
@@ -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) |
|
||||
@@ -210,7 +210,21 @@ Sourced by `bin/pos-entertainment-send|config|enable|disable|status` (after `lib
|
||||
## lib/usb-lib.sh — shared USB-storage detection
|
||||
|
||||
**File:** `lib/usb-lib.sh` (installed to `/usr/local/bin/usb-lib.sh`)
|
||||
**Purpose:** the one copy of the USB-storage machinery shared by `pos system backup`'s post-verify USB copy and `pos media sync` — `usb_detect` (lsblk JSON, TRAN + lsusb/by-id cross-check → `USB_MOUNTED`/`USB_UNMOUNTED`), `usb_related_present`, `usb_mount_offer` (mount an unmounted stick at `/media/<label>`, `usb-automount` scheme), and `usb_pick_root` (detect → mount-offer → single/multi picker → `USB_ROOT`). Defines only `usb_*`; seams `USB_MOUNT_BASE` (default `/media`, alias `BACKUP_MOUNT_BASE`) and `USB_BYID` (default `/dev/disk/by-id`, alias `BACKUP_USB_BYID`) keep existing config lines working.
|
||||
**Purpose:** the one copy of the USB-storage machinery shared by `pos system backup`'s post-verify USB copy and `pos media sync` — `usb_detect` (lsblk JSON, TRAN + lsusb/by-id cross-check → `USB_MOUNTED` as `mp|label|size|model|fs` entries and `USB_UNMOUNTED` as `path|label|size|model`; EFI system partitions — Ventoy `VTOYEFI`, `/boot/efi` — are excluded from both), `usb_related_present`, `usb_mount_offer` (mount an unmounted stick at `/media/<label>`, `usb-automount` scheme), and `usb_pick_root` (detect → mount-offer → single-confirm or multi-picker, rows showing size/label/fs → `USB_ROOT` set to the bare mountpoint). Defines only `usb_*`; seams `USB_MOUNT_BASE` (default `/media`, alias `BACKUP_MOUNT_BASE`) and `USB_BYID` (default `/dev/disk/by-id`, alias `BACKUP_USB_BYID`) keep existing config lines working.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+121
-13
@@ -1,18 +1,54 @@
|
||||
# How-To: `pos ai`
|
||||
|
||||
Chat with Google Gemini — from the terminal and through the Telegram bot.
|
||||
Tools: `gemini` (`ask`, `chat`, `models`).
|
||||
Chat with AI models — Gemini, OpenRouter, and more — from the terminal and
|
||||
through the Telegram bot.
|
||||
Tools: `gemini` (`ask`, `chat`, `models`), `openrouter` (`ask`, `chat`, `sessions`).
|
||||
|
||||
| Tool | What it does |
|
||||
|------|--------------|
|
||||
| `pos ai gemini ask "<prompt>"` | One-shot answer to stdout (scriptable) |
|
||||
| `pos ai gemini ask --session <name> "…"` | Same, but remembers prior turns |
|
||||
| `pos ai gemini chat` | Interactive multi-turn conversation |
|
||||
| `pos ai gemini ask "<prompt>"` | Answer to stdout (scriptable; terse by default, `--full` for long form). Runs inside the persistent **`default`** session — it remembers prior turns across invocations |
|
||||
| `pos ai gemini ask --last "why did that fail?"` | Same, but also appends the output of the **most recent logged pos command or captured output** so the model can diagnose a real failure (stderr notes which source + staleness warning) |
|
||||
| `pos ai gemini capture <cmd>` | Run any command, capture its output for `--last`, and show it on screen |
|
||||
| `pos ai gemini ask --session <name> "…"` | Same, but uses a named session instead of `default` |
|
||||
| `pos ai gemini chat` | Interactive multi-turn conversation (session `default` unless `--session`) |
|
||||
| `pos ai gemini models` | List available model ids |
|
||||
| `pos ai gemini sessions` | List/clear persistent sessions (`reset <name>`) |
|
||||
| `pos ai gemini sessions` | List persistent sessions / clear one (`reset <name>`, e.g. `reset default`) |
|
||||
|
||||
Shared flags: `--model <id>` overrides the model; `--system "<text>"` adds a
|
||||
system instruction to every turn (kept out of the session file).
|
||||
Shared flags: `--model <id>` overrides the model; `--system "<text>"` sets the
|
||||
system instruction for every turn (kept out of the session file) — it replaces
|
||||
the built-in terse ask prompt wholesale; `--full` skips that built-in prompt
|
||||
for long-form answers; `--last` attaches the latest pos command output or
|
||||
captured output (tail, max 4096 chars) to the question and notes on stderr
|
||||
which source was attached, its age, and a staleness warning once it is older
|
||||
than an hour (`ask` only; stdout stays pure answer). Use `capture` to save
|
||||
output from any command for `--last`.
|
||||
|
||||
Every `ask`/`chat` lands in a persistent session file under
|
||||
`~/.local/share/linux_post_install/ai/<name>.json` (capped at 40 turns).
|
||||
Terminal work accumulates in `default`; clear it with
|
||||
`pos ai gemini sessions reset default`.
|
||||
|
||||
---
|
||||
|
||||
## Terse by default, rendered on screen
|
||||
|
||||
`ask` prepends a built-in system instruction telling the model to work like a
|
||||
CLI assistant: lead with the exact commands, one-line explanations, no essays —
|
||||
and when the message is a "how do I install/update/solve/edit X" question or
|
||||
pastes an error/command output, diagnose it and lead with the fix command(s).
|
||||
That prompt ends with one machine-context line (hostname, distro, kernel and
|
||||
architecture detected on this box), so answers match the actual machine;
|
||||
`--system "<text>"` swaps it wholesale; `--full` drops it for long-form output.
|
||||
`chat` keeps its neutral behavior (only `--system` applies).
|
||||
|
||||
On a terminal, answers are rendered as markdown, separated from your prompt
|
||||
line by one blank line: fenced code blocks stay
|
||||
monospace (indented + dimmed), inline `` `code` `` turns yellow, `**bold**`
|
||||
turns bold, headers become bold cyan, `---` becomes a thin rule. If `glow` is
|
||||
installed it is used automatically; otherwise a small built-in renderer kicks
|
||||
in — no extra dependency either way. When stdout is **not** a tty (pipes,
|
||||
scripts, cron, the Telegram/Matrix bridges) the raw markdown bytes are printed
|
||||
exactly as before (no added blank lines), so scripting stays byte-stable.
|
||||
|
||||
---
|
||||
|
||||
@@ -37,6 +73,47 @@ system instruction to every turn (kept out of the session file).
|
||||
`ai.env` lives at `~/.config/linux_post_install/ai.env` (chmod 600); `pos config ai`
|
||||
is the only place the key is written. The key is never printed by `pos`.
|
||||
|
||||
## OpenRouter — many providers, one key
|
||||
|
||||
[OpenRouter](https://openrouter.ai) gives access to hundreds of models from
|
||||
different providers (Anthropic, OpenAI, Meta, Mistral, Google, …) through a
|
||||
single OpenAI-compatible API. `pos ai openrouter` works identically to the
|
||||
Gemini tool — same subcommands (`ask`, `chat`, `sessions`), same flags
|
||||
(`--last`, `--system`, `--full`, `--session`), same terminal rendering and
|
||||
machine context.
|
||||
|
||||
1. Get an API key from https://openrouter.ai/settings/keys.
|
||||
|
||||
2. Configure it:
|
||||
|
||||
```bash
|
||||
pos config ai-openrouter # enter OPENROUTER_API_KEY
|
||||
```
|
||||
|
||||
3. Test:
|
||||
|
||||
```bash
|
||||
pos ai openrouter ask "hi"
|
||||
```
|
||||
|
||||
The default model is `openrouter/auto` (OpenRouter picks the best available
|
||||
provider automatically). Override with `--model provider/model-name`:
|
||||
|
||||
```bash
|
||||
pos ai openrouter ask --model anthropic/claude-sonnet-4 "explain DNS"
|
||||
```
|
||||
|
||||
Sessions are stored separately from Gemini's:
|
||||
|
||||
```
|
||||
~/.local/share/linux_post_install/ai-openrouter/<name>.json
|
||||
```
|
||||
|
||||
All features work the same way — `--last` for diagnosing failures, `--system`
|
||||
for custom instructions, `--full` for long-form answers, persistent sessions,
|
||||
tty markdown rendering, and machine context. The only difference is the backend
|
||||
API.
|
||||
|
||||
## From the Telegram bot
|
||||
|
||||
Once `pos ai gemini ask` works, any non-command message starting with `ai ` is
|
||||
@@ -57,7 +134,8 @@ you: ai --model gemini-2.5-flash explain a Raft consensus log
|
||||
|
||||
### Telegram memory & formatting
|
||||
|
||||
Each chat has its own persistent session (`telegram-<chat id>`), so the model
|
||||
Each chat has its own persistent session (`telegram-<chat id>` — independent
|
||||
of your terminal's `default` session), so the model
|
||||
remembers the conversation; `ai /reset` clears it. The listener passes a system
|
||||
prompt telling the model it is answering in a Telegram chat — so it uses emojis
|
||||
and stays lively — and strips markdown (`**x**`, backticks, `#`, links…) from
|
||||
@@ -73,23 +151,53 @@ you: ai check this details about my linux ← reply to the /status message
|
||||
|
||||
## Recipes
|
||||
|
||||
- **Diagnose the last failed pos run:** `pos ai gemini ask --last "why did that fail?"` — every non-interactive `pos <cmd>` logs its output to `~/.local/share/linux_post_install/logs/`; `--last` attaches the newest one (tail, max 4096 chars, errors at the bottom kept) and says on stderr which log it grabbed (name, age, first line). Older than an hour? You get a `[!]` staleness warning — the newest log may predate your current problem, so pipe the fresh failure in instead
|
||||
- **Pipe arbitrary output in:** `failing-cmd 2>&1 | pos ai gemini ask how do I fix this`
|
||||
- **Answer from a file:** `pos ai gemini ask "$(cat notes.txt)"`
|
||||
- **Pipe into it:** `echo "fix this: $(cat error.log)" | pos ai gemini ask`
|
||||
- **Answer in a cron job:** `pos ai gemini ask "summarize today's git log" > /tmp/ai_digest.txt`
|
||||
- **Long-form on demand:** `pos ai gemini ask --full "compare ext4 and zfs in depth"`
|
||||
- **Forget what the terminal asked:** `pos ai gemini sessions reset default`
|
||||
- **Change the default model:**
|
||||
```bash
|
||||
pos config ai # set AI_GEMINI_MODEL, or:
|
||||
AI_GEMINI_MODEL=gemini-2.5-flash pos ai gemini ask "hi"
|
||||
```
|
||||
|
||||
## Capturing any command's output for --last
|
||||
|
||||
By default, `--last` reads from pos dispatcher logs (only pos commands). To analyze
|
||||
output from **any** command (`pip install`, `apt upgrade`, `make`, etc.):
|
||||
|
||||
**Option A — explicit capture:**
|
||||
```bash
|
||||
pos ai gemini capture pip install xyz
|
||||
pos ai gemini ask --last "what happened"
|
||||
```
|
||||
The `capture` subcommand runs the command, shows its output on screen, and saves it
|
||||
for `--last`. Each `capture` overwrites the previous one (latest only).
|
||||
|
||||
**Option B — automatic capture (shell hook):**
|
||||
```bash
|
||||
# Add to ~/.bashrc:
|
||||
source /usr/local/bin/pos-ai-hook.sh
|
||||
```
|
||||
After sourcing, every command's output is silently captured. Then just run any
|
||||
command and `--last` picks it up automatically. Captures up to 1 MB (oldest
|
||||
truncated). To disable: `unset __POS_CAPTURE_ACTIVE`.
|
||||
|
||||
## How it works
|
||||
|
||||
- `ask` POSTs `contents:[{role:user, parts:[{text:"…"}]}]` to
|
||||
- `ask` POSTs `contents:[…]` (prior turns of the active session plus the new
|
||||
user turn) to
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/<model>:generateContent`
|
||||
with the key in the `x-goog-api-key` header, and prints
|
||||
`.candidates[0].content.parts[].text` — nothing else.
|
||||
- `chat` keeps the whole conversation in memory as a growing `contents[]` array,
|
||||
so later turns have earlier context. `/reset` drops it.
|
||||
- Sessions live as one JSON file per name under
|
||||
`~/.local/share/linux_post_install/ai/` (`default.json` unless `--session`);
|
||||
each turn is appended and the file is pruned to the last 40 turns.
|
||||
- `chat` keeps the whole conversation in memory as a growing `contents[]`
|
||||
array (seeded from the session file), so later turns have earlier context.
|
||||
`/reset` drops it (and empties the session file).
|
||||
- On a non-2xx response the API's `error.message` is shown and the exit code is
|
||||
non-zero — so scripts can rely on `ask` failing loudly.
|
||||
|
||||
|
||||
+41
-1
@@ -1,13 +1,14 @@
|
||||
# How-To: `pos docker`
|
||||
|
||||
Manage self-hosted services, watch containers, and spin up disposable VMs.
|
||||
Tools: `compose`, `ps`, `health`, `vbox`.
|
||||
Tools: `compose`, `ps`, `health`, `stack`, `vbox`.
|
||||
|
||||
| Tool | What it does |
|
||||
|------|--------------|
|
||||
| `pos docker compose` | ScaleTail service manager (deploy/stop/logs/update) |
|
||||
| `pos docker ps` | Enhanced container overview (health, IPs, ports, uptime) |
|
||||
| `pos docker health` | One-glance health dashboard (exits 1 if unhealthy) |
|
||||
| `pos docker stack` | Containers grouped by compose stack (`-a` includes stopped) |
|
||||
| `pos docker vbox` | Disposable Docker containers as lightweight VMs |
|
||||
|
||||
---
|
||||
@@ -100,6 +101,31 @@ not unhealthy.
|
||||
|
||||
---
|
||||
|
||||
## `pos docker stack` — containers by compose stack
|
||||
|
||||
```bash
|
||||
pos docker stack # running containers grouped by compose project
|
||||
pos docker stack -a # include stopped/exited containers
|
||||
```
|
||||
|
||||
Grouped view of your compose deployments: every stack is a section with its
|
||||
services (container name, status, ports); containers started outside compose
|
||||
land in a `Standalone` group at the end. `-a` behaves like `docker ps -a` and
|
||||
also shows exited services (e.g. one-shot migration jobs that `docker ps`
|
||||
hides).
|
||||
|
||||
**Recipe:** after `pos docker compose up <svc>`, confirm it joined the right
|
||||
stack:
|
||||
|
||||
```bash
|
||||
pos docker stack | grep -A5 <svc>
|
||||
```
|
||||
|
||||
**Recipe:** spot what's restarting across all stacks at a glance (yellow
|
||||
`Restarting` / red `Exited` statuses stand out on a terminal).
|
||||
|
||||
---
|
||||
|
||||
## `pos docker vbox` — disposable VMs
|
||||
|
||||
Docker containers with a bind-mounted host dir so files persist even after the
|
||||
@@ -110,6 +136,8 @@ pos docker vbox create lab1 # default dir ~/lab1
|
||||
pos docker vbox create lab1 --dir . # files land in cwd
|
||||
pos docker vbox create lab1 --dir /mnt/data/lab1
|
||||
pos docker vbox create kali kalilinux/kali-rolling # custom image
|
||||
pos docker vbox create ai --gpu --cpus 4 --memory 8g
|
||||
pos docker vbox create iot --device /dev/ttyUSB0 --port 8080:80
|
||||
pos docker vbox enter lab1
|
||||
pos docker vbox stop lab1
|
||||
pos docker vbox start lab1
|
||||
@@ -117,6 +145,16 @@ pos docker vbox rm lab1
|
||||
pos docker vbox ls
|
||||
```
|
||||
|
||||
**Interactive create:** bare `pos docker vbox` (or the menu's "Create a VM")
|
||||
walks a name prompt → category hub → review screen that renders the exact
|
||||
`docker create` plan before anything is pulled; confirming runs the same
|
||||
`create` verb as the CLI. Categories: image quick-picks, GPU/Nvidia (offers
|
||||
`--gpus all` when the Nvidia container toolkit is present, explicit device
|
||||
nodes otherwise, info line when no GPU exists), host devices (USB, serial,
|
||||
video/sound, disks — system disks labelled), extra host-dir mounts, port
|
||||
publishes, CPU/RAM limits. Quitting or EOF at any point discards — nothing is
|
||||
created without an explicit `y` at the review.
|
||||
|
||||
**Recipe:** a disposable browsing/download box:
|
||||
```bash
|
||||
pos docker vbox create dl --dir /mnt/data/dl
|
||||
@@ -128,6 +166,8 @@ pos docker vbox rm dl # container gone, files kept
|
||||
- `enter` needs a shell/SSH-capable image; `kalilinux/kali-rolling` works.
|
||||
- If files "disappear" after `rm`, check you used `--dir` on a real path — the
|
||||
container image changes are lost, only the mounted dir persists.
|
||||
- `--gpu` needs `nvidia-container-toolkit`; without it, pass explicit nodes
|
||||
instead (`--device /dev/nvidia0 --device /dev/nvidiactl --device /dev/nvidia-uvm`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+100
-2
@@ -1,13 +1,15 @@
|
||||
# How-To: `pos media`
|
||||
|
||||
Download audio and video from the web via `yt-dlp`, and sync your library to a
|
||||
USB stick. Tools: `mp3`, `mp4`, `sync`.
|
||||
Download audio and video from the web via `yt-dlp`, sync your library to a
|
||||
USB stick, and keep YouTube channels incrementally up to date.
|
||||
Tools: `mp3`, `mp4`, `sync`, `ytsync`.
|
||||
|
||||
| Tool | What it does |
|
||||
|------|--------------|
|
||||
| `pos media mp3` | Download audio, convert to MP3 |
|
||||
| `pos media mp4` | Download video with smart/interactive format selection |
|
||||
| `pos media sync` | Incrementally copy `~/Music` onto a USB stick (mp3/mp4) |
|
||||
| `pos media ytsync` | Track YouTube channels/playlists and download only new videos into `~/Videos` |
|
||||
|
||||
Requires `yt-dlp` and `ffmpeg` (`sudo apt install yt-dlp ffmpeg`); the tools
|
||||
fail with a clean error message instead of a raw `command not found` if either
|
||||
@@ -116,6 +118,14 @@ The source folder may be a symlink to a library elsewhere
|
||||
(`~/Music -> /mnt/data/music`) — it is followed, the artist/album tree is
|
||||
mirrored under the symlink's target.
|
||||
|
||||
**Target picking.** Mounted USB partitions are listed with size, label and
|
||||
filesystem (single candidate → confirm prompt; several → numbered picker, one
|
||||
row per partition). EFI system partitions (e.g. a Ventoy stick's `VTOYEFI`,
|
||||
32 MB) are **never** offered as a target — they are boot machinery, not
|
||||
storage. Before any copy the tool verifies the payload fits (`df` vs the
|
||||
exact bytes to copy) and fails fast with `Not enough free space on …` instead
|
||||
of dying mid-copy with `No space left on device`.
|
||||
|
||||
| Flag | Meaning |
|
||||
|------|---------|
|
||||
| `--mp3` | Sync only `*.mp3` (neither flag = both) |
|
||||
@@ -139,6 +149,85 @@ Config (all in `~/.config/linux_post_install/system.env` or exported):
|
||||
|
||||
---
|
||||
|
||||
## `pos media ytsync` — incremental YouTube channel sync
|
||||
|
||||
```bash
|
||||
pos media ytsync # interactive
|
||||
pos media ytsync add https://youtube.com/@SomeChannel
|
||||
pos media ytsync sync # fetch new videos from every source
|
||||
pos media ytsync list
|
||||
pos media ytsync remove "Some Channel"
|
||||
```
|
||||
|
||||
The first run asks for a channel or playlist URL, shows what was resolved
|
||||
(`Resolved : Linus Tech Tips (channel · 2140 videos)` + the target library path),
|
||||
and downloads everything. Every later run probes the same URL, diffs it against a
|
||||
per-source download archive, and fetches **only new videos** — one yt-dlp call per
|
||||
video (`bestvideo*+bestaudio/best` merged to MP4 with metadata, chapters and the
|
||||
thumbnail embedded; existing files are never overwritten).
|
||||
|
||||
Where files land:
|
||||
|
||||
```
|
||||
~/Videos/<channel>/<title>.mp4 # channels & single videos
|
||||
~/Videos/<channel>/<playlist>/<NNN> - <title>.mp4 # playlist sources (playlist order)
|
||||
```
|
||||
|
||||
| Command | Meaning |
|
||||
|---------|---------|
|
||||
| `add [url]` | Register a source + first download (asks for the URL if omitted) |
|
||||
| `sync [name]` | Incremental pass — all tracked sources, or one by slug/display name |
|
||||
| `list` | Tracked sources table: type, archived count, last sync date, destination |
|
||||
| `remove <name>` | Stop tracking. Downloaded files AND the archive are kept |
|
||||
|
||||
Notes:
|
||||
|
||||
- A watch link with **both** `?v=` and `&list=` downloads only that single video,
|
||||
never the whole playlist; `youtu.be/<id>` short links count as watch links too.
|
||||
- Retitled/renamed videos keep their local filenames (the archive is keyed by video
|
||||
id); inserting a video mid-playlist shifts future numbering only.
|
||||
- Members-only / age-gated videos are skipped with a count ("N videos require
|
||||
sign-in"). Escape hatch: put `YTSYNC_EXTRA_ARGS=--cookies-from-browser firefox`
|
||||
(or `--cookies <file>`) into the config file below.
|
||||
- State lives outside `~/Videos`, in `~/.local/share/linux_post_install/ytsync/`
|
||||
(registry, per-source archives, run history) — the media tree stays pure media.
|
||||
|
||||
**Recipe: daily automation** — no built-in timers; use the shared scheduler:
|
||||
|
||||
```bash
|
||||
pos system schedule config # name: ytsync
|
||||
# INTERVAL=daily (or hourly / weekly / OnCalendar=…)
|
||||
# NOTIFY=never # ytsync sends its own digest; don't double-notify
|
||||
# MSG="ytSync"
|
||||
# COMMAND=pos media ytsync sync
|
||||
pos system schedule enable ytsync
|
||||
pos system schedule run ytsync # test once, right now
|
||||
```
|
||||
|
||||
ytsync never prompts on `sync`, so the job is timer-safe by construction. It
|
||||
notifies via `lib/notify.sh` only when something happened (new videos or failures);
|
||||
a scheduled no-op run stays silent — hence `NOTIFY=never` on the job to avoid
|
||||
double alerts.
|
||||
|
||||
**Previewing:** `--dry-run` works on `add` and `sync` — real probe + plan block
|
||||
(`New : 12 would be downloaded (800 already present)` + example filenames), zero
|
||||
writes anywhere:
|
||||
|
||||
```bash
|
||||
pos media ytsync sync --dry-run
|
||||
pos media ytsync add https://youtube.com/@SomeChannel --dry-run
|
||||
```
|
||||
|
||||
Config (`~/.config/linux_post_install/ytsync.env`, chmod 600 — materialize/edit via
|
||||
`pos config ytsync`; exported environment wins):
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `YTSYNC_VIDEOS_DIR` | `$HOME/Videos` | Videos root for synced sources |
|
||||
| `YTSYNC_EXTRA_ARGS` | *(empty)* | Extra yt-dlp flags appended to every download call (cookies, format overrides, …) |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Format list is empty / download fails → the site or age-gate requires
|
||||
@@ -149,6 +238,15 @@ Config (all in `~/.config/linux_post_install/system.env` or exported):
|
||||
it falls back to the uploader name in the artist slot.
|
||||
- Very large downloads: ensure free space; files land in `~/Music`/`~/Videos`
|
||||
(or your `-o` directory).
|
||||
- ytsync: "could not reach YouTube" at add → connectivity/DNS; the interactive
|
||||
flow re-prompts up to 3×. "source not found or private" → wrong handle or a
|
||||
deleted/private source.
|
||||
- ytsync: many "exists, kept" warnings after a YouTube-side change → the
|
||||
channel's internal id changed, so re-adding computed a new archive. Rename the
|
||||
old archive file (`~/.local/share/linux_post_install/ytsync/archive/<old>.txt`)
|
||||
to the new slug before syncing (see `tools-docs/ytsync.md`).
|
||||
- ytsync: "disk full — stopping … mid-run" → free space on the videos volume and
|
||||
run `pos media ytsync sync` again; unfetched videos simply stay "new".
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -55,6 +55,12 @@ omitted.
|
||||
- **Dedicated USB-over-network box:** set `--port` once, then clients connect
|
||||
to that port.
|
||||
|
||||
**Interactive menu:** run `pos share usb server` with no args for a menu
|
||||
(list / share / unshare / auto-share / disconnect …). The share flow lists
|
||||
devices and clients from the server as pickers — no IDs to memorize; if the
|
||||
server listing can't be read, it prints the raw output and falls back to
|
||||
manual ID entry.
|
||||
|
||||
**Troubleshooting:**
|
||||
- `usbsrv: command not found` → the binary isn't installed; get it from
|
||||
incentivespro.com and drop it in `x64_bin/` (or `arm64_bin/`) then re-run
|
||||
@@ -101,6 +107,12 @@ to restrict it** — print the restricted form:
|
||||
- **Read-only backups to a LAN host:** use `(ro,sync,no_subtree_check)` and only
|
||||
`enable` the server where it's needed.
|
||||
|
||||
**Interactive menu:** run `pos share nfs server` with no args for a menu
|
||||
(share / unshare / list / reload / enable / disable / status). The share flow
|
||||
offers mounted folders as a picker and client-spec presets (open, WireGuard,
|
||||
LAN, single IP) so you don't hand-type export specs; an inactive
|
||||
`nfs-server` service or a UFW conflict is offered as a one-key fix.
|
||||
|
||||
**Troubleshooting:**
|
||||
- "exportfs not found" → `nfs-kernel-server` isn't installed; `sudo apt install nfs-kernel-server`
|
||||
- Client sees "mount.nfs: Permission denied" → your `/etc/exports` client rule
|
||||
@@ -139,6 +151,17 @@ up — a down/unreachable NFS server can't break boot (with fstab it could).
|
||||
- **One-off mount (no persistence):**
|
||||
`pos share nfs client mount 10.0.0.5:/srv/data /mnt/data`
|
||||
|
||||
**Interactive menu:** run `pos share nfs client` with no args for a menu
|
||||
(mount / persist / unmount / unpersist / list). Mountpoints are offered from
|
||||
existing mount-layout candidates with manual entry as fallback — the picker
|
||||
also accepts the server-side export path as a "(as on server)" pick when it
|
||||
differs from your local layout, and `n=new` creates a fresh directory in
|
||||
place (y/N confirmed; a failure just returns to the picker). Unmount lists
|
||||
the active NFS mounts as `<mountpoint> ← <source>` picks and asks for
|
||||
confirmation before unmounting (with a typed fallback when nothing is
|
||||
mounted); unmount and unpersist tolerate already-absent targets instead of
|
||||
erroring.
|
||||
|
||||
**Troubleshooting:**
|
||||
- "mount.nfs not found" → `nfs-common` isn't installed; `sudo apt install nfs-common`
|
||||
- Mount hangs → check the server export (`pos share nfs server list` on the
|
||||
@@ -197,6 +220,11 @@ Both are warnings only — the share is still written.
|
||||
- **Change a share's access later:** re-run `share` with the same name — the
|
||||
block is replaced, not duplicated.
|
||||
|
||||
**Interactive menu:** run `pos share smb server` with no args for a menu
|
||||
(share / unshare / list / users / reload / enable / disable / status). The
|
||||
share flow offers mounted folders as a picker and walks through read-only /
|
||||
guest / valid-users confirms; a UFW conflict is offered as a one-key fix.
|
||||
|
||||
**Troubleshooting:**
|
||||
- "smbd not found" → `samba` isn't installed; `sudo apt install samba`
|
||||
- Windows can't connect → check the client is in `--users` / has a Samba
|
||||
@@ -259,6 +287,18 @@ fstab it could). `enable --now` arms the automount immediately.
|
||||
persistent units under "Persistent (automount)", so the configured shares are
|
||||
visible even before their first access
|
||||
|
||||
**Interactive menu:** run `pos share smb client` with no args for a menu
|
||||
(enumerate / mount / persist / unmount / unpersist / list). Enter the server,
|
||||
an empty user tries guest enumeration first (with an auth retry on denial),
|
||||
then shares and mountpoints are offered as pickers with manual fallback —
|
||||
the account you authenticated with is reused for the mount. The mountpoint
|
||||
picker accepts `n=new` to create a fresh directory in place (y/N confirmed;
|
||||
a failure just returns to the picker); when the server is this machine, its
|
||||
underlying share directory is offered as a "(as on server)" pick too.
|
||||
Unmount lists the active CIFS mounts as `<mountpoint> ← <source>` picks and
|
||||
asks for confirmation before unmounting (typed fallback when nothing is
|
||||
mounted).
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
@@ -148,9 +148,12 @@ EXAMPLES
|
||||
pos docker compose ls List available ScaleTail services
|
||||
pos docker compose up jellyfin Deploy a service with Tailscale
|
||||
pos docker health One-glance health dashboard
|
||||
pos docker stack Containers grouped by compose stack
|
||||
pos docker stack -a Include stopped/exited containers
|
||||
|
||||
pos media mp3 <url> Download audio as MP3
|
||||
pos media mp4 <url> Download video as MP4
|
||||
pos media ytsync sync Incremental YouTube channel sync
|
||||
|
||||
pos system firewall Interactive UFW manager
|
||||
pos system backup /srv/project Encrypted (AES-256) folder snapshot
|
||||
@@ -256,7 +259,7 @@ MAIN_LOG="$LOG_DIR/pos.log"
|
||||
log_cmd() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $* → exit $2" >> "$MAIN_LOG"; }
|
||||
|
||||
# Commands that read from stdin interactively — only log invocation
|
||||
INTERACTIVE_CMDS="docker-compose docker-vbox network-hotspot system-firewall media-mp4 media-sync system-backup share-usb-server share-smb-server share-smb-client communication-telegram-listener communication-matrix-listener ai-gemini system-schedule entertainment-config config"
|
||||
INTERACTIVE_CMDS="docker-compose docker-vbox network-hotspot system-firewall media-mp4 media-sync system-backup share-usb-server share-smb-server share-smb-client share-nfs-client share-nfs-server communication-telegram-listener communication-matrix-listener ai-gemini ai-openrouter system-schedule entertainment-config config"
|
||||
|
||||
for ((i=n-1; i>=0; i--)); do
|
||||
cmd="pos"
|
||||
|
||||
+331
-46
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: ai gemini — Chat with Google Gemini (ask, chat, models, sessions)
|
||||
# POS_SUBCMDS: ask chat models sessions
|
||||
# POS_FLAGS: --model --session --system
|
||||
# POS: ai gemini — Chat with Google Gemini (ask, capture, chat, models, sessions)
|
||||
# POS_SUBCMDS: ask capture chat models sessions
|
||||
# POS_FLAGS: --model --session --system --full --last
|
||||
# POS_CONFIG: ai | ai.env | AI_GEMINI_API_KEY=secret:API key from aistudio.google.com | AI_GEMINI_MODEL=:Model id (default gemini-2.5-flash)
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
@@ -11,50 +11,86 @@ CONFIG_FILE="$HOME/.config/linux_post_install/ai.env"
|
||||
API="https://generativelanguage.googleapis.com/v1beta"
|
||||
DEFAULT_MODEL="gemini-2.5-flash"
|
||||
SESSION_DIR="$HOME/.local/share/linux_post_install/ai"
|
||||
SESSION=""
|
||||
DISPATCH_LOG_DIR="$HOME/.local/share/linux_post_install/logs" # bin/pos per-run logs
|
||||
LAST_CMD_OUTPUT_FILE="$HOME/.local/share/linux_post_install/last_cmd_output" # --last fallback for any command
|
||||
OS_RELEASE_FILE="${OS_RELEASE_FILE:-/etc/os-release}" # read-only test seam (DEV.md env-overridable paths)
|
||||
SESSION="default"
|
||||
SYSTEM_PROMPT=""
|
||||
MAX_SESSION_TURNS=40
|
||||
LAST_LOG_MAX_BYTES=4096
|
||||
LAST_LOG_STALE_SECS=3600 # --last: warn when the attached log is older than this
|
||||
# Built-in terse ask prompt. cmd_ask appends a machine-context clause
|
||||
# (see machine_context) unless --system replaces it or --full drops everything.
|
||||
DEFAULT_SYSTEM_PROMPT="You assist a user working in a Linux/Unix CLI terminal. Be extremely terse: lead with the exact command(s) to run; one-line explanations max; short bullets only when necessary; no greetings, no closing offers, no essays. The user's message may be an install/update/solve/edit question ('how do I …') and/or may paste a problem, error, or command output: diagnose it from that and lead with the fix command(s)."
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: pos ai gemini <subcommand> [--model <id>] [--session <name>] [--system <text>]
|
||||
Usage: pos ai gemini <subcommand> [--model <id>] [--session <name>] [--system <text>] [--full] [--last]
|
||||
|
||||
Chat with Google Gemini via the REST API (generativelanguage.googleapis.com).
|
||||
|
||||
Subcommands:
|
||||
ask "<prompt>" One-shot answer; prints ONLY the answer text to stdout
|
||||
(pipe/script/Telegram-friendly). The prompt may also be
|
||||
piped in via stdin when no argument is given. With
|
||||
--session, previous turns are sent as context.
|
||||
chat Interactive multi-turn conversation.
|
||||
ask "<prompt>" Answer; prints the answer text to stdout. The prompt may
|
||||
also be piped in via stdin when no argument is given.
|
||||
Runs inside the persistent 'default' session (prior turns
|
||||
are sent as context); --session <name> picks another.
|
||||
capture <cmd..> Run a command, capture its output for --last, and show it.
|
||||
Each capture overwrites the previous one (latest only).
|
||||
chat Interactive multi-turn conversation (session 'default'
|
||||
unless --session is given).
|
||||
models List models that support generateContent.
|
||||
sessions List persistent sessions / clear one:
|
||||
'sessions' and 'sessions reset <name>'.
|
||||
|
||||
Options:
|
||||
--model <id> Override the model for this invocation.
|
||||
--session <name> Persistent memory: ask/chat remember prior turns in
|
||||
--session <name> Use a named persistent session instead of 'default':
|
||||
~/.local/share/linux_post_install/ai/<name>.json
|
||||
(capped at $MAX_SESSION_TURNS turns). ask without
|
||||
--session stays one-shot.
|
||||
(capped at $MAX_SESSION_TURNS turns).
|
||||
--system <text> System instruction sent with every turn (kept out of the
|
||||
session file), e.g. "Reply like a friendly Telegram chat".
|
||||
session file); replaces the built-in terse ask prompt
|
||||
wholesale, e.g. "Reply like a friendly Telegram chat".
|
||||
--full Skip the built-in terse prompt — long-form answers.
|
||||
--last ask only: attach the most recent pos dispatcher log or
|
||||
captured output (tail, max $LAST_LOG_MAX_BYTES chars) so
|
||||
the model can diagnose a real failure. Sources in priority
|
||||
order: (1) newest pos log, (2) captured output from
|
||||
'capture'. Notes on stderr which source was attached and
|
||||
its age; warns when stale (>60 min).
|
||||
-h|--help This help.
|
||||
|
||||
Config: $CONFIG_FILE (edit with 'pos config ai')
|
||||
AI_GEMINI_API_KEY API key from aistudio.google.com (required)
|
||||
AI_GEMINI_MODEL Model id (default $DEFAULT_MODEL)
|
||||
|
||||
Notes:
|
||||
ask is terse by default: a built-in system instruction tells the model to
|
||||
lead with the exact commands and keep prose minimal — and to diagnose pasted
|
||||
errors/output with a fix first. That prompt ends with one machine-context
|
||||
line (hostname, distro, kernel, arch detected on this box) so answers fit
|
||||
the actual machine; --system replaces it wholesale, --full drops it all.
|
||||
Every ask/chat lands in a persistent session ('default' unless --session);
|
||||
clear it with 'pos ai gemini sessions reset default'. On a terminal the
|
||||
answer is rendered as markdown (glow if installed, else a built-in
|
||||
renderer); when stdout is not a tty (pipes, scripts, Telegram bridges) the
|
||||
raw markdown is printed unchanged.
|
||||
|
||||
Examples:
|
||||
pos ai gemini ask "Explain DNS in one line"
|
||||
pos ai gemini ask "check disk space on /"
|
||||
pos ai gemini ask --full "Explain DNS in depth"
|
||||
echo "summarize this log" | pos ai gemini ask
|
||||
failing-cmd 2>&1 | pos ai gemini ask how do I fix this
|
||||
pos ai gemini ask --last "why did that fail?" # attach last output
|
||||
pos ai gemini capture pip install xyz # capture any command
|
||||
pos ai gemini ask --last "what happened?" # after capture
|
||||
pos ai gemini chat
|
||||
pos ai gemini models
|
||||
pos ai gemini ask --model gemini-2.5-flash "hi"
|
||||
pos ai gemini ask --system "Reply like a pirate" "explain chmod"
|
||||
pos ai gemini ask --session work "my name is joe"
|
||||
pos ai gemini ask --session work "what is my name?" # remembers
|
||||
pos ai gemini sessions
|
||||
pos ai gemini sessions reset work
|
||||
pos ai gemini sessions reset default # forget default memory
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
@@ -91,10 +127,72 @@ resolve_model() {
|
||||
fi
|
||||
}
|
||||
|
||||
# ── --last: attach the most recent pos command output ───────────
|
||||
# bin/pos logs every non-interactive run to DISPATCH_LOG_DIR/<ts>_pos_<cmd>.log
|
||||
# (ai-gemini itself is interactive-logged, so it never creates its own output
|
||||
# log). The <ts> prefix is zero-padded sortable, so name-descending = newest;
|
||||
# mtime alone would tie-flake for same-second runs. pos.log is the invocation
|
||||
# index, not command output — excluded.
|
||||
newest_pos_log() {
|
||||
[ -d "$DISPATCH_LOG_DIR" ] || return 1
|
||||
local f
|
||||
while IFS= read -r f; do
|
||||
[ -s "$f" ] && { printf '%s' "$f"; return 0; }
|
||||
done < <(ls "$DISPATCH_LOG_DIR"/*.log 2>/dev/null | LC_ALL=C sort -r | grep -v '/pos\.log$')
|
||||
return 1
|
||||
}
|
||||
|
||||
# stdout = the context block body for log $1: its END kept (errors live at
|
||||
# the bottom), head-truncated to LAST_LOG_MAX_BYTES with a marker.
|
||||
last_log_context() {
|
||||
local raw
|
||||
raw="$(tail -c "$LAST_LOG_MAX_BYTES" "$1")"
|
||||
# a byte cut can split a multibyte char — drop invalid sequences when possible
|
||||
if command -v iconv >/dev/null 2>&1; then
|
||||
raw="$(printf '%s' "$raw" | iconv -c -f utf-8 -t utf-8 2>/dev/null || printf '%s' "$raw")"
|
||||
fi
|
||||
if [ "$(wc -c <"$1")" -gt "$LAST_LOG_MAX_BYTES" ]; then
|
||||
printf '[…truncated…]\n%s' "$raw"
|
||||
else
|
||||
printf '%s' "$raw"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── --last transparency (stderr-only; stdout stays pure answer) ──
|
||||
# Seconds → human age: just now / Nm / Nh / Nd.
|
||||
human_age() {
|
||||
local s="$1"
|
||||
[ "$s" -lt 0 ] && s=0
|
||||
if [ "$s" -lt 60 ]; then printf 'just now'
|
||||
elif [ "$s" -lt 3600 ]; then printf '%sm' "$((s / 60))"
|
||||
elif [ "$s" -lt 86400 ]; then printf '%sh' "$((s / 3600))"
|
||||
else printf '%sd' "$((s / 86400))"
|
||||
fi
|
||||
}
|
||||
|
||||
# Tell the user WHICH pos log got attached and how fresh it is — on STDERR,
|
||||
# so a misread of an ancient log as the current failure is visible before
|
||||
# the model answers. $1 = log file (as recorded by last_log_context).
|
||||
last_log_annotate() {
|
||||
local f="$1" age_s age line
|
||||
age_s=$(( $(date +%s) - $(stat -c %Y "$f") ))
|
||||
[ "$age_s" -lt 0 ] && age_s=0
|
||||
age="$(human_age "$age_s")"
|
||||
printf '[i] attaching last pos output — %s (%s)\n' "$(basename "$f")" "$age" >&2
|
||||
# preview: first meaningful line of the log (blank lines skipped)
|
||||
line="$(grep -m1 '[^[:space:]]' "$f" 2>/dev/null || true)"
|
||||
if [ -n "$line" ]; then
|
||||
printf '[i] "%.100s"\n' "$line" >&2
|
||||
fi
|
||||
if [ "$age_s" -gt "$LAST_LOG_STALE_SECS" ]; then
|
||||
printf '[!] that log is %s old and may not match your current problem. For a FRESH failure of any command: failing-cmd 2>&1 | pos ai gemini ask "what happened"\n' "$age" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Persistent session memory ───────────────────────────────────
|
||||
# History lives as a Gemini "contents" JSON document per session name under
|
||||
# SESSION_DIR. Names are sanitized to [A-Za-z0-9_-]; ask/chat only touch the
|
||||
# session layer when --session is given (otherwise they stay stateless).
|
||||
# SESSION_DIR. Names are sanitized to [A-Za-z0-9_-]; ask/chat always run in a
|
||||
# session ('default' unless --session names another).
|
||||
session_file() {
|
||||
local name="${1:-$SESSION}"
|
||||
name="${name//[^A-Za-z0-9_-]/_}"
|
||||
@@ -156,29 +254,204 @@ gemini_generate() {
|
||||
printf '%s' "$body_out" | jq -r '[.candidates[0].content.parts[]?.text] | join("")'
|
||||
}
|
||||
|
||||
# ── Terminal markdown rendering (tty-only; raw bytes otherwise) ──
|
||||
# On a tty: one blank line separates the answer from the prompt line above,
|
||||
# then fenced code indents+dims, `code`→yellow, **bold**/__bold__→bold,
|
||||
# #-headers (1-4)→bold cyan with #'s stripped, --- rules→thin rule, list
|
||||
# markers kept. glow(1) is used opportunistically when installed. The answer
|
||||
# ends with exactly one trailing newline.
|
||||
# stdout not a tty (pipes/scripts/Telegram bridges) → RAW markdown unchanged
|
||||
# (nothing added: single trailing newline only).
|
||||
render_markdown() {
|
||||
local text="$1"
|
||||
if [ ! -t 1 ]; then
|
||||
printf '%s\n' "$text"
|
||||
return 0
|
||||
fi
|
||||
local rendered prog
|
||||
# The awk program lives in a variable: its backtick regexes would be
|
||||
# parsed as command substitution inside $( … ). $() strips any
|
||||
# renderer-added trailing newlines; the final printf re-adds exactly
|
||||
# one, plus the leading blank separator line.
|
||||
prog='
|
||||
BEGIN {
|
||||
e = sprintf("%c", 27)
|
||||
R = e "[0m"; DIM = e "[2m"; B = e "[1m"
|
||||
YEL = e "[33m"; CYA = e "[1;36m"
|
||||
RULE = ""
|
||||
for (i = 0; i < 60; i++) RULE = RULE "─"
|
||||
RULE = DIM RULE R
|
||||
}
|
||||
/^```/ { fence = !fence; next }
|
||||
fence { printf "%s %s%s\n", DIM, $0, R; next }
|
||||
/^#{1,4} / {
|
||||
sub(/^#{1,4} +/, "")
|
||||
printf "%s%s%s\n", CYA, $0, R
|
||||
next
|
||||
}
|
||||
/^(-{3,}|\*{3,}|_{3,})$/ { print RULE; next }
|
||||
{
|
||||
line = $0
|
||||
out = ""; rest = line
|
||||
while (match(rest, /`[^`]*`/)) { # inline code first: keeps ** literal in backticks
|
||||
out = out substr(rest, 1, RSTART - 1) YEL \
|
||||
substr(rest, RSTART + 1, RLENGTH - 2) R
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
line = out rest
|
||||
out = ""; rest = line
|
||||
while (match(rest, /\*\*[^*]+\*\*/)) {
|
||||
out = out substr(rest, 1, RSTART - 1) B \
|
||||
substr(rest, RSTART + 2, RLENGTH - 4) R
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
line = out rest
|
||||
out = ""; rest = line
|
||||
while (match(rest, /__[^_]+__/)) {
|
||||
out = out substr(rest, 1, RSTART - 1) B \
|
||||
substr(rest, RSTART + 2, RLENGTH - 4) R
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
print out rest
|
||||
}
|
||||
'
|
||||
if command -v glow >/dev/null 2>&1; then
|
||||
rendered="$(printf '%s\n' "$text" | glow -)"
|
||||
else
|
||||
rendered="$(printf '%s\n' "$text" | awk "$prog")"
|
||||
fi
|
||||
printf '\n%s\n' "$rendered"
|
||||
}
|
||||
|
||||
# ── Machine context appended to the built-in default prompt ─────
|
||||
# One compact clause so default answers fit the actual box. Collected
|
||||
# best-effort: hostnamectl(1) first (single call), then /etc/os-release +
|
||||
# uname(1) fill any gaps. Every source is optional and failures are
|
||||
# ignored — with nothing resolvable the clause is omitted entirely.
|
||||
|
||||
mc_clean() {
|
||||
# stdin→stdout: strip ANSI color sequences, drop control chars (emoji,
|
||||
# CR…), collapse all whitespace runs to single spaces, trim both ends.
|
||||
# (tr handles the control ranges: sed lacks \xHH inside [brackets].)
|
||||
sed -e 's/\x1b\[[0-9;]*[A-Za-z]//g' \
|
||||
-e 's/[[:space:]][[:space:]]*/ /g' \
|
||||
| tr -d '\000-\010\013-\037\177' \
|
||||
| sed -e 's/^ //; s/ $//'
|
||||
}
|
||||
|
||||
machine_context() {
|
||||
local raw line key val h="" o="" k="" a="" part out=""
|
||||
# Preferred single source: one hostnamectl status call.
|
||||
if command -v hostnamectl >/dev/null 2>&1; then
|
||||
raw="$(hostnamectl status 2>/dev/null || true)"
|
||||
while IFS= read -r line; do
|
||||
key="$(printf '%s' "${line%%:*}" | tr -d '[:space:]')"
|
||||
val="${line#*:}"
|
||||
case "$key" in
|
||||
Statichostname|Transienthostname|Hostname)
|
||||
[ -z "$h" ] && h="$val" ;;
|
||||
OperatingSystem)
|
||||
[ -z "$o" ] && o="$val" ;;
|
||||
Kernel)
|
||||
[ -z "$k" ] && k="$val" ;;
|
||||
Architecture)
|
||||
[ -z "$a" ] && a="$val" ;;
|
||||
esac
|
||||
done <<< "$raw"
|
||||
fi
|
||||
# Fallback/complement for the distro: os-release (PRETTY_NAME, else
|
||||
# NAME + VERSION_ID). Sourced in a subshell — its vars stay local.
|
||||
if [ -z "$o" ] && [ -r "$OS_RELEASE_FILE" ]; then
|
||||
o="$(
|
||||
. "$OS_RELEASE_FILE" 2>/dev/null || true
|
||||
if [ -n "${PRETTY_NAME:-}" ]; then
|
||||
printf '%s' "$PRETTY_NAME"
|
||||
elif [ -n "${NAME:-}" ]; then
|
||||
printf '%s' "${NAME}${VERSION_ID:+ (${VERSION_ID})}"
|
||||
fi
|
||||
)"
|
||||
fi
|
||||
# Fallback/complement for kernel + arch: uname(1).
|
||||
[ -n "$k" ] || k="$(uname -sr 2>/dev/null || true)"
|
||||
[ -n "$a" ] || a="$(uname -m 2>/dev/null || true)"
|
||||
h="$(printf '%s' "$h" | mc_clean)"
|
||||
o="$(printf '%s' "$o" | mc_clean)"
|
||||
k="$(printf '%s' "$k" | mc_clean)"
|
||||
a="$(printf '%s' "$a" | mc_clean)"
|
||||
# Normalize to the bare release: both hostnamectl and uname -sr report
|
||||
# "Linux <rel>".
|
||||
case "$k" in "Linux "*) k="${k#Linux }" ;; esac
|
||||
local out=""
|
||||
for part in "$h" "$o" "${k:+kernel $k}" "$a"; do
|
||||
[ -n "$part" ] || continue
|
||||
if [ -n "$out" ]; then out="$out, $part"; else out="$part"; fi
|
||||
done
|
||||
[ -n "$out" ] || return 0
|
||||
printf 'Machine context (answers must fit this box): %s.' "$out"
|
||||
}
|
||||
|
||||
cmd_capture() {
|
||||
[ $# -gt 0 ] || err "usage: pos ai gemini capture <command> [args...]"
|
||||
mkdir -p "$(dirname "$LAST_CMD_OUTPUT_FILE")"
|
||||
"$@" 2>&1 | tee "$LAST_CMD_OUTPUT_FILE"
|
||||
local rc=${PIPESTATUS[0]}
|
||||
printf '[captured → %s]\n' "$LAST_CMD_OUTPUT_FILE" >&2
|
||||
return $rc
|
||||
}
|
||||
|
||||
cmd_ask() {
|
||||
local prompt="" contents out
|
||||
local prompt="" contents out system ctx mc
|
||||
if [ $# -gt 0 ]; then
|
||||
prompt="$*"
|
||||
elif [ ! -t 0 ]; then
|
||||
prompt="$(cat)"
|
||||
fi
|
||||
[ -n "$prompt" ] || err "No prompt given — usage: pos ai gemini ask \"<prompt>\""
|
||||
require_key
|
||||
if [ -n "$SESSION" ]; then
|
||||
contents="$(session_load)"
|
||||
contents="$(session_push "$contents" user "$prompt")"
|
||||
else
|
||||
contents="$(jq -nc --arg t "$prompt" '{contents:[{role:"user",parts:[{text:$t}]}]}')"
|
||||
# --last: append the most recent logged pos command output AFTER the
|
||||
# question, so the model diagnoses the real failure.
|
||||
if [ "$LAST_MODE" -eq 1 ]; then
|
||||
local log_file="" pos_log="" captured_log=""
|
||||
pos_log="$(newest_pos_log 2>/dev/null)" || true
|
||||
[ -s "$LAST_CMD_OUTPUT_FILE" ] && captured_log="$LAST_CMD_OUTPUT_FILE"
|
||||
# Use whichever source is newer (auto-capture beats stale pos logs)
|
||||
if [ -n "$pos_log" ] && [ -n "$captured_log" ]; then
|
||||
local pos_age=$(( $(date +%s) - $(stat -c %Y "$pos_log") ))
|
||||
local cap_age=$(( $(date +%s) - $(stat -c %Y "$captured_log") ))
|
||||
if [ "$cap_age" -lt "$pos_age" ]; then
|
||||
log_file="$captured_log"
|
||||
else
|
||||
log_file="$pos_log"
|
||||
fi
|
||||
elif [ -n "$captured_log" ]; then
|
||||
log_file="$captured_log"
|
||||
else
|
||||
log_file="$pos_log"
|
||||
fi
|
||||
[ -n "$log_file" ] || err "No recent output found — run 'pos ai gemini capture <cmd>' first, or pipe: cmd 2>&1 | pos ai gemini ask \"what happened\""
|
||||
last_log_annotate "$log_file"
|
||||
ctx="$(last_log_context "$log_file")"
|
||||
prompt="$prompt"$'\n\n[last command output:]\n'"$ctx"
|
||||
fi
|
||||
if ! out="$(gemini_generate "$(resolve_model)" "$contents" "$SYSTEM_PROMPT" 2>&1)"; then
|
||||
require_key
|
||||
# Terse by default: user --system replaces the built-in prompt wholesale;
|
||||
# --full skips everything (built-in text AND machine context). The
|
||||
# default prompt carries a machine-context clause so answers fit this
|
||||
# box; with no detectable facts it is omitted.
|
||||
system="$SYSTEM_PROMPT"
|
||||
if [ -z "$system" ] && [ "$FULL_MODE" -eq 0 ]; then
|
||||
mc="$(machine_context)"
|
||||
[ -n "$mc" ] && mc=" $mc"
|
||||
system="$DEFAULT_SYSTEM_PROMPT$mc"
|
||||
fi
|
||||
# Persistent session memory ('default' unless --session).
|
||||
contents="$(session_load)"
|
||||
contents="$(session_push "$contents" user "$prompt")"
|
||||
if ! out="$(gemini_generate "$(resolve_model)" "$contents" "$system" 2>&1)"; then
|
||||
err "$out"
|
||||
fi
|
||||
if [ -n "$SESSION" ]; then
|
||||
contents="$(session_push "$contents" model "$out")"
|
||||
session_save "$contents"
|
||||
fi
|
||||
printf '%s\n' "$out"
|
||||
contents="$(session_push "$contents" model "$out")"
|
||||
session_save "$contents"
|
||||
render_markdown "$out"
|
||||
}
|
||||
|
||||
cmd_chat() {
|
||||
@@ -186,12 +459,8 @@ cmd_chat() {
|
||||
local model contents text answer
|
||||
model="$(resolve_model)"
|
||||
require_key
|
||||
if [ -n "$SESSION" ]; then
|
||||
contents="$(session_load)"
|
||||
printf 'session: %s (resumed %s prior turns)\n' "$SESSION" "$(printf '%s' "$contents" | jq -r '.contents | length')"
|
||||
else
|
||||
contents='{"contents":[]}'
|
||||
fi
|
||||
contents="$(session_load)"
|
||||
printf 'session: %s (resumed %s prior turns)\n' "$SESSION" "$(printf '%s' "$contents" | jq -r '.contents | length')"
|
||||
trap 'echo; echo "bye"; exit 0' INT
|
||||
echo "Gemini · ${model} — type a message; q=quit, /reset=clear history"
|
||||
while true; do
|
||||
@@ -202,7 +471,7 @@ cmd_chat() {
|
||||
q|Q|quit|exit) echo; echo "bye"; return 0 ;;
|
||||
/reset)
|
||||
contents='{"contents":[]}'
|
||||
[ -n "$SESSION" ] && session_save "$contents"
|
||||
session_save "$contents"
|
||||
echo "[history cleared]"
|
||||
continue ;;
|
||||
esac
|
||||
@@ -212,8 +481,13 @@ cmd_chat() {
|
||||
continue
|
||||
fi
|
||||
contents="$(session_push "$contents" model "$answer")"
|
||||
[ -n "$SESSION" ] && session_save "$contents"
|
||||
printf '\n%s\n\n' "$answer"
|
||||
session_save "$contents"
|
||||
# The bare '> ' prompt has no newline: this one closes the prompt
|
||||
# line, and render_markdown's tty-only blank line then shows as the
|
||||
# visible gap before the answer. On a pipe both bytes are preserved.
|
||||
printf '\n'
|
||||
render_markdown "$answer"
|
||||
printf '\n\n'
|
||||
done
|
||||
echo
|
||||
return 0
|
||||
@@ -276,6 +550,8 @@ cmd_models() {
|
||||
|
||||
# ── Parse flags + subcommand ────────────────────────────────────
|
||||
MODEL_OVERRIDE=""
|
||||
FULL_MODE=0
|
||||
LAST_MODE=0
|
||||
cmd=""
|
||||
args=()
|
||||
while [ $# -gt 0 ]; do
|
||||
@@ -290,6 +566,10 @@ while [ $# -gt 0 ]; do
|
||||
--system)
|
||||
[ $# -ge 2 ] || err "--system needs a value"
|
||||
SYSTEM_PROMPT="$2"; shift 2 ;;
|
||||
--full)
|
||||
FULL_MODE=1; shift ;;
|
||||
--last)
|
||||
LAST_MODE=1; shift ;;
|
||||
-*) err "Unknown option '$1' (see --help)" ;;
|
||||
*)
|
||||
if [ -z "$cmd" ]; then
|
||||
@@ -301,11 +581,16 @@ while [ $# -gt 0 ]; do
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$LAST_MODE" -eq 1 ] && [ "${cmd:-}" != "ask" ]; then
|
||||
err "--last only applies to 'ask' — for capturing output use 'capture': pos ai gemini capture <cmd>"
|
||||
fi
|
||||
|
||||
case "${cmd:-}" in
|
||||
"") usage ;;
|
||||
ask) cmd_ask "${args[@]}" ;;
|
||||
chat) cmd_chat "${args[@]}" ;;
|
||||
models) cmd_models "${args[@]}" ;;
|
||||
sessions) cmd_sessions "${args[@]}" ;;
|
||||
*) err "Unknown ai gemini subcommand '$cmd' (see --help)" ;;
|
||||
"") usage ;;
|
||||
ask) cmd_ask "${args[@]}" ;;
|
||||
capture) cmd_capture "${args[@]}" ;;
|
||||
chat) cmd_chat "${args[@]}" ;;
|
||||
models) cmd_models "${args[@]}" ;;
|
||||
sessions) cmd_sessions "${args[@]}" ;;
|
||||
*) err "Unknown ai gemini subcommand '$cmd' (see --help)" ;;
|
||||
esac
|
||||
|
||||
Executable
+597
@@ -0,0 +1,597 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: ai openrouter — Chat with OpenRouter models (ask, capture, chat, models, sessions)
|
||||
# POS_SUBCMDS: ask capture chat models sessions
|
||||
# POS_FLAGS: --model --session --system --full --last
|
||||
# POS_CONFIG: ai-openrouter | ai-openrouter.env | OPENROUTER_API_KEY=secret:API key from openrouter.ai (https://openrouter.ai/settings/keys) | OPENROUTER_MODEL=:Model id (default openrouter/auto)
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
CONFIG_FILE="$HOME/.config/linux_post_install/ai-openrouter.env"
|
||||
API="https://openrouter.ai/api/v1"
|
||||
DEFAULT_MODEL="openrouter/auto"
|
||||
SESSION_DIR="$HOME/.local/share/linux_post_install/ai-openrouter"
|
||||
DISPATCH_LOG_DIR="$HOME/.local/share/linux_post_install/logs" # bin/pos per-run logs
|
||||
LAST_CMD_OUTPUT_FILE="$HOME/.local/share/linux_post_install/last_cmd_output" # --last fallback for any command
|
||||
OS_RELEASE_FILE="${OS_RELEASE_FILE:-/etc/os-release}" # read-only test seam (DEV.md env-overridable paths)
|
||||
SESSION="default"
|
||||
SYSTEM_PROMPT=""
|
||||
MAX_SESSION_TURNS=40
|
||||
LAST_LOG_MAX_BYTES=4096
|
||||
LAST_LOG_STALE_SECS=3600 # --last: warn when the attached log is older than this
|
||||
# Built-in terse ask prompt. cmd_ask appends a machine-context clause
|
||||
# (see machine_context) unless --system replaces it or --full drops everything.
|
||||
DEFAULT_SYSTEM_PROMPT="You assist a user working in a Linux/Unix CLI terminal. Be extremely terse: lead with the exact command(s) to run; one-line explanations max; short bullets only when necessary; no greetings, no closing offers, no essays. The user's message may be an install/update/solve/edit question ('how do I …') and/or may paste a problem, error, or command output: diagnose it from that and lead with the fix command(s)."
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: pos ai openrouter <subcommand> [--model <id>] [--session <name>] [--system <text>] [--full] [--last]
|
||||
|
||||
Chat with OpenRouter models via the REST API (openrouter.ai).
|
||||
|
||||
Subcommands:
|
||||
ask "<prompt>" Answer; prints the answer text to stdout. The prompt may
|
||||
also be piped in via stdin when no argument is given.
|
||||
Runs inside the persistent 'default' session (prior turns
|
||||
are sent as context); --session <name> picks another.
|
||||
capture <cmd..> Run a command, capture its output for --last, and show it.
|
||||
Each capture overwrites the previous one (latest only).
|
||||
chat Interactive multi-turn conversation (session 'default'
|
||||
unless --session is given).
|
||||
models List available models and flag the configured default.
|
||||
sessions List persistent sessions / clear one:
|
||||
'sessions' and 'sessions reset <name>'.
|
||||
|
||||
Options:
|
||||
--model <id> Override the model for this invocation.
|
||||
--session <name> Use a named persistent session instead of 'default':
|
||||
~/.local/share/linux_post_install/ai-openrouter/<name>.json
|
||||
(capped at $MAX_SESSION_TURNS turns).
|
||||
--system <text> System instruction sent with every turn (kept out of the
|
||||
session file); replaces the built-in terse ask prompt
|
||||
wholesale, e.g. "Reply like a friendly Telegram chat".
|
||||
--full Skip the built-in terse prompt — long-form answers.
|
||||
--last ask only: attach the most recent pos dispatcher log or
|
||||
captured output (tail, max $LAST_LOG_MAX_BYTES chars) so
|
||||
the model can diagnose a real failure. Sources in priority
|
||||
order: (1) newest pos log, (2) captured output from
|
||||
'capture'. Notes on stderr which source was attached and
|
||||
its age; warns when stale (>60 min).
|
||||
-h|--help This help.
|
||||
|
||||
Config: $CONFIG_FILE (edit with 'pos config ai-openrouter')
|
||||
OPENROUTER_API_KEY API key from openrouter.ai (required)
|
||||
OPENROUTER_MODEL Model id (default $DEFAULT_MODEL)
|
||||
|
||||
Notes:
|
||||
ask is terse by default: a built-in system instruction tells the model to
|
||||
lead with the exact commands and keep prose minimal — and to diagnose pasted
|
||||
errors/output with a fix first. That prompt ends with one machine-context
|
||||
line (hostname, distro, kernel, arch detected on this box) so answers fit
|
||||
the actual machine; --system replaces it wholesale, --full drops it all.
|
||||
Every ask/chat lands in a persistent session ('default' unless --session);
|
||||
clear it with 'pos ai openrouter sessions reset default'. On a terminal the
|
||||
answer is rendered as markdown (glow if installed, else a built-in
|
||||
renderer); when stdout is not a tty (pipes, scripts, Telegram bridges) the
|
||||
raw markdown is printed unchanged.
|
||||
|
||||
Examples:
|
||||
pos ai openrouter ask "check disk space on /"
|
||||
pos ai openrouter ask --full "Explain DNS in depth"
|
||||
echo "summarize this log" | pos ai openrouter ask
|
||||
failing-cmd 2>&1 | pos ai openrouter ask how do I fix this
|
||||
pos ai openrouter ask --last "why did that fail?" # attach last output
|
||||
pos ai openrouter capture pip install xyz # capture any command
|
||||
pos ai openrouter ask --last "what happened?" # after capture
|
||||
pos ai openrouter chat
|
||||
pos ai openrouter ask --model anthropic/claude-sonnet-4 "hi"
|
||||
pos ai openrouter ask --system "Reply like a pirate" "explain chmod"
|
||||
pos ai openrouter ask --session work "my name is joe"
|
||||
pos ai openrouter ask --session work "what is my name?" # remembers
|
||||
pos ai openrouter sessions
|
||||
pos ai openrouter sessions reset default # forget default memory
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── ai-openrouter.env loader (same pattern as telegram.env) ────────
|
||||
load_config() {
|
||||
[ -f "$CONFIG_FILE" ] || return 0
|
||||
local k v
|
||||
while IFS='=' read -r k v; do
|
||||
[ -n "$k" ] || continue
|
||||
case "$k" in
|
||||
\#*) continue ;;
|
||||
esac
|
||||
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
|
||||
v="${v//$'\r'/}"
|
||||
if [ -z "${!k:-}" ]; then
|
||||
export "$k"="$v"
|
||||
fi
|
||||
done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true)
|
||||
}
|
||||
|
||||
require_key() {
|
||||
load_config
|
||||
[ -n "${OPENROUTER_API_KEY:-}" ] || err "No OpenRouter API key — run 'pos config ai-openrouter'"
|
||||
}
|
||||
|
||||
resolve_model() {
|
||||
if [ -n "${MODEL_OVERRIDE:-}" ]; then
|
||||
printf '%s' "$MODEL_OVERRIDE"
|
||||
elif [ -n "${OPENROUTER_MODEL:-}" ]; then
|
||||
printf '%s' "$OPENROUTER_MODEL"
|
||||
else
|
||||
printf '%s' "$DEFAULT_MODEL"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── --last: attach the most recent pos command output ───────────
|
||||
# bin/pos logs every non-interactive run to DISPATCH_LOG_DIR/<ts>_pos_<cmd>.log
|
||||
# (ai-openrouter itself is interactive-logged, so it never creates its own output
|
||||
# log). The <ts> prefix is zero-padded sortable, so name-descending = newest;
|
||||
# mtime alone would tie-flake for same-second runs. pos.log is the invocation
|
||||
# index, not command output — excluded.
|
||||
newest_pos_log() {
|
||||
[ -d "$DISPATCH_LOG_DIR" ] || return 1
|
||||
local f
|
||||
while IFS= read -r f; do
|
||||
[ -s "$f" ] && { printf '%s' "$f"; return 0; }
|
||||
done < <(ls "$DISPATCH_LOG_DIR"/*.log 2>/dev/null | LC_ALL=C sort -r | grep -v '/pos\.log$')
|
||||
return 1
|
||||
}
|
||||
|
||||
# stdout = the context block body for log $1: its END kept (errors live at
|
||||
# the bottom), head-truncated to LAST_LOG_MAX_BYTES with a marker.
|
||||
last_log_context() {
|
||||
local raw
|
||||
raw="$(tail -c "$LAST_LOG_MAX_BYTES" "$1")"
|
||||
# a byte cut can split a multibyte char — drop invalid sequences when possible
|
||||
if command -v iconv >/dev/null 2>&1; then
|
||||
raw="$(printf '%s' "$raw" | iconv -c -f utf-8 -t utf-8 2>/dev/null || printf '%s' "$raw")"
|
||||
fi
|
||||
if [ "$(wc -c <"$1")" -gt "$LAST_LOG_MAX_BYTES" ]; then
|
||||
printf '[…truncated…]\n%s' "$raw"
|
||||
else
|
||||
printf '%s' "$raw"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── --last transparency (stderr-only; stdout stays pure answer) ──
|
||||
# Seconds → human age: just now / Nm / Nh / Nd.
|
||||
human_age() {
|
||||
local s="$1"
|
||||
[ "$s" -lt 0 ] && s=0
|
||||
if [ "$s" -lt 60 ]; then printf 'just now'
|
||||
elif [ "$s" -lt 3600 ]; then printf '%sm' "$((s / 60))"
|
||||
elif [ "$s" -lt 86400 ]; then printf '%sh' "$((s / 3600))"
|
||||
else printf '%sd' "$((s / 86400))"
|
||||
fi
|
||||
}
|
||||
|
||||
# Tell the user WHICH pos log got attached and how fresh it is — on STDERR,
|
||||
# so a misread of an ancient log as the current failure is visible before
|
||||
# the model answers. $1 = log file (as recorded by last_log_context).
|
||||
last_log_annotate() {
|
||||
local f="$1" age_s age line
|
||||
age_s=$(( $(date +%s) - $(stat -c %Y "$f") ))
|
||||
[ "$age_s" -lt 0 ] && age_s=0
|
||||
age="$(human_age "$age_s")"
|
||||
printf '[i] attaching last pos output — %s (%s)\n' "$(basename "$f")" "$age" >&2
|
||||
# preview: first meaningful line of the log (blank lines skipped)
|
||||
line="$(grep -m1 '[^[:space:]]' "$f" 2>/dev/null || true)"
|
||||
if [ -n "$line" ]; then
|
||||
printf '[i] "%.100s"\n' "$line" >&2
|
||||
fi
|
||||
if [ "$age_s" -gt "$LAST_LOG_STALE_SECS" ]; then
|
||||
printf '[!] that log is %s old and may not match your current problem. For a FRESH failure of any command: failing-cmd 2>&1 | pos ai openrouter ask "what happened"\n' "$age" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Persistent session memory ───────────────────────────────────
|
||||
# History lives as an OpenAI-style "messages" JSON document per session name under
|
||||
# SESSION_DIR. Names are sanitized to [A-Za-z0-9_-]; ask/chat always run in a
|
||||
# session ('default' unless --session names another).
|
||||
session_file() {
|
||||
local name="${1:-$SESSION}"
|
||||
name="${name//[^A-Za-z0-9_-]/_}"
|
||||
printf '%s/%s.json' "$SESSION_DIR" "$name"
|
||||
}
|
||||
|
||||
session_load() {
|
||||
[ -n "$SESSION" ] || { printf '{"messages":[]}'; return 0; }
|
||||
local f
|
||||
f="$(session_file)"
|
||||
if [ -s "$f" ] && jq -e '.messages' "$f" >/dev/null 2>&1; then
|
||||
cat "$f"
|
||||
else
|
||||
printf '{"messages":[]}'
|
||||
fi
|
||||
}
|
||||
|
||||
session_save() {
|
||||
[ -n "$SESSION" ] || return 0
|
||||
local f tmp
|
||||
f="$(session_file)"
|
||||
mkdir -p "$SESSION_DIR"
|
||||
tmp="$(mktemp)"
|
||||
printf '%s\n' "$1" >"$tmp"
|
||||
mv "$tmp" "$f"
|
||||
chmod 600 "$f"
|
||||
}
|
||||
|
||||
# Append a turn and prune to the last MAX_SESSION_TURNS entries. stdout = JSON.
|
||||
session_push() {
|
||||
local messages="$1" role="$2" text="$3"
|
||||
printf '%s' "$messages" | jq -c --arg r "$role" --arg t "$text" \
|
||||
'.messages += [{role:$r, content:$t}] | .messages |= .[-'"$MAX_SESSION_TURNS"':]'
|
||||
}
|
||||
|
||||
# One OpenRouter chat completion call. $1 = model, $2 = messages JSON, $3 = optional
|
||||
# system instruction (prepended as a system message, not stored in the session).
|
||||
# stdout = the answer text on success; an error message on failure (exit 1).
|
||||
openrouter_generate() {
|
||||
local model="$1" messages_json="$2" system="${3:-}" body resp code body_out errmsg
|
||||
if [ -n "$system" ]; then
|
||||
body="$(printf '%s' "$messages_json" | jq -c --arg s "$system" \
|
||||
'[{role:"system",content:$s}] + .messages')"
|
||||
else
|
||||
body="$(printf '%s' "$messages_json" | jq -c '.messages')"
|
||||
fi
|
||||
body="$(printf '%s' "$body" | jq -nc --arg m "$model" --argjson msgs "$body" \
|
||||
'{model:$m, messages:$msgs}')"
|
||||
resp="$(curl -sS -m 60 -X POST "${API}/chat/completions" \
|
||||
-H "Authorization: Bearer ${OPENROUTER_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "HTTP-Referer: https://github.com/admin/Linux_post_install" \
|
||||
--write-out $'\n%{http_code}' \
|
||||
--data "$body")" || { echo "request failed (curl exit $?)" >&2; return 1; }
|
||||
code="${resp##*$'\n'}"
|
||||
body_out="${resp%$'\n'*}"
|
||||
if [ "$code" != "200" ]; then
|
||||
errmsg="$(printf '%s' "$body_out" | jq -r '.error.message // empty' 2>/dev/null || true)"
|
||||
echo "API error $code${errmsg:+: $errmsg}" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$body_out" | jq -r '.choices[0].message.content // ""'
|
||||
}
|
||||
|
||||
# ── Terminal markdown rendering (tty-only; raw bytes otherwise) ──
|
||||
# On a tty: one blank line separates the answer from the prompt line above,
|
||||
# then fenced code indents+dims, `code`→yellow, **bold**/__bold__→bold,
|
||||
# #-headers (1-4)→bold cyan with #'s stripped, --- rules→thin rule, list
|
||||
# markers kept. glow(1) is used opportunistically when installed. The answer
|
||||
# ends with exactly one trailing newline.
|
||||
# stdout not a tty (pipes/scripts/Telegram bridges) → RAW markdown unchanged
|
||||
# (nothing added: single trailing newline only).
|
||||
render_markdown() {
|
||||
local text="$1"
|
||||
if [ ! -t 1 ]; then
|
||||
printf '%s\n' "$text"
|
||||
return 0
|
||||
fi
|
||||
local rendered prog
|
||||
# The awk program lives in a variable: its backtick regexes would be
|
||||
# parsed as command substitution inside $( … ). $() strips any
|
||||
# renderer-added trailing newlines; the final printf re-adds exactly
|
||||
# one, plus the leading blank separator line.
|
||||
prog='
|
||||
BEGIN {
|
||||
e = sprintf("%c", 27)
|
||||
R = e "[0m"; DIM = e "[2m"; B = e "[1m"
|
||||
YEL = e "[33m"; CYA = e "[1;36m"
|
||||
RULE = ""
|
||||
for (i = 0; i < 60; i++) RULE = RULE "─"
|
||||
RULE = DIM RULE R
|
||||
}
|
||||
/^```/ { fence = !fence; next }
|
||||
fence { printf "%s %s%s\n", DIM, $0, R; next }
|
||||
/^#{1,4} / {
|
||||
sub(/^#{1,4} +/, "")
|
||||
printf "%s%s%s\n", CYA, $0, R
|
||||
next
|
||||
}
|
||||
/^(-{3,}|\*{3,}|_{3,})$/ { print RULE; next }
|
||||
{
|
||||
line = $0
|
||||
out = ""; rest = line
|
||||
while (match(rest, /`[^`]*`/)) { # inline code first: keeps ** literal in backticks
|
||||
out = out substr(rest, 1, RSTART - 1) YEL \
|
||||
substr(rest, RSTART + 1, RLENGTH - 2) R
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
line = out rest
|
||||
out = ""; rest = line
|
||||
while (match(rest, /\*\*[^*]+\*\*/)) {
|
||||
out = out substr(rest, 1, RSTART - 1) B \
|
||||
substr(rest, RSTART + 2, RLENGTH - 4) R
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
line = out rest
|
||||
out = ""; rest = line
|
||||
while (match(rest, /__[^_]+__/)) {
|
||||
out = out substr(rest, 1, RSTART - 1) B \
|
||||
substr(rest, RSTART + 2, RLENGTH - 4) R
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
print out rest
|
||||
}
|
||||
'
|
||||
if command -v glow >/dev/null 2>&1; then
|
||||
rendered="$(printf '%s\n' "$text" | glow -)"
|
||||
else
|
||||
rendered="$(printf '%s\n' "$text" | awk "$prog")"
|
||||
fi
|
||||
printf '\n%s\n' "$rendered"
|
||||
}
|
||||
|
||||
# ── Machine context appended to the built-in default prompt ─────
|
||||
# One compact clause so default answers fit the actual box. Collected
|
||||
# best-effort: hostnamectl(1) first (single call), then /etc/os-release +
|
||||
# uname(1) fill any gaps. Every source is optional and failures are
|
||||
# ignored — with nothing resolvable the clause is omitted entirely.
|
||||
|
||||
mc_clean() {
|
||||
# stdin→stdout: strip ANSI color sequences, drop control chars (emoji,
|
||||
# CR…), collapse all whitespace runs to single spaces, trim both ends.
|
||||
# (tr handles the control ranges: sed lacks \xHH inside [brackets].)
|
||||
sed -e 's/\x1b\[[0-9;]*[A-Za-z]//g' \
|
||||
-e 's/[[:space:]][[:space:]]*/ /g' \
|
||||
| tr -d '\000-\010\013-\037\177' \
|
||||
| sed -e 's/^ //; s/ $//'
|
||||
}
|
||||
|
||||
machine_context() {
|
||||
local raw line key val h="" o="" k="" a="" part out=""
|
||||
# Preferred single source: one hostnamectl status call.
|
||||
if command -v hostnamectl >/dev/null 2>&1; then
|
||||
raw="$(hostnamectl status 2>/dev/null || true)"
|
||||
while IFS= read -r line; do
|
||||
key="$(printf '%s' "${line%%:*}" | tr -d '[:space:]')"
|
||||
val="${line#*:}"
|
||||
case "$key" in
|
||||
Statichostname|Transienthostname|Hostname)
|
||||
[ -z "$h" ] && h="$val" ;;
|
||||
OperatingSystem)
|
||||
[ -z "$o" ] && o="$val" ;;
|
||||
Kernel)
|
||||
[ -z "$k" ] && k="$val" ;;
|
||||
Architecture)
|
||||
[ -z "$a" ] && a="$val" ;;
|
||||
esac
|
||||
done <<< "$raw"
|
||||
fi
|
||||
# Fallback/complement for the distro: os-release (PRETTY_NAME, else
|
||||
# NAME + VERSION_ID). Sourced in a subshell — its vars stay local.
|
||||
if [ -z "$o" ] && [ -r "$OS_RELEASE_FILE" ]; then
|
||||
o="$(
|
||||
. "$OS_RELEASE_FILE" 2>/dev/null || true
|
||||
if [ -n "${PRETTY_NAME:-}" ]; then
|
||||
printf '%s' "$PRETTY_NAME"
|
||||
elif [ -n "${NAME:-}" ]; then
|
||||
printf '%s' "${NAME}${VERSION_ID:+ (${VERSION_ID})}"
|
||||
fi
|
||||
)"
|
||||
fi
|
||||
# Fallback/complement for kernel + arch: uname(1).
|
||||
[ -n "$k" ] || k="$(uname -sr 2>/dev/null || true)"
|
||||
[ -n "$a" ] || a="$(uname -m 2>/dev/null || true)"
|
||||
h="$(printf '%s' "$h" | mc_clean)"
|
||||
o="$(printf '%s' "$o" | mc_clean)"
|
||||
k="$(printf '%s' "$k" | mc_clean)"
|
||||
a="$(printf '%s' "$a" | mc_clean)"
|
||||
# Normalize to the bare release: both hostnamectl and uname -sr report
|
||||
# "Linux <rel>".
|
||||
case "$k" in "Linux "*) k="${k#Linux }" ;; esac
|
||||
local out=""
|
||||
for part in "$h" "$o" "${k:+kernel $k}" "$a"; do
|
||||
[ -n "$part" ] || continue
|
||||
if [ -n "$out" ]; then out="$out, $part"; else out="$part"; fi
|
||||
done
|
||||
[ -n "$out" ] || return 0
|
||||
printf 'Machine context (answers must fit this box): %s.' "$out"
|
||||
}
|
||||
|
||||
cmd_capture() {
|
||||
[ $# -gt 0 ] || err "usage: pos ai openrouter capture <command> [args...]"
|
||||
mkdir -p "$(dirname "$LAST_CMD_OUTPUT_FILE")"
|
||||
"$@" 2>&1 | tee "$LAST_CMD_OUTPUT_FILE"
|
||||
local rc=${PIPESTATUS[0]}
|
||||
printf '[captured → %s]\n' "$LAST_CMD_OUTPUT_FILE" >&2
|
||||
return $rc
|
||||
}
|
||||
|
||||
cmd_ask() {
|
||||
local prompt="" messages out system ctx mc
|
||||
if [ $# -gt 0 ]; then
|
||||
prompt="$*"
|
||||
elif [ ! -t 0 ]; then
|
||||
prompt="$(cat)"
|
||||
fi
|
||||
[ -n "$prompt" ] || err "No prompt given — usage: pos ai openrouter ask \"<prompt>\""
|
||||
# --last: append the most recent logged pos command output AFTER the
|
||||
# question, so the model diagnoses the real failure.
|
||||
if [ "$LAST_MODE" -eq 1 ]; then
|
||||
local log_file="" pos_log="" captured_log=""
|
||||
pos_log="$(newest_pos_log 2>/dev/null)" || true
|
||||
[ -s "$LAST_CMD_OUTPUT_FILE" ] && captured_log="$LAST_CMD_OUTPUT_FILE"
|
||||
# Use whichever source is newer (auto-capture beats stale pos logs)
|
||||
if [ -n "$pos_log" ] && [ -n "$captured_log" ]; then
|
||||
local pos_age=$(( $(date +%s) - $(stat -c %Y "$pos_log") ))
|
||||
local cap_age=$(( $(date +%s) - $(stat -c %Y "$captured_log") ))
|
||||
if [ "$cap_age" -lt "$pos_age" ]; then
|
||||
log_file="$captured_log"
|
||||
else
|
||||
log_file="$pos_log"
|
||||
fi
|
||||
elif [ -n "$captured_log" ]; then
|
||||
log_file="$captured_log"
|
||||
else
|
||||
log_file="$pos_log"
|
||||
fi
|
||||
[ -n "$log_file" ] || err "No recent output found — run 'pos ai openrouter capture <cmd>' first, or pipe: cmd 2>&1 | pos ai openrouter ask \"what happened\""
|
||||
last_log_annotate "$log_file"
|
||||
ctx="$(last_log_context "$log_file")"
|
||||
prompt="$prompt"$'\n\n[last command output:]\n'"$ctx"
|
||||
fi
|
||||
require_key
|
||||
# Terse by default: user --system replaces the built-in prompt wholesale;
|
||||
# --full skips everything (built-in text AND machine context). The
|
||||
# default prompt carries a machine-context clause so answers fit this
|
||||
# box; with no detectable facts it is omitted.
|
||||
system="$SYSTEM_PROMPT"
|
||||
if [ -z "$system" ] && [ "$FULL_MODE" -eq 0 ]; then
|
||||
mc="$(machine_context)"
|
||||
[ -n "$mc" ] && mc=" $mc"
|
||||
system="$DEFAULT_SYSTEM_PROMPT$mc"
|
||||
fi
|
||||
# Persistent session memory ('default' unless --session).
|
||||
messages="$(session_load)"
|
||||
messages="$(session_push "$messages" user "$prompt")"
|
||||
if ! out="$(openrouter_generate "$(resolve_model)" "$messages" "$system" 2>&1)"; then
|
||||
err "$out"
|
||||
fi
|
||||
messages="$(session_push "$messages" assistant "$out")"
|
||||
session_save "$messages"
|
||||
render_markdown "$out"
|
||||
}
|
||||
|
||||
cmd_chat() {
|
||||
[ $# -eq 0 ] || err "Unexpected argument for chat: $*"
|
||||
local model messages text answer
|
||||
model="$(resolve_model)"
|
||||
require_key
|
||||
messages="$(session_load)"
|
||||
printf 'session: %s (resumed %s prior turns)\n' "$SESSION" "$(printf '%s' "$messages" | jq -r '.messages | length')"
|
||||
trap 'echo; echo "bye"; exit 0' INT
|
||||
echo "OpenRouter · ${model} — type a message; q=quit, /reset=clear history"
|
||||
while true; do
|
||||
printf '> '
|
||||
IFS= read -r text || break
|
||||
case "$text" in
|
||||
"" ) continue ;;
|
||||
q|Q|quit|exit) echo; echo "bye"; return 0 ;;
|
||||
/reset)
|
||||
messages='{"messages":[]}'
|
||||
session_save "$messages"
|
||||
echo "[history cleared]"
|
||||
continue ;;
|
||||
esac
|
||||
messages="$(session_push "$messages" user "$text")"
|
||||
if ! answer="$(openrouter_generate "$model" "$messages" "$SYSTEM_PROMPT" 2>&1)"; then
|
||||
warn "AI error: $answer"
|
||||
continue
|
||||
fi
|
||||
messages="$(session_push "$messages" assistant "$answer")"
|
||||
session_save "$messages"
|
||||
# The bare '> ' prompt has no newline: this one closes the prompt
|
||||
# line, and render_markdown's tty-only blank line then shows as the
|
||||
# visible gap before the answer. On a pipe both bytes are preserved.
|
||||
printf '\n'
|
||||
render_markdown "$answer"
|
||||
printf '\n\n'
|
||||
done
|
||||
echo
|
||||
return 0
|
||||
}
|
||||
|
||||
cmd_sessions() {
|
||||
local action="${1:-list}" name f n
|
||||
case "$action" in
|
||||
list|"")
|
||||
[ -d "$SESSION_DIR" ] || { echo "no sessions"; return 0; }
|
||||
local found=0
|
||||
for f in "$SESSION_DIR"/*.json; do
|
||||
[ -f "$f" ] || continue
|
||||
found=1
|
||||
n="$(jq -r '.messages | length' "$f" 2>/dev/null || echo 0)"
|
||||
printf ' %-32s %s turns\n' "$(basename "$f" .json)" "${n:-0}"
|
||||
done
|
||||
[ "$found" -eq 1 ] || echo "no sessions"
|
||||
;;
|
||||
reset)
|
||||
[ $# -ge 2 ] || err "usage: pos ai openrouter sessions reset <name>"
|
||||
name="$2"
|
||||
if rm -f "$(session_file "$name")"; then
|
||||
ok "session '$name' cleared"
|
||||
fi
|
||||
;;
|
||||
*) err "Unknown sessions subcommand '$action' (list | reset <name>)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cmd_models() {
|
||||
[ $# -eq 0 ] || err "Unexpected argument for models: $*"
|
||||
local model resp code body m
|
||||
model="$(resolve_model)"
|
||||
require_key
|
||||
resp="$(curl -sS -m 30 "${API}/models" \
|
||||
-H "Authorization: Bearer ${OPENROUTER_API_KEY}" \
|
||||
--write-out $'\n%{http_code}')" || err "request failed (curl exit $?)"
|
||||
code="${resp##*$'\n'}"
|
||||
body="${resp%$'\n'*}"
|
||||
if [ "$code" != "200" ]; then
|
||||
err "API error $code: $(printf '%s' "$body" | jq -r '.error.message // empty')"
|
||||
fi
|
||||
local list
|
||||
list="$(printf '%s' "$body" | jq -r '.data[]?.id' | sort)"
|
||||
echo "OpenRouter models:"
|
||||
while IFS= read -r m; do
|
||||
[ -n "$m" ] || continue
|
||||
if [ "$m" = "$model" ]; then
|
||||
printf ' %-48s <- default\n' "$m"
|
||||
else
|
||||
printf ' %-48s\n' "$m"
|
||||
fi
|
||||
done <<< "$list"
|
||||
if ! grep -qxF "$model" <<< "$list"; then
|
||||
warn "configured default '$model' is not in the list — set OPENROUTER_MODEL"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Parse flags + subcommand ────────────────────────────────────
|
||||
MODEL_OVERRIDE=""
|
||||
FULL_MODE=0
|
||||
LAST_MODE=0
|
||||
cmd=""
|
||||
args=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|--help) usage ;;
|
||||
--model)
|
||||
[ $# -ge 2 ] || err "--model needs a value"
|
||||
MODEL_OVERRIDE="$2"; shift 2 ;;
|
||||
--session)
|
||||
[ $# -ge 2 ] || err "--session needs a value"
|
||||
SESSION="$2"; shift 2 ;;
|
||||
--system)
|
||||
[ $# -ge 2 ] || err "--system needs a value"
|
||||
SYSTEM_PROMPT="$2"; shift 2 ;;
|
||||
--full)
|
||||
FULL_MODE=1; shift ;;
|
||||
--last)
|
||||
LAST_MODE=1; shift ;;
|
||||
-*) err "Unknown option '$1' (see --help)" ;;
|
||||
*)
|
||||
if [ -z "$cmd" ]; then
|
||||
cmd="$1"
|
||||
else
|
||||
args+=("$1")
|
||||
fi
|
||||
shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$LAST_MODE" -eq 1 ] && [ "${cmd:-}" != "ask" ]; then
|
||||
err "--last only applies to 'ask' — for capturing output use 'capture': pos ai openrouter capture <cmd>"
|
||||
fi
|
||||
|
||||
case "${cmd:-}" in
|
||||
"") usage ;;
|
||||
ask) cmd_ask "${args[@]}" ;;
|
||||
capture) cmd_capture "${args[@]}" ;;
|
||||
chat) cmd_chat "${args[@]}" ;;
|
||||
models) cmd_models "${args[@]}" ;;
|
||||
sessions) cmd_sessions "${args[@]}" ;;
|
||||
*) err "Unknown ai openrouter subcommand '$cmd' (see --help)" ;;
|
||||
esac
|
||||
+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
|
||||
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: docker stack — Containers grouped by compose stack (project); standalone group; -a/--all includes stopped
|
||||
# POS_FLAGS: -a --all
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: pos docker stack [-a|--all]
|
||||
|
||||
Show running containers grouped by their Docker Compose stack (project).
|
||||
Non-compose containers (no stack) are listed under a "Standalone" group
|
||||
at the end.
|
||||
|
||||
Flags:
|
||||
-a, --all Include stopped/exited containers too (like docker ps -a)
|
||||
-h, --help Show this help
|
||||
|
||||
Exit codes:
|
||||
0 Success (also when no containers are found)
|
||||
|
||||
Examples:
|
||||
pos docker stack Grouped view of running containers
|
||||
pos docker stack -a Include stopped/exited containers
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
command -v docker &>/dev/null || err "docker not found — run 'install.sh --apps' and install Docker first"
|
||||
|
||||
ALL=0
|
||||
case "${1:-}" in
|
||||
-h|--help) usage ;;
|
||||
-a|--all) ALL=1 ;;
|
||||
esac
|
||||
|
||||
ps_args=()
|
||||
[ "$ALL" -eq 1 ] && ps_args+=(-a)
|
||||
ps_args+=(--format '{{.Names}}{{"\u001f"}}{{.Label "com.docker.compose.project"}}{{"\u001f"}}{{.Status}}{{"\u001f"}}{{.Ports}}')
|
||||
|
||||
if ! data="$(docker ps "${ps_args[@]}" 2>/dev/null)"; then
|
||||
err "docker daemon not reachable — is it running?"
|
||||
fi
|
||||
|
||||
if [ -z "$data" ]; then
|
||||
echo "No containers."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Compose projects (stack names), sorted; empty project label = standalone.
|
||||
projects="$(printf '%s\n' "$data" | awk -F'\x1f' 'NF>=2 && $2!="" {print $2}' | LC_ALL=C sort -u)"
|
||||
standalone_rows="$(printf '%s\n' "$data" | awk -F'\x1f' 'NF>=2 && $2==""')"
|
||||
|
||||
# Column width for aligned output (global so all stacks line up).
|
||||
width=0
|
||||
while IFS=$'\x1f' read -r name _proj _status _ports; do
|
||||
[ "${#name}" -gt "$width" ] && width="${#name}"
|
||||
done <<< "$data"
|
||||
|
||||
show_rows() {
|
||||
local rows="$1"
|
||||
[ -z "$rows" ] && return 0
|
||||
while IFS=$'\x1f' read -r name _proj status ports; do
|
||||
[ -z "$name" ] && continue
|
||||
case "$status" in
|
||||
Up*) sdisp="${GREEN}${status}${RESET}" ;;
|
||||
Exited*|Dead*|Created*) sdisp="${RED}${status}${RESET}" ;;
|
||||
Paused*|Restarting*) sdisp="${YELLOW}${status}${RESET}" ;;
|
||||
*) sdisp="$status" ;;
|
||||
esac
|
||||
printf " %-*s %b %s\n" "$width" "$name" "$sdisp" "${ports:--}"
|
||||
done <<< "$rows"
|
||||
}
|
||||
|
||||
stack_header() {
|
||||
local label="$1"
|
||||
local pad=$((46 - ${#label}))
|
||||
[ "$pad" -lt 1 ] && pad=1
|
||||
printf -- "── %s%s%s %s\n" "$CYAN" "$label" "$RESET" "$(printf '%*s' "$pad" '' | sed 's/ /─/g')"
|
||||
}
|
||||
|
||||
stacks=0
|
||||
while IFS= read -r proj; do
|
||||
[ -z "$proj" ] && continue
|
||||
stacks=$((stacks + 1))
|
||||
rows="$(printf '%s\n' "$data" | awk -F'\x1f' -v p="$proj" '$2==p')"
|
||||
stack_header "$proj"
|
||||
show_rows "$rows"
|
||||
echo
|
||||
done <<< "$projects"
|
||||
|
||||
if [ -n "$standalone_rows" ]; then
|
||||
stack_header "Standalone"
|
||||
show_rows "$standalone_rows"
|
||||
echo
|
||||
fi
|
||||
|
||||
containers="$(printf '%s\n' "$data" | grep -c . || true)"
|
||||
standalone="$(printf '%s\n' "$standalone_rows" | grep -c . || true)"
|
||||
printf '%*s\n' 60 '' | sed 's/ /─/g'
|
||||
echo "Stacks: $stacks containers: $containers standalone: $standalone"
|
||||
+973
-6
File diff suppressed because it is too large
Load Diff
+146
-68
@@ -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,71 +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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Executable
+1191
File diff suppressed because it is too large
Load Diff
+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 "$@"
|
||||
|
||||
+432
-66
@@ -1,9 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: share nfs-client — Mount NFS shares (ephemeral or persistent systemd mount units)
|
||||
# POS_SUBCMDS: mount unmount list persist unpersist 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/share-lib.sh" 2>/dev/null || source "$(dirname "$0")/share-lib.sh"
|
||||
|
||||
# Env seam (testable): where persistent .mount units are written.
|
||||
UNIT_DIR="${UNIT_DIR:-/etc/systemd/system}"
|
||||
|
||||
command -v mount.nfs &>/dev/null || err "mount.nfs not found (install nfs-common)"
|
||||
command -v systemd-escape &>/dev/null || err "systemd-escape not found"
|
||||
@@ -16,29 +21,26 @@ Mount and manage NFS shares from remote servers (nfs-common).
|
||||
|
||||
Commands:
|
||||
mount <server:export> <local-dir> One-shot mount (creates local-dir if needed)
|
||||
unmount <local-dir> Unmount the share
|
||||
unmount <local-dir> Unmount the share (idempotent: rc 0 when
|
||||
nothing is mounted)
|
||||
list Show active NFS mounts
|
||||
persist <server:export> <local-dir> Persistent mount via a systemd .mount unit
|
||||
(ordered after network-online.target)
|
||||
unpersist <local-dir> Stop, disable and remove the mount unit
|
||||
menu Interactive browser (server → export → mountpoint)
|
||||
|
||||
Run without arguments to open the interactive menu.
|
||||
|
||||
Examples:
|
||||
pos share nfs-client mount 100.100.100.1:/srv/media /mnt/nfs/media
|
||||
pos share nfs-client persist 100.100.100.1:/srv/media /mnt/nfs/media
|
||||
pos share nfs-client list
|
||||
pos share nfs-client unmount /mnt/nfs/media
|
||||
pos share nfs-client menu
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
cmd="${1:-}"
|
||||
case "$cmd" in
|
||||
-h|--help) usage ;;
|
||||
mount|unmount|list|persist|unpersist) ;;
|
||||
"") err "Missing command (mount|unmount|list|persist|unpersist)" ;;
|
||||
*) err "Unknown command '$cmd' (see --help)" ;;
|
||||
esac
|
||||
|
||||
validate_share() {
|
||||
local what="$1"
|
||||
case "$what" in
|
||||
@@ -56,83 +58,447 @@ validate_dir() {
|
||||
esac
|
||||
}
|
||||
|
||||
case "$cmd" in
|
||||
mount)
|
||||
what="${2:-}"
|
||||
where="${3:-}"
|
||||
[ -n "$what" ] && [ -n "$where" ] || err "Usage: pos share nfs-client mount <server:export> <local-dir>"
|
||||
validate_share "$what"
|
||||
validate_dir "$where"
|
||||
cmd_mount() {
|
||||
local what="$1" where="$2"
|
||||
validate_share "$what"
|
||||
validate_dir "$where"
|
||||
|
||||
sudo mkdir -p "$where"
|
||||
sudo mount -t nfs -o rw,noatime "$what" "$where"
|
||||
log "Mounted $what at $where"
|
||||
;;
|
||||
sudo mkdir -p "$where"
|
||||
sudo mount -t nfs -o rw,noatime "$what" "$where"
|
||||
log "Mounted $what at $where"
|
||||
}
|
||||
|
||||
unmount)
|
||||
where="${2:-}"
|
||||
[ -n "$where" ] || err "Usage: pos share nfs-client unmount <local-dir>"
|
||||
validate_dir "$where"
|
||||
cmd_unmount() {
|
||||
local where="$1"
|
||||
validate_dir "$where"
|
||||
|
||||
sudo umount "$where"
|
||||
log "Unmounted $where"
|
||||
;;
|
||||
|
||||
list)
|
||||
if findmnt -t nfs,nfs4 >/dev/null 2>&1; then
|
||||
findmnt -t nfs,nfs4
|
||||
if ! findmnt -r -n -o TARGET -t nfs,nfs4 2>/dev/null | grep -qxF "$where"; then
|
||||
# Idempotent no-op — rc 0 whether or not a unit exists. A persisted
|
||||
# boot-time .mount that is not currently mounted usually means the
|
||||
# unit failed or was stopped; say so instead of a bare nothing-to-do.
|
||||
if persisted_nfs_at "$where"; then
|
||||
warn "$where has a persistent NFS mount unit (${PERSISTED_UNIT}) — not currently mounted."
|
||||
log "Check it: systemctl status ${PERSISTED_UNIT%.mount} — or remove the persistence: menu option 5 (pos share nfs-client unpersist $where)"
|
||||
else
|
||||
echo "No NFS mounts"
|
||||
log "$where is not mounted as NFS — nothing to do"
|
||||
fi
|
||||
;;
|
||||
return 0
|
||||
fi
|
||||
sudo umount "$where"
|
||||
log "Unmounted $where"
|
||||
}
|
||||
|
||||
persist)
|
||||
what="${2:-}"
|
||||
where="${3:-}"
|
||||
[ -n "$what" ] && [ -n "$where" ] || err "Usage: pos share nfs-client persist <server:export> <local-dir>"
|
||||
validate_share "$what"
|
||||
validate_dir "$where"
|
||||
# Persisted Type=nfs/nfs4 unit declaring Where=<path>? Sets PERSISTED_UNIT.
|
||||
persisted_nfs_at() { # <path> — rc 0 persisted · rc 1 not persisted
|
||||
local uf
|
||||
PERSISTED_UNIT=""
|
||||
for uf in "${UNIT_DIR}"/*.mount; do
|
||||
[ -f "$uf" ] || continue
|
||||
grep -q '^Type=nfs' "$uf" || continue
|
||||
if [ "$(sed -n 's/^Where=//p' "$uf")" = "$1" ]; then
|
||||
PERSISTED_UNIT="$(basename "$uf")"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
unit="$(systemd-escape --path --suffix=mount "$where")"
|
||||
unit_file="/etc/systemd/system/${unit}"
|
||||
cmd_list() {
|
||||
local out
|
||||
if out="$(findmnt -t nfs,nfs4 2>/dev/null)" && [ "$(grep -c . <<<"$out")" -gt 1 ]; then
|
||||
printf '%s\n' "$out"
|
||||
else
|
||||
echo "No NFS mounts"
|
||||
fi
|
||||
}
|
||||
|
||||
sudo mkdir -p "$where"
|
||||
cat <<UNIT | sudo tee "$unit_file" >/dev/null
|
||||
write_mount_unit() { # <unit_file> <what> <where>
|
||||
cat <<UNIT | sudo tee "$1" >/dev/null
|
||||
[Unit]
|
||||
Description=NFS mount of ${what} at ${where}
|
||||
Description=NFS mount of ${2} at ${3}
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Mount]
|
||||
What=${what}
|
||||
Where=${where}
|
||||
What=${2}
|
||||
Where=${3}
|
||||
Type=nfs
|
||||
Options=defaults,_netdev,rw,noatime
|
||||
UNIT
|
||||
}
|
||||
|
||||
show_mount_unit() { # <what> <where> (dry-run preview)
|
||||
cat <<UNIT
|
||||
[Unit]
|
||||
Description=NFS mount of ${1} at ${2}
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Mount]
|
||||
What=${1}
|
||||
Where=${2}
|
||||
Type=nfs
|
||||
Options=defaults,_netdev,rw,noatime
|
||||
UNIT
|
||||
}
|
||||
|
||||
cmd_persist() {
|
||||
local what="$1" where="$2"
|
||||
validate_share "$what"
|
||||
validate_dir "$where"
|
||||
|
||||
unit="$(systemd-escape --path --suffix=mount "$where")"
|
||||
unit_file="${UNIT_DIR}/${unit}"
|
||||
|
||||
if [ -f "$unit_file" ]; then
|
||||
if [ -t 0 ]; then
|
||||
# FLAGGED DELTA: interactive overwrite now confirms first.
|
||||
confirm "Unit ${unit} already exists — overwrite?" n ||
|
||||
{ warn "Aborted — ${unit_file} left untouched"; return 1; }
|
||||
else
|
||||
warn "Overwriting existing unit ${unit}"
|
||||
fi
|
||||
fi
|
||||
|
||||
sudo mkdir -p "$where"
|
||||
if [ "${DRY_RUN:-0}" -eq 1 ]; then
|
||||
log "(dry-run) would write ${unit_file}:"
|
||||
show_mount_unit "$what" "$where"
|
||||
else
|
||||
write_mount_unit "$unit_file" "$what" "$where"
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now "$unit"
|
||||
log "Persistent NFS mount: ${what} → ${where} (${unit})"
|
||||
notify_send "NFS mount persisted: ${what} → ${where}"
|
||||
|
||||
# Verify the export actually mounted; roll back the unit if not.
|
||||
local i mounted=1
|
||||
for i in 1 2 3 4 5; do
|
||||
if findmnt -r -n -o TARGET -t nfs,nfs4 2>/dev/null | grep -qxF "$where"; then
|
||||
mounted=0
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$mounted" -ne 0 ]; then
|
||||
warn "Unit enabled but ${where} never appeared among NFS mounts — rolling back"
|
||||
sudo systemctl disable "$unit" 2>/dev/null || true
|
||||
sudo systemctl stop "$unit" 2>/dev/null || true
|
||||
sudo rm -f "$unit_file"
|
||||
sudo systemctl daemon-reload
|
||||
err "Persistent mount failed — unit removed (${unit})"
|
||||
fi
|
||||
fi
|
||||
log "Persistent NFS mount: ${what} → ${where} (${unit})"
|
||||
notify_send "NFS mount persisted: ${what} → ${where}"
|
||||
}
|
||||
|
||||
cmd_unpersist() {
|
||||
local where="$1"
|
||||
validate_dir "$where"
|
||||
|
||||
unit="$(systemd-escape --path --suffix=mount "$where")"
|
||||
unit_file="${UNIT_DIR}/${unit}"
|
||||
|
||||
if [ ! -f "$unit_file" ]; then
|
||||
# Idempotent no-op — rc 0 whether or not anything is configured.
|
||||
# Return, not exit: a bogus typed path reached from the menu must
|
||||
# not kill the whole session. Mirror of cmd_unmount's guidance for
|
||||
# a persisted unit under a non-escape-derived filename.
|
||||
if persisted_nfs_at "$where"; then
|
||||
warn "$where has a persistent NFS mount unit (${PERSISTED_UNIT}) under a non-standard unit name."
|
||||
log "Check it: systemctl status ${PERSISTED_UNIT%.mount} — or find it in the list: pos share nfs-client list"
|
||||
else
|
||||
warn "No systemd mount unit for $where (${unit})"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
sudo systemctl disable "$unit" 2>/dev/null || true
|
||||
sudo systemctl stop "$unit" 2>/dev/null || true
|
||||
sudo rm -f "$unit_file"
|
||||
sudo systemctl daemon-reload
|
||||
log "Removed persistent NFS mount: $where"
|
||||
notify_send "NFS persistent mount removed: $where"
|
||||
}
|
||||
|
||||
# ── Interactive menu flows ─────────────────────────────────────
|
||||
|
||||
# Mountpoint picker — local port of the share_pick primitive with exactly
|
||||
# two deltas: the hint line offers `n=new`, and typing n runs the
|
||||
# create-new-dir flow below. share_pick cannot intercept `n` (it filters on
|
||||
# it) and lib/menu-lib.sh is shared, so the fork lives here. Rendering of
|
||||
# numbered picks / text filter / 0=back is byte-identical to menu_pick.
|
||||
# stdout: chosen item text (or the freshly created dir) · rc 1 = back/cancel.
|
||||
pick_mountpoint() {
|
||||
local prompt="$1"; shift
|
||||
local -a items=("$@")
|
||||
if [ "${#items[@]}" -eq 0 ]; then
|
||||
return 1
|
||||
fi
|
||||
if ! [ -t 0 ]; then
|
||||
printf '[!] Interactive picker needs a terminal.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
local filter="" ans i n total=${#items[@]} made
|
||||
local -a shown=()
|
||||
while true; do
|
||||
shown=()
|
||||
for ((i = 0; i < total; i++)); do
|
||||
if [ -z "$filter" ] || [[ "${items[$i],,}" == *"${filter,,}"* ]]; then
|
||||
shown+=("${items[$i]}")
|
||||
fi
|
||||
done
|
||||
n=${#shown[@]}
|
||||
{
|
||||
echo
|
||||
if [ -n "$filter" ]; then
|
||||
printf -- "-- %d of %d match '%s' --\n" "$n" "$total" "$filter"
|
||||
else
|
||||
printf -- "-- %d available --\n" "$total"
|
||||
fi
|
||||
if [ "$n" -eq 0 ]; then
|
||||
printf '[!] no matches — enter nothing or / to clear the filter\n' >&2
|
||||
else
|
||||
for ((i = 0; i < n; i++)); do
|
||||
printf ' %2d) %s\n' $((i + 1)) "${shown[$i]}"
|
||||
done
|
||||
fi
|
||||
} >&2
|
||||
if ! read -rp "${prompt} [1-${n}], n=new, text=filter, 0=back " ans; then
|
||||
return 1 # EOF — cancel
|
||||
fi
|
||||
case "$ans" in
|
||||
"") [ -z "$filter" ] || filter="" ; continue ;;
|
||||
"/") filter="" ; continue ;;
|
||||
0 | q | Q | b | B) return 1 ;;
|
||||
n | N)
|
||||
made="$(ask_new_mountpoint)" && { echo "$made"; return 0; }
|
||||
continue # declined/invalid/mkdir-failed → redraw
|
||||
;;
|
||||
*[!0-9]*)
|
||||
filter="$ans"
|
||||
continue
|
||||
;;
|
||||
*)
|
||||
if (( ans >= 1 && ans <= n )); then
|
||||
echo "${shown[$((ans - 1))]}"
|
||||
return 0
|
||||
fi
|
||||
echo "Unknown choice." >&2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Create-new-dir flow behind the picker's `n` key. Validates the shape
|
||||
# (absolute, no trailing slash), confirm-gates the creation, then mkdir -p.
|
||||
# Any decline, invalid input, EOF or mkdir failure is a warning + rc 1 —
|
||||
# the picker redraws, the tool never aborts.
|
||||
ask_new_mountpoint() { # stdout: created dir · rc 1 = cancelled/failed
|
||||
# NOTE: runs inside $( ) from the picker — every display line MUST go to
|
||||
# stderr (menu-lib contract: display → stderr, result → stdout).
|
||||
local dir
|
||||
dir="$(share_ask_value "New mountpoint (absolute path)")" || return 1
|
||||
case "$dir" in
|
||||
/*) ;;
|
||||
*) warn "'$dir' is not an absolute path — must start with /" >&2; return 1 ;;
|
||||
esac
|
||||
case "$dir" in
|
||||
*/) warn "'$dir' must not end with a slash" >&2; return 1 ;;
|
||||
esac
|
||||
case "$dir" in
|
||||
/etc|/boot|/bin|/sbin|/lib|/lib64|/usr|/var|/root|/home/*/.ssh*)
|
||||
warn "Refusing system path as mountpoint" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
confirm "Create mountpoint ${dir}?" n || return 1
|
||||
if ! run sudo mkdir -p "$dir"; then
|
||||
warn "Could not create ${dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
log "Created mount point $dir" >&2
|
||||
echo "$dir"
|
||||
}
|
||||
|
||||
menu_pick_export() { # <host> — stdout: server:export · rc 1 cancelled
|
||||
local host="$1" idx exp
|
||||
local -a exports=()
|
||||
if mapfile -t exports < <(share_nfs_exports "$host") && [ "${#exports[@]}" -gt 0 ]; then
|
||||
if idx="$(share_pick "Pick export on ${host}" "${exports[@]}")"; then
|
||||
exp="${exports[$((idx - 1))]}"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
exp="$(share_ask_value "Export path on ${host} (e.g. /srv/media)")" || return 1
|
||||
[ -n "$exp" ] || { warn "No export path given"; return 1; }
|
||||
fi
|
||||
case "$exp" in
|
||||
/*) echo "${host}:${exp}" ;;
|
||||
*) echo "${host}:/${exp}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
menu_ask_mountpoint() { # [server_path] — stdout: absolute path · rc 1 cancelled
|
||||
local srv="${1:-}" idx res cand dir known=0
|
||||
local -a cands=() dirs=()
|
||||
mapfile -t cands < <(share_folder_candidates)
|
||||
for cand in "${cands[@]}"; do
|
||||
dirs+=("${cand%% (*}") # bare path (strip "(mounted fstype)" note)
|
||||
done
|
||||
# Same-as-server suggestion: unless the server-side path already exists
|
||||
# among the local candidates, append it as a synthetic pick so mounting
|
||||
# at a mirrored path is a normal selection.
|
||||
if [ -n "$srv" ] && [ "${#dirs[@]}" -gt 0 ] &&
|
||||
printf '%s\n' "${dirs[@]}" | grep -qxF -- "$srv"; then
|
||||
known=1
|
||||
fi
|
||||
if [ -n "$srv" ] && [ "$known" -eq 0 ]; then
|
||||
cands+=("${srv} (as on server)")
|
||||
fi
|
||||
if [ "${#cands[@]}" -gt 0 ]; then
|
||||
if res="$(pick_mountpoint "Mountpoint" "${cands[@]}")"; then
|
||||
dir="${res%% (*}" # strip "(as on server)"/mount note
|
||||
case "$dir" in
|
||||
/etc|/boot|/bin|/sbin|/lib|/lib64|/usr|/var|/root|/home/*/.ssh*)
|
||||
warn "Refusing system path as mountpoint"
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
echo "$dir"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1
|
||||
[ -n "$dir" ] || { warn "No mountpoint given"; return 1; }
|
||||
echo "$dir"
|
||||
}
|
||||
|
||||
menu_mount() {
|
||||
local mode="$1" host what where
|
||||
host="$(share_ask_value "NFS server (host or IP)")" || return 1
|
||||
[ -n "$host" ] || { warn "No server given"; return 1; }
|
||||
|
||||
if ! share_port_probe "$host" 2049; then
|
||||
warn "${host} does not answer on TCP/2049 (nfsd down, or a firewall blocks it)."
|
||||
confirm "Try anyway?" n || return 1
|
||||
fi
|
||||
|
||||
what="$(menu_pick_export "$host")" || return 1
|
||||
where="$(menu_ask_mountpoint "${what#*:}")" || return 1 # ${what#*:} = server-side export path
|
||||
|
||||
if [ "$mode" = "persist" ]; then
|
||||
cmd_persist "$what" "$where"
|
||||
else
|
||||
cmd_mount "$what" "$where"
|
||||
fi
|
||||
}
|
||||
|
||||
menu_unmount() {
|
||||
local idx row src where i
|
||||
local -a tgts=() srcs=() items=()
|
||||
# Same enumeration source as the `list` view (findmnt -t nfs,nfs4),
|
||||
# reduced to TARGET|SOURCE rows.
|
||||
while IFS= read -r row; do
|
||||
[ -n "$row" ] || continue
|
||||
tgts+=("${row%%|*}")
|
||||
srcs+=("${row#*|}")
|
||||
done < <(findmnt -rn -o TARGET,SOURCE -t nfs,nfs4 2>/dev/null |
|
||||
awk '{ src=$NF; $NF=""; sub(/[ \t]+$/, ""); print $0 "|" src }')
|
||||
if [ "${#tgts[@]}" -eq 0 ]; then
|
||||
log "No active NFS mounts"
|
||||
where="$(share_ask_value "Local mountpoint to unmount")" || return 1
|
||||
[ -n "$where" ] || return 1
|
||||
cmd_unmount "$where"
|
||||
return 0
|
||||
fi
|
||||
for ((i = 0; i < ${#tgts[@]}; i++)); do
|
||||
items+=("${tgts[$i]} ← ${srcs[$i]}")
|
||||
done
|
||||
idx="$(share_pick "Unmount which NFS mount?" "${items[@]}")" || return 1
|
||||
where="${tgts[$((idx - 1))]}"
|
||||
src="${srcs[$((idx - 1))]}"
|
||||
confirm "Unmount ${where} (from ${src})?" n || { log "Cancelled"; return 1; }
|
||||
cmd_unmount "$where"
|
||||
}
|
||||
|
||||
menu_unpersist() {
|
||||
local idx uf where unit
|
||||
local -a items=() paths=()
|
||||
for uf in "${UNIT_DIR}"/*.mount; do
|
||||
grep -q '^Type=nfs' "$uf" 2>/dev/null || continue
|
||||
where="$(sed -n 's/^Where=//p' "$uf")"
|
||||
[ -n "$where" ] || continue
|
||||
paths+=("$where")
|
||||
items+=("$where")
|
||||
done
|
||||
if [ "${#items[@]}" -gt 0 ]; then
|
||||
idx="$(share_pick "Remove which persistent NFS mount?" "${items[@]}")" || return 1
|
||||
where="${paths[$((idx - 1))]}"
|
||||
else
|
||||
where="$(share_ask_value "Local mountpoint whose unit to remove")" || return 1
|
||||
[ -n "$where" ] || return 1
|
||||
fi
|
||||
cmd_unpersist "$where"
|
||||
}
|
||||
|
||||
run_menu() {
|
||||
share_menu_guard || exit 1
|
||||
while true; do
|
||||
local choice
|
||||
choice="$(share_menu_run "NFS client" \
|
||||
"Mount an export (one-shot)" \
|
||||
"Persist an export (systemd .mount unit)" \
|
||||
"List active NFS mounts" \
|
||||
"Unmount a mounted share" \
|
||||
"Remove a persistent mount")" || return 0
|
||||
case "$choice" in
|
||||
# Handlers return nonzero on cancel/back — normalized here so a
|
||||
# cancel can never reach set -e and kill the whole session.
|
||||
1) menu_mount ephemeral || true ;;
|
||||
2) menu_mount persist || true ;;
|
||||
3) cmd_list || true ;;
|
||||
4) menu_unmount || true ;;
|
||||
5) menu_unpersist || true ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
cmd="${1:-}"
|
||||
case "$cmd" in
|
||||
-h|--help) usage ;;
|
||||
""|menu)
|
||||
run_menu
|
||||
exit 0
|
||||
;;
|
||||
mount|unmount|list|persist|unpersist) ;;
|
||||
*) err "Unknown command '$cmd' (see --help)" ;;
|
||||
esac
|
||||
|
||||
case "$cmd" in
|
||||
mount)
|
||||
[ $# -ge 3 ] || err "Usage: pos share nfs-client mount <server:export> <local-dir>"
|
||||
cmd_mount "$2" "$3"
|
||||
;;
|
||||
|
||||
unmount)
|
||||
[ $# -ge 2 ] || err "Usage: pos share nfs-client unmount <local-dir>"
|
||||
cmd_unmount "$2"
|
||||
;;
|
||||
|
||||
list)
|
||||
cmd_list
|
||||
;;
|
||||
|
||||
persist)
|
||||
[ $# -ge 3 ] || err "Usage: pos share nfs-client persist <server:export> <local-dir>"
|
||||
cmd_persist "$2" "$3"
|
||||
;;
|
||||
|
||||
unpersist)
|
||||
where="${2:-}"
|
||||
[ -n "$where" ] || err "Usage: pos share nfs-client unpersist <local-dir>"
|
||||
validate_dir "$where"
|
||||
|
||||
unit="$(systemd-escape --path --suffix=mount "$where")"
|
||||
unit_file="/etc/systemd/system/${unit}"
|
||||
|
||||
if [ ! -f "$unit_file" ]; then
|
||||
warn "No systemd mount unit for $where (${unit})"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sudo systemctl disable "$unit" 2>/dev/null || true
|
||||
sudo systemctl stop "$unit" 2>/dev/null || true
|
||||
sudo rm -f "$unit_file"
|
||||
sudo systemctl daemon-reload
|
||||
log "Removed persistent NFS mount: $where"
|
||||
notify_send "NFS persistent mount removed: $where"
|
||||
[ $# -ge 2 ] || err "Usage: pos share nfs-client unpersist <local-dir>"
|
||||
cmd_unpersist "$2"
|
||||
;;
|
||||
esac
|
||||
|
||||
+184
-73
@@ -1,12 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: share nfs-server — Manage the NFS kernel server (status, share/unshare exports, enable/disable)
|
||||
# POS_SUBCMDS: status share unshare list reload enable disable 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/share-lib.sh" 2>/dev/null || source "$(dirname "$0")/share-lib.sh"
|
||||
|
||||
command -v exportfs &>/dev/null || err "exportfs not found (install nfs-kernel-server)"
|
||||
|
||||
# Env seam (testable): which exports file is managed.
|
||||
EXPORTS_FILE="${EXPORTS_FILE:-/etc/exports}"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: pos share nfs-server <command> [args]
|
||||
@@ -22,6 +27,9 @@ Commands:
|
||||
reload Re-apply /etc/exports after hand edits
|
||||
enable Start nfs-server and enable it on boot
|
||||
disable Stop nfs-server and disable it on boot
|
||||
menu Interactive browser (folder → client preset → share)
|
||||
|
||||
Run without arguments to open the interactive menu.
|
||||
|
||||
Clients are /etc/exports entries — restrict them to your trusted network:
|
||||
pos share nfs-server share /mnt/hdd '100.64.0.0/10(rw,sync,no_subtree_check)' # Tailscale CGNAT
|
||||
@@ -33,6 +41,7 @@ Examples:
|
||||
pos share nfs-server share /mnt/hdd
|
||||
pos share nfs-server list
|
||||
pos share nfs-server unshare /mnt/hdd
|
||||
pos share nfs-server menu
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
@@ -40,13 +49,10 @@ EOF
|
||||
cmd="${1:-}"
|
||||
case "$cmd" in
|
||||
-h|--help) usage ;;
|
||||
status|share|unshare|list|reload|enable|disable) ;;
|
||||
"") err "Missing command (status|share|unshare|list|reload|enable|disable)" ;;
|
||||
""|menu|status|share|unshare|list|reload|enable|disable) ;;
|
||||
*) err "Unknown command '$cmd' (see --help)" ;;
|
||||
esac
|
||||
|
||||
EXPORTS_FILE=/etc/exports
|
||||
|
||||
require_root_dir() {
|
||||
local path="$1"
|
||||
case "$path" in
|
||||
@@ -56,79 +62,184 @@ require_root_dir() {
|
||||
[ -d "$path" ] || err "Path not found: $path"
|
||||
}
|
||||
|
||||
case "$cmd" in
|
||||
status)
|
||||
if systemctl is-active --quiet nfs-server 2>/dev/null; then
|
||||
ok "nfs-server: running"
|
||||
else
|
||||
warn "nfs-server: not running (enable with 'pos share nfs-server enable')"
|
||||
fi
|
||||
cmd_status() {
|
||||
if systemctl is-active --quiet nfs-server 2>/dev/null; then
|
||||
ok "nfs-server: running"
|
||||
else
|
||||
warn "nfs-server: not running (enable with 'pos share nfs-server enable')"
|
||||
fi
|
||||
echo
|
||||
section "Exports"
|
||||
exportfs -v 2>/dev/null || echo " (none)"
|
||||
}
|
||||
|
||||
cmd_share() {
|
||||
local path="$1" client="${2:-*(rw,sync,no_subtree_check)}"
|
||||
require_root_dir "$path"
|
||||
if [ "$client" = "*(rw,sync,no_subtree_check)" ]; then
|
||||
warn "Generic export '$client' — ANY client can mount $path. Restrict it, e.g.:"
|
||||
echo " pos share nfs-server share $path '100.64.0.0/10(rw,sync,no_subtree_check)'"
|
||||
echo " pos share nfs-server share $path '10.10.0.0/24(rw,sync,no_subtree_check)'"
|
||||
echo
|
||||
section "Exports"
|
||||
exportfs -v 2>/dev/null || echo " (none)"
|
||||
;;
|
||||
fi
|
||||
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
awk -v p="$path" '$1 != p' "$EXPORTS_FILE" > "$tmp"
|
||||
echo "$path $client" >> "$tmp"
|
||||
sudo cp "$tmp" "$EXPORTS_FILE"
|
||||
rm -f "$tmp"
|
||||
|
||||
sudo exportfs -ra
|
||||
log "Exported: $path $client"
|
||||
notify_send "NFS share added: $path $client"
|
||||
|
||||
# Advisory post-checks (never abort the share operation).
|
||||
if ! share_service_active nfs-server; then
|
||||
share_offer_fix "The nfs-server service is not running" \
|
||||
sudo systemctl enable --now nfs-server
|
||||
fi
|
||||
if share_ufw_blocks_ports '2049|111|\bnfs\b'; then
|
||||
warn "ufw is active but has no NFS rule — clients will be blocked."
|
||||
share_offer_fix "Allow NFS through ufw" sudo ufw allow 2049/tcp
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_unshare() {
|
||||
local path="$1"
|
||||
require_root_dir "$path"
|
||||
|
||||
if ! awk -v p="$path" '$1 == p {found=1} END {exit !found}' "$EXPORTS_FILE"; then
|
||||
warn "No export for $path in $EXPORTS_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
awk -v p="$path" '$1 != p' "$EXPORTS_FILE" > "$tmp"
|
||||
sudo cp "$tmp" "$EXPORTS_FILE"
|
||||
rm -f "$tmp"
|
||||
|
||||
sudo exportfs -ra
|
||||
log "Removed export: $path"
|
||||
notify_send "NFS share removed: $path"
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
exportfs -v 2>/dev/null || echo "No exports"
|
||||
}
|
||||
|
||||
cmd_reload() {
|
||||
sudo exportfs -ra
|
||||
log "NFS exports reloaded"
|
||||
}
|
||||
|
||||
cmd_enable() {
|
||||
sudo systemctl enable --now nfs-server
|
||||
log "nfs-server enabled (starts on boot)"
|
||||
notify_send "NFS server enabled"
|
||||
}
|
||||
|
||||
cmd_disable() {
|
||||
sudo systemctl disable --now nfs-server
|
||||
log "nfs-server disabled (will not start on boot)"
|
||||
notify_send "NFS server disabled"
|
||||
}
|
||||
|
||||
# ── Interactive menu flows ─────────────────────────────────────
|
||||
menu_pick_folder() { # stdout: folder path · rc 1 cancelled
|
||||
local idx dir cand
|
||||
local -a cands=()
|
||||
if mapfile -t cands < <(share_folder_candidates) && [ "${#cands[@]}" -gt 0 ]; then
|
||||
if idx="$(share_pick "Share which folder?" "${cands[@]}")"; then
|
||||
cand="${cands[$((idx - 1))]}"
|
||||
dir="${cand%% (*}" # strip "(mounted fstype)" annotation
|
||||
[ -d "$dir" ] || { warn "Folder vanished: $dir"; return 1; }
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
dir="$(share_ask_value "Folder to share (absolute path)")" || return 1
|
||||
[ -n "$dir" ] || { warn "No folder given"; return 1; }
|
||||
echo "$dir"
|
||||
}
|
||||
|
||||
menu_share() {
|
||||
local dir idx spec
|
||||
dir="$(menu_pick_folder)" || return 1
|
||||
|
||||
local -a specs=(
|
||||
"100.64.0.0/10(rw,sync,no_subtree_check) — Tailscale CGNAT range"
|
||||
"10.10.0.0/24(rw,sync,no_subtree_check) — WireGuard subnet"
|
||||
"192.168.1.0/24(rw,sync,no_subtree_check) — LAN subnet"
|
||||
"192.168.1.0/24(ro,sync,no_subtree_check) — LAN read-only"
|
||||
"* (rw,sync,no_subtree_check) — ANY client (unsafe)"
|
||||
)
|
||||
if idx="$(share_pick "Client access for ${dir}" "${specs[@]}")"; then
|
||||
spec="$(sed 's/[[:space:]]*—.*//' <<<"${specs[$((idx - 1))]}")"
|
||||
[ "$spec" = "*" ] && spec="*(rw,sync,no_subtree_check)"
|
||||
else
|
||||
spec="$(share_ask_value "Client spec (e.g. 10.10.0.0/24(rw,sync))" "")" || return 1
|
||||
[ -n "$spec" ] || spec="*(rw,sync,no_subtree_check)"
|
||||
fi
|
||||
cmd_share "$dir" "$spec"
|
||||
}
|
||||
|
||||
menu_unshare() {
|
||||
local idx path
|
||||
local -a paths=()
|
||||
if mapfile -t paths < <(awk 'NF > 0 && $1 !~ /^#/ {print $1}' "$EXPORTS_FILE" 2>/dev/null) &&
|
||||
[ "${#paths[@]}" -gt 0 ]; then
|
||||
idx="$(share_pick "Remove which export?" "${paths[@]}")" || return 1
|
||||
path="${paths[$((idx - 1))]}"
|
||||
else
|
||||
path="$(share_ask_value "Exported path to remove")" || return 1
|
||||
[ -n "$path" ] || return 1
|
||||
fi
|
||||
cmd_unshare "$path"
|
||||
}
|
||||
|
||||
run_menu() {
|
||||
share_menu_guard || exit 1
|
||||
while true; do
|
||||
local choice
|
||||
choice="$(share_menu_run "NFS server" \
|
||||
"Show status (service + exports)" \
|
||||
"Share a folder" \
|
||||
"Remove an export" \
|
||||
"List current exports" \
|
||||
"Reload exports after hand edits" \
|
||||
"Enable service on boot" \
|
||||
"Disable service")" || return 0
|
||||
case "$choice" in
|
||||
1) cmd_status ;;
|
||||
2) menu_share ;;
|
||||
3) menu_unshare ;;
|
||||
4) cmd_list ;;
|
||||
5) cmd_reload ;;
|
||||
6) cmd_enable ;;
|
||||
7) cmd_disable ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
case "$cmd" in
|
||||
""|menu)
|
||||
run_menu
|
||||
exit 0
|
||||
;;
|
||||
status) cmd_status ;;
|
||||
share)
|
||||
path="${2:-}"
|
||||
client="${3:-*(rw,sync,no_subtree_check)}"
|
||||
[ -n "$path" ] || err "Usage: pos share nfs-server share <path> [client]"
|
||||
require_root_dir "$path"
|
||||
if [ "$client" = "*(rw,sync,no_subtree_check)" ]; then
|
||||
warn "Generic export '$client' — ANY client can mount $path. Restrict it, e.g.:"
|
||||
echo " pos share nfs-server share $path '100.64.0.0/10(rw,sync,no_subtree_check)'"
|
||||
echo " pos share nfs-server share $path '10.10.0.0/24(rw,sync,no_subtree_check)'"
|
||||
echo
|
||||
fi
|
||||
|
||||
tmp="$(mktemp)"
|
||||
awk -v p="$path" '$1 != p' "$EXPORTS_FILE" > "$tmp"
|
||||
echo "$path $client" >> "$tmp"
|
||||
sudo cp "$tmp" "$EXPORTS_FILE"
|
||||
rm -f "$tmp"
|
||||
|
||||
sudo exportfs -ra
|
||||
log "Exported: $path $client"
|
||||
notify_send "NFS share added: $path $client"
|
||||
[ $# -ge 2 ] || err "Usage: pos share nfs-server share <path> [client]"
|
||||
cmd_share "$2" "${3:-*(rw,sync,no_subtree_check)}"
|
||||
;;
|
||||
|
||||
unshare)
|
||||
path="${2:-}"
|
||||
[ -n "$path" ] || err "Usage: pos share nfs-server unshare <path>"
|
||||
require_root_dir "$path"
|
||||
|
||||
if ! awk -v p="$path" '$1 == p {found=1} END {exit !found}' "$EXPORTS_FILE"; then
|
||||
warn "No export for $path in $EXPORTS_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tmp="$(mktemp)"
|
||||
awk -v p="$path" '$1 != p' "$EXPORTS_FILE" > "$tmp"
|
||||
sudo cp "$tmp" "$EXPORTS_FILE"
|
||||
rm -f "$tmp"
|
||||
|
||||
sudo exportfs -ra
|
||||
log "Removed export: $path"
|
||||
notify_send "NFS share removed: $path"
|
||||
;;
|
||||
|
||||
list)
|
||||
exportfs -v 2>/dev/null || echo "No exports"
|
||||
;;
|
||||
|
||||
reload)
|
||||
sudo exportfs -ra
|
||||
log "NFS exports reloaded"
|
||||
;;
|
||||
|
||||
enable)
|
||||
sudo systemctl enable --now nfs-server
|
||||
log "nfs-server enabled (starts on boot)"
|
||||
notify_send "NFS server enabled"
|
||||
;;
|
||||
|
||||
disable)
|
||||
sudo systemctl disable --now nfs-server
|
||||
log "nfs-server disabled (will not start on boot)"
|
||||
notify_send "NFS server disabled"
|
||||
[ $# -ge 2 ] || err "Usage: pos share nfs-server unshare <path>"
|
||||
cmd_unshare "$2"
|
||||
;;
|
||||
list) cmd_list ;;
|
||||
reload) cmd_reload ;;
|
||||
enable) cmd_enable ;;
|
||||
disable) cmd_disable ;;
|
||||
esac
|
||||
|
||||
+661
-114
@@ -1,12 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: share smb-client — Mount SMB/CIFS shares (ephemeral or persistent systemd mount units)
|
||||
# POS_SUBCMDS: mount unmount list persist unpersist 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/share-lib.sh" 2>/dev/null || source "$(dirname "$0")/share-lib.sh"
|
||||
|
||||
SMB_CREDS_DIR="${SMB_CREDS_DIR:-/etc/samba/credentials}"
|
||||
UNIT_DIR="${UNIT_DIR:-/etc/systemd/system}"
|
||||
SMB_PORT="${SMB_PORT:-445}"
|
||||
SMB_CONF="${SMB_CONF:-/etc/samba/smb.conf}"
|
||||
command -v mount.cifs &>/dev/null || err "mount.cifs not found (install cifs-utils)"
|
||||
command -v systemd-escape &>/dev/null || err "systemd-escape not found"
|
||||
|
||||
@@ -18,12 +22,24 @@ Mount and manage SMB/CIFS shares from remote servers (cifs-utils).
|
||||
|
||||
Commands:
|
||||
mount <//server/share> <local-dir> [user] One-shot mount (creates local-dir if needed)
|
||||
unmount <local-dir> Unmount the share
|
||||
unmount <local-dir> Unmount the share (idempotent: rc 0 when
|
||||
nothing is mounted)
|
||||
list Show active + persistent SMB mounts
|
||||
persist <//server/share> <local-dir> [user] Persistent mount via systemd .mount + .automount
|
||||
units (mounts on first access — never blocks boot)
|
||||
unpersist <local-dir> Stop, disable and remove the mount/automount units
|
||||
|
||||
Safety net before anything is mounted:
|
||||
- server reachability on port 445 is probed first (fast fail with hints,
|
||||
before any password is asked)
|
||||
- an already-mounted target refuses a second mount instead of stacking
|
||||
- mounting over a non-empty directory asks for confirmation first
|
||||
- every successful mount is read-tested immediately; a mount that cannot
|
||||
be listed is rolled back automatically
|
||||
- mount failures are translated into targeted fixes (password, chmod o+x
|
||||
traversal, wrong share name, firewall/down server) instead of raw
|
||||
kernel errors
|
||||
|
||||
With no user, guest access is attempted. With a user, you are prompted for
|
||||
the Samba password — one-shot mounts use a throwaway chmod-600 credentials
|
||||
file; persistent mounts keep one at /etc/samba/credentials/.
|
||||
@@ -40,14 +56,14 @@ EOF
|
||||
cmd="${1:-}"
|
||||
case "$cmd" in
|
||||
-h|--help) usage ;;
|
||||
mount|unmount|list|persist|unpersist) ;;
|
||||
"") err "Missing command (mount|unmount|list|persist|unpersist)" ;;
|
||||
""|menu|mount|unmount|list|persist|unpersist) ;;
|
||||
*) err "Unknown command '$cmd' (see --help)" ;;
|
||||
esac
|
||||
|
||||
validate_share() {
|
||||
case "$1" in
|
||||
//*) ;;
|
||||
//*/*) ;;
|
||||
//*) err "Invalid share '$1' — missing share name: expected <//server/share> (e.g. //10.0.0.5/media)" ;;
|
||||
*) err "Invalid share '$1' — expected <//server/share> (e.g. //10.0.0.5/media)" ;;
|
||||
esac
|
||||
}
|
||||
@@ -59,11 +75,74 @@ validate_dir() {
|
||||
esac
|
||||
}
|
||||
|
||||
# Prompt for the Samba password (no echo, via /dev/tty) and write a fresh
|
||||
# chmod-600 credentials file; prints its path, caller removes it.
|
||||
# Split //server/share into server + share; normalizes trailing slashes.
|
||||
split_share() {
|
||||
local rest="$1"
|
||||
rest="${rest#//}"
|
||||
SHARE_PATH="/${rest#*/}"
|
||||
SERVER="${rest%%/*}"
|
||||
SHARE_PATH="${SHARE_PATH%/}"
|
||||
[ -n "$SERVER" ] || err "No server in share '$1'"
|
||||
[ -n "${SHARE_PATH#/}" ] || err "No share name in '$1'"
|
||||
}
|
||||
|
||||
# TCP connect probe against the server before touching passwords or mounts.
|
||||
probe_server() {
|
||||
local host="$1"
|
||||
if timeout 3 bash -c "exec 3<>/dev/tcp/${host}/${SMB_PORT}" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
err "Server ${host} not reachable on port ${SMB_PORT} (SMB) — nothing was mounted.
|
||||
Checks:
|
||||
- is the address right? ping ${host}
|
||||
- is the server up and sharing? on the server: pos share smb-server status
|
||||
- port filtered? from here: pos network checkport ${host}:${SMB_PORT}
|
||||
- Tailscale host? make sure 'tailscale status' lists it (up + recent)"
|
||||
}
|
||||
|
||||
# Source currently cifs-mounted at $1 (empty string when none).
|
||||
mounted_src() {
|
||||
findmnt -rnf -t cifs -o SOURCE,TARGET 2>/dev/null | awk -v t="$1" '$2 == t {print $1; exit}'
|
||||
}
|
||||
|
||||
# Persisted Type=cifs unit declaring Where=<path>? An idle automount never
|
||||
# appears in findmnt, so this is the only way to tell "idle automount" apart
|
||||
# from "nothing configured". Sets PERSISTED_UNIT to the unit file basename.
|
||||
persisted_smb_at() { # <path> — rc 0 persisted · rc 1 not persisted
|
||||
local uf
|
||||
PERSISTED_UNIT=""
|
||||
for uf in "${UNIT_DIR}"/*.mount; do
|
||||
[ -f "$uf" ] || continue
|
||||
grep -q '^Type=cifs$' "$uf" || continue
|
||||
if [ "$(sed -n 's/^Where=//p' "$uf")" = "$1" ]; then
|
||||
PERSISTED_UNIT="$(basename "$uf")"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Create the mountpoint if needed; refuse to silently shadow a non-empty dir.
|
||||
ensure_mountpoint() {
|
||||
local where="$1"
|
||||
if [ -e "$where" ] && [ ! -d "$where" ]; then
|
||||
err "$where exists and is not a directory — pick another mount point"
|
||||
fi
|
||||
if [ -d "$where" ] && [ -n "$(ls -A "$where" 2>/dev/null)" ]; then
|
||||
warn "$where is not empty — its contents are hidden while the share is mounted."
|
||||
confirm "Mount over it anyway?" n || err "Aborted — nothing was mounted"
|
||||
elif [ ! -d "$where" ]; then
|
||||
run sudo mkdir -p "$where"
|
||||
log "Created mount point $where"
|
||||
fi
|
||||
}
|
||||
|
||||
# Prompt for the Samba password (no echo) and write a fresh chmod-600
|
||||
# credentials file; prints its path, caller removes it. Test seam:
|
||||
# SMB_PW_FILE redirects the password read (never advertised).
|
||||
make_creds() {
|
||||
local user="$1" pw tmp
|
||||
read -rsp "Samba password for $user: " pw </dev/tty || true
|
||||
local user="$1" pw tmp pw_in="${SMB_PW_FILE:-/dev/tty}"
|
||||
read -rsp "Samba password for $user: " pw <"$pw_in" || true
|
||||
echo >&2
|
||||
[ -n "$pw" ] || err "empty password"
|
||||
tmp="$(mktemp)"
|
||||
@@ -73,94 +152,223 @@ make_creds() {
|
||||
}
|
||||
|
||||
mount_opts() {
|
||||
local user="$1"
|
||||
printf 'uid=%s,gid=%s' "$(id -u)" "$(id -g)"
|
||||
}
|
||||
|
||||
case "$cmd" in
|
||||
mount)
|
||||
what="${2:-}"
|
||||
where="${3:-}"
|
||||
user="${4:-}"
|
||||
[ -n "$what" ] && [ -n "$where" ] || err "Usage: pos share smb-client mount <//server/share> <local-dir> [user]"
|
||||
validate_share "$what"
|
||||
validate_dir "$where"
|
||||
# Translate a failed mount attempt into the fix the human actually needs.
|
||||
diagnose_mount_failure() {
|
||||
local msg="$1"
|
||||
case "$msg" in
|
||||
*LOGON_FAILURE*|*WRONG_PASSWORD*)
|
||||
err "Server rejected the login (wrong user/password).
|
||||
- retry with the correct Samba user: pos share smb-client mount $what $where <user>
|
||||
- does the account exist at all? on the server: pos share smb-server status" ;;
|
||||
*ACCESS_DENIED*|*"error(13)"*|*"Permission denied"*|*STATUS_CANNOT_DELETE*)
|
||||
err "Access denied by the server (login worked, share said no).
|
||||
On the server side, in order:
|
||||
1. traversal: every parent dir of the share needs o+x — the server tool
|
||||
prints the exact dirs when you re-share; quick fix:
|
||||
chmod o+x <parent-dir>
|
||||
2. user missing from the Samba passdb: pos share smb-server adduser <user>
|
||||
3. share restricted: 'valid users' must contain your user (pos share smb-server list)" ;;
|
||||
*BAD_NETWORK_NAME*)
|
||||
err "The server has no share named '${SHARE_PATH#/}'.
|
||||
- typo in the share name? on the server: pos share smb-server list
|
||||
- correct syntax: //$SERVER/<share>" ;;
|
||||
*CONNECTION_REFUSED*|*"error(111)"*)
|
||||
err "${SERVER} refused the connection — smbd is down or a firewall rejects port ${SMB_PORT}.
|
||||
on the server: pos share smb-server enable / sudo ufw allow Samba" ;;
|
||||
*HOST_UNREACHABLE*|*"error(101)"*|*"error(113)"*|*"No route to host"*|*"Network is unreachable"*)
|
||||
err "${SERVER} is unreachable (routing/host down).
|
||||
- Tailscale host? 'tailscale status' must list it as online
|
||||
- probe from here: pos network checkport ${SERVER}:${SMB_PORT}" ;;
|
||||
*IO_TIMEOUT*|*"Host is down"*|*"error(112)"*|*"timed out"*)
|
||||
err "${SERVER} accepted TCP but never answered the SMB handshake (usually a dropping firewall).
|
||||
on the server: sudo ufw allow Samba / pos share smb-server status" ;;
|
||||
*)
|
||||
err "Mount failed:
|
||||
$msg
|
||||
More causes + fixes: DOC/howto/share.md (SMB troubleshooting)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
sudo mkdir -p "$where"
|
||||
if [ -n "$user" ]; then
|
||||
creds="$(make_creds "$user")"
|
||||
trap 'rm -f "$creds"' EXIT
|
||||
sudo mount -t cifs "$what" "$where" -o "credentials=$creds,$(mount_opts "$user")"
|
||||
# Mount attempt with captured stderr (so failures can be diagnosed).
|
||||
try_mount() {
|
||||
local what="$1" where="$2" opts="$3"
|
||||
if [ "${DRY_RUN:-0}" -eq 1 ]; then
|
||||
log "(dry-run) mount -t cifs $what $where -o $opts"
|
||||
return 0
|
||||
fi
|
||||
MOUNT_ERR="$(sudo mount -t cifs "$what" "$where" -o "$opts" 2>&1)" || return 1
|
||||
}
|
||||
|
||||
# Post-mount proof: the mount must actually list. A mount that cannot be
|
||||
# read is worse than no mount — roll it back.
|
||||
read_test_or_rollback() {
|
||||
local where="$1" what="$2"
|
||||
if [ "${DRY_RUN:-0}" -eq 1 ]; then return 0; fi
|
||||
if timeout 5 ls -A "$where" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
warn "Mounted, but $where cannot be listed — rolling back."
|
||||
sudo umount "$where" 2>/dev/null || sudo umount -l "$where" 2>/dev/null || true
|
||||
err "Share '${what}' mounted but reading it failed, so it was unmounted again.
|
||||
Login works but file permissions don't — on the server:
|
||||
- ls -la <shared-path> as root: do others/the owner have r-x?
|
||||
- the connecting user must have unix-level access to the folder itself
|
||||
Details: DOC/howto/share.md (SMB troubleshooting)"
|
||||
}
|
||||
|
||||
cmd_mount() { # <//server/share> <local-dir> [user]
|
||||
local what="$1" where="$2" user="${3:-}"
|
||||
local src creds opts
|
||||
validate_share "$what"
|
||||
validate_dir "$where"
|
||||
split_share "$what"
|
||||
|
||||
probe_server "$SERVER"
|
||||
|
||||
if src="$(mounted_src "$where")"; [ -n "$src" ]; then
|
||||
err "$where is already mounted (source: ${src:-unknown}) — nothing done.
|
||||
Unmount it first: pos share smb-client unmount $where"
|
||||
fi
|
||||
|
||||
ensure_mountpoint "$where"
|
||||
|
||||
if [ -n "$user" ]; then
|
||||
creds="$(make_creds "$user")"
|
||||
trap 'rm -f "$creds"' EXIT
|
||||
opts="credentials=$creds,$(mount_opts)"
|
||||
try_mount "$what" "$where" "$opts" || {
|
||||
rm -f "$creds"
|
||||
trap - EXIT
|
||||
diagnose_mount_failure "${MOUNT_ERR:-}"
|
||||
}
|
||||
rm -f "$creds"
|
||||
trap - EXIT
|
||||
else
|
||||
warn "No user — attempting guest mount (works only if the server allows guest access)"
|
||||
try_mount "$what" "$where" "guest,$(mount_opts)" || diagnose_mount_failure "${MOUNT_ERR:-}"
|
||||
fi
|
||||
|
||||
read_test_or_rollback "$where" "$what"
|
||||
log "Mounted $what at $where (read test passed)"
|
||||
notify_send "SMB mounted: $what → $where"
|
||||
}
|
||||
|
||||
cmd_unmount() { # <local-dir>
|
||||
local where="$1" src out
|
||||
validate_dir "$where"
|
||||
|
||||
src="$(mounted_src "$where")"
|
||||
if [ -z "$src" ]; then
|
||||
# Idempotent no-op — rc 0 whether or not anything is configured.
|
||||
if persisted_smb_at "$where"; then
|
||||
warn "$where is a persisted automount — not currently mounted."
|
||||
log "Access it once (e.g.: ls $where) to auto-mount it, or remove the persistence first: menu option 5 (pos share smb-client unpersist $where)"
|
||||
else
|
||||
warn "No user — attempting guest mount (works only if the server allows guest access)"
|
||||
sudo mount -t cifs "$what" "$where" -o "guest,$(mount_opts "")"
|
||||
log "Nothing mounted at $where"
|
||||
fi
|
||||
log "Mounted $what at $where"
|
||||
;;
|
||||
|
||||
unmount)
|
||||
where="${2:-}"
|
||||
[ -n "$where" ] || err "Usage: pos share smb-client unmount <local-dir>"
|
||||
validate_dir "$where"
|
||||
|
||||
sudo umount "$where"
|
||||
log "Unmounted $where"
|
||||
;;
|
||||
|
||||
list)
|
||||
found=0
|
||||
active_mounts="$(findmnt -t cifs 2>/dev/null || true)"
|
||||
if [ -n "$active_mounts" ]; then
|
||||
printf 'Active mounts:\n'
|
||||
printf '%s\n' "$active_mounts"
|
||||
found=1
|
||||
return 0
|
||||
fi
|
||||
if ! out="$(sudo umount "$where" 2>&1)"; then
|
||||
if grep -qE "busy|in use" <<<"$out"; then
|
||||
warn "$where is busy (${src})"
|
||||
confirm "Force a lazy unmount now?" y && { run sudo umount -l "$where"; log "Lazy-unmounted $where"; return 0; }
|
||||
err "Still mounted. Find the blocker: sudo lsof +D $where (or fuser -vm $where)"
|
||||
fi
|
||||
persistent=()
|
||||
for unit in "${UNIT_DIR}"/*.mount; do
|
||||
[ -e "$unit" ] || continue
|
||||
grep -q '^Type=cifs$' "$unit" || continue
|
||||
what="$(sed -n 's/^What=//p' "$unit")"
|
||||
where="$(sed -n 's/^Where=//p' "$unit")"
|
||||
[ -n "$what" ] && [ -n "$where" ] || continue
|
||||
persistent+=("$where|$what")
|
||||
err "Unmount failed: $out"
|
||||
fi
|
||||
log "Unmounted $where"
|
||||
notify_send "SMB unmounted: $where"
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
local found=0 active_mounts unit what where
|
||||
local -a persistent=()
|
||||
active_mounts="$(findmnt -t cifs 2>/dev/null || true)"
|
||||
if [ -n "$active_mounts" ]; then
|
||||
printf 'Active mounts:\n'
|
||||
printf '%s\n' "$active_mounts"
|
||||
found=1
|
||||
fi
|
||||
for unit in "${UNIT_DIR}"/*.mount; do
|
||||
[ -e "$unit" ] || continue
|
||||
grep -q '^Type=cifs$' "$unit" || continue
|
||||
what="$(sed -n 's/^What=//p' "$unit")"
|
||||
where="$(sed -n 's/^Where=//p' "$unit")"
|
||||
[ -n "$what" ] && [ -n "$where" ] || continue
|
||||
persistent+=("$where|$what")
|
||||
done
|
||||
if [ "${#persistent[@]}" -gt 0 ]; then
|
||||
found=1
|
||||
printf 'Persistent (automount):\n'
|
||||
for entry in "${persistent[@]}"; do
|
||||
printf ' %-44s %s\n' "${entry%%|*}" "${entry#*|}"
|
||||
done
|
||||
if [ "${#persistent[@]}" -gt 0 ]; then
|
||||
found=1
|
||||
printf 'Persistent (automount):\n'
|
||||
for entry in "${persistent[@]}"; do
|
||||
printf ' %-44s %s\n' "${entry%%|*}" "${entry#*|}"
|
||||
done
|
||||
fi
|
||||
[ "$found" -eq 1 ] || echo "No SMB mounts"
|
||||
;;
|
||||
fi
|
||||
[ "$found" -eq 1 ] || echo "No SMB mounts"
|
||||
}
|
||||
|
||||
persist)
|
||||
what="${2:-}"
|
||||
where="${3:-}"
|
||||
user="${4:-}"
|
||||
[ -n "$what" ] && [ -n "$where" ] || err "Usage: pos share smb-client persist <//server/share> <local-dir> [user]"
|
||||
validate_share "$what"
|
||||
validate_dir "$where"
|
||||
cmd_persist() { # <//server/share> <local-dir> [user]
|
||||
local what="$1" where="$2" user="${3:-}"
|
||||
local src unit auto_unit unit_file auto_file opts creds_file tmp out verified
|
||||
validate_share "$what"
|
||||
validate_dir "$where"
|
||||
split_share "$what"
|
||||
|
||||
unit="$(systemd-escape --path --suffix=mount "$where")"
|
||||
auto_unit="${unit%.mount}.automount"
|
||||
unit_file="${UNIT_DIR}/${unit}"
|
||||
auto_file="${UNIT_DIR}/${auto_unit}"
|
||||
opts="$(mount_opts "$user"),_netdev,noexec"
|
||||
if [ -n "$user" ]; then
|
||||
creds_file="$SMB_CREDS_DIR/$(basename "$where")"
|
||||
sudo mkdir -p "$SMB_CREDS_DIR"
|
||||
tmp="$(make_creds "$user")"
|
||||
sudo install -m 600 "$tmp" "$creds_file"
|
||||
rm -f "$tmp"
|
||||
opts="credentials=$creds_file,$opts"
|
||||
else
|
||||
warn "No user — persisting a guest mount (works only if the server allows guest access)"
|
||||
opts="guest,$opts"
|
||||
fi
|
||||
probe_server "$SERVER"
|
||||
|
||||
sudo mkdir -p "$where"
|
||||
cat <<UNIT | sudo tee "$unit_file" >/dev/null
|
||||
if src="$(mounted_src "$where")"; [ -n "$src" ]; then
|
||||
err "$where is already mounted (source: ${src:-unknown}) — nothing done.
|
||||
Active mount + automount units conflict; unmount first:
|
||||
pos share smb-client unmount $where"
|
||||
fi
|
||||
|
||||
unit="$(systemd-escape --path --suffix=mount "$where")"
|
||||
auto_unit="${unit%.mount}.automount"
|
||||
unit_file="${UNIT_DIR}/${unit}"
|
||||
auto_file="${UNIT_DIR}/${auto_unit}"
|
||||
|
||||
if [ -e "$unit_file" ] || [ -e "$auto_file" ]; then
|
||||
warn "Units for $where already exist — they will be REPLACED:"
|
||||
if [ -e "$unit_file" ]; then warn " ${unit_file}"; fi
|
||||
if [ -e "$auto_file" ]; then warn " ${auto_file}"; fi
|
||||
confirm "Replace them?" n || err "Aborted — units left untouched"
|
||||
fi
|
||||
|
||||
opts="$(mount_opts),_netdev,noexec"
|
||||
creds_file=""
|
||||
if [ -n "$user" ]; then
|
||||
creds_file="$SMB_CREDS_DIR/$(basename "$where")"
|
||||
sudo mkdir -p "$SMB_CREDS_DIR"
|
||||
tmp="$(make_creds "$user")"
|
||||
sudo install -m 600 "$tmp" "$creds_file"
|
||||
rm -f "$tmp"
|
||||
opts="credentials=$creds_file,$opts"
|
||||
else
|
||||
warn "No user — persisting a guest mount (works only if the server allows guest access)"
|
||||
opts="guest,$opts"
|
||||
fi
|
||||
|
||||
rollback_persist() {
|
||||
warn "Rolling back everything this command created…"
|
||||
sudo systemctl disable "$auto_unit" 2>/dev/null || true
|
||||
sudo systemctl stop "$auto_unit" 2>/dev/null || true
|
||||
sudo systemctl disable "$unit" 2>/dev/null || true
|
||||
sudo systemctl stop "$unit" 2>/dev/null || true
|
||||
sudo rm -f "$unit_file" "$auto_file"
|
||||
if [ -n "$creds_file" ]; then sudo rm -f "$creds_file"; fi
|
||||
sudo systemctl daemon-reload 2>/dev/null || true
|
||||
}
|
||||
|
||||
if [ "${DRY_RUN:-0}" -eq 1 ]; then
|
||||
log "(dry-run) write $unit_file + $auto_file (Type=cifs, Options=$opts)"
|
||||
log "(dry-run) daemon-reload + enable --now $auto_unit"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cat <<UNIT | sudo tee "$unit_file" >/dev/null
|
||||
[Unit]
|
||||
Description=SMB mount of ${what} at ${where}
|
||||
After=network-online.target
|
||||
@@ -172,7 +380,7 @@ Where=${where}
|
||||
Type=cifs
|
||||
Options=${opts}
|
||||
UNIT
|
||||
cat <<UNIT | sudo tee "$auto_file" >/dev/null
|
||||
cat <<UNIT | sudo tee "$auto_file" >/dev/null
|
||||
[Unit]
|
||||
Description=Automount of SMB share ${what} at ${where}
|
||||
|
||||
@@ -182,36 +390,375 @@ Where=${where}
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now "$auto_unit"
|
||||
log "Persistent SMB mount (automount): ${what} → ${where} (${auto_unit})"
|
||||
notify_send "SMB mount persisted: ${what} → ${where}"
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# Enable, then prove the automount actually serves the share before
|
||||
# declaring victory — a broken unit here would bite months later.
|
||||
if ! out="$(sudo systemctl enable --now "$auto_unit" 2>&1)"; then
|
||||
rollback_persist
|
||||
err "Could not enable $auto_unit: $out"
|
||||
fi
|
||||
|
||||
ls "$where" >/dev/null 2>&1 || true # poke the automount
|
||||
verified=0
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if [ -n "$(mounted_src "$where")" ]; then verified=1; break; fi
|
||||
sleep 0.5
|
||||
done
|
||||
if [ "$verified" -ne 1 ]; then
|
||||
rollback_persist
|
||||
err "Automount did not trigger for $where — units removed again.
|
||||
Check: systemctl status $auto_unit"
|
||||
fi
|
||||
|
||||
# Same read-proof as one-shot mounts: catch permission problems now.
|
||||
if ! timeout 5 ls -A "$where" >/dev/null 2>&1; then
|
||||
rollback_persist
|
||||
err "Automount triggered but $where is not readable — units + credentials removed.
|
||||
Login works but file permissions don't — fix unix perms on the server
|
||||
(the shared folder itself needs r-x for the connecting user).
|
||||
Details: DOC/howto/share.md (SMB troubleshooting)"
|
||||
fi
|
||||
|
||||
log "Persistent SMB mount (automount): ${what} → ${where} (${auto_unit})"
|
||||
log "Verified: automount triggers and the share is readable"
|
||||
notify_send "SMB mount persisted: ${what} → ${where}"
|
||||
}
|
||||
|
||||
cmd_unpersist() { # <local-dir>
|
||||
local where="$1" unit auto_unit unit_file auto_file
|
||||
validate_dir "$where"
|
||||
|
||||
unit="$(systemd-escape --path --suffix=mount "$where")"
|
||||
auto_unit="${unit%.mount}.automount"
|
||||
unit_file="${UNIT_DIR}/${unit}"
|
||||
auto_file="${UNIT_DIR}/${auto_unit}"
|
||||
|
||||
if [ ! -f "$unit_file" ] && [ ! -f "$auto_file" ]; then
|
||||
# Idempotent no-op — rc 0 whether or not anything is configured.
|
||||
# Return, not exit: a bogus typed path reached from the menu must
|
||||
# not kill the whole session. A persisted unit can also live under
|
||||
# a non-escape-derived filename (Where= still matches) — point at
|
||||
# it instead of a bare nothing-to-do.
|
||||
if persisted_smb_at "$where"; then
|
||||
warn "$where has a persistent SMB mount unit (${PERSISTED_UNIT}) under a non-standard unit name."
|
||||
log "Check it: systemctl status ${PERSISTED_UNIT%.mount} — or find it in the list: pos share smb-client list"
|
||||
else
|
||||
log "No persistent SMB mount for $where — nothing to do"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
sudo systemctl disable "$auto_unit" 2>/dev/null || true
|
||||
sudo systemctl stop "$auto_unit" 2>/dev/null || true
|
||||
sudo systemctl disable "$unit" 2>/dev/null || true
|
||||
sudo systemctl stop "$unit" 2>/dev/null || true
|
||||
sudo rm -f "$unit_file" "$auto_file"
|
||||
sudo rm -f "$SMB_CREDS_DIR/$(basename "$where")"
|
||||
sudo rmdir "$SMB_CREDS_DIR" 2>/dev/null || true
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
if [ -n "$(mounted_src "$where")" ]; then
|
||||
warn "$where is still mounted (something holds it open)"
|
||||
if confirm "Force a lazy unmount now?" y; then run sudo umount -l "$where"; fi
|
||||
fi
|
||||
log "Removed persistent SMB mount: $where"
|
||||
notify_send "SMB persistent mount removed: $where"
|
||||
}
|
||||
|
||||
# ── Interactive menu flows ─────────────────────────────────────
|
||||
|
||||
# Best-effort resolution of the directory BEHIND share <$2> on host <$1>.
|
||||
# The underlying path of a remote SMB share is not remotely discoverable;
|
||||
# it is only locally knowable when THIS machine is the server (testparm
|
||||
# answers from the local config). Anything else stays unresolved (rc 1)
|
||||
# and the mountpoint picker silently skips the "(as on server)" suggestion.
|
||||
smb_server_path() { # <host> <share> — stdout: server-side dir · rc 1 = unresolved
|
||||
local host="${1,,}" share="$2" name p ips=""
|
||||
local -a names=("localhost" "127.0.0.1" "::1" "$(hostname)")
|
||||
names+=("$(hostname -f 2>/dev/null || true)")
|
||||
ips="$(hostname -I 2>/dev/null)" || true
|
||||
# shellcheck disable=SC2086 — $ips is an intentional space-split IP list
|
||||
for name in "${names[@]}" ${ips}; do
|
||||
if [ "$name" = "$host" ]; then
|
||||
share_require_bin testparm "" || return 1
|
||||
[ -f "$SMB_CONF" ] || return 1
|
||||
p="$(testparm -s --parameter-name=path --section-name="$share" "$SMB_CONF" 2>/dev/null)" || return 1
|
||||
[ -n "$p" ] || return 1
|
||||
printf '%s\n' "$p"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Mountpoint picker — local port of the share_pick primitive with exactly
|
||||
# two deltas: the hint line offers `n=new`, and typing n runs the
|
||||
# create-new-dir flow below. share_pick cannot intercept `n` (it filters on
|
||||
# it) and lib/menu-lib.sh is shared, so the fork lives here. Rendering of
|
||||
# numbered picks / text filter / 0=back is byte-identical to menu_pick.
|
||||
# stdout: chosen item text (or the freshly created dir) · rc 1 = back/cancel.
|
||||
pick_mountpoint() {
|
||||
local prompt="$1"; shift
|
||||
local -a items=("$@")
|
||||
if [ "${#items[@]}" -eq 0 ]; then
|
||||
return 1
|
||||
fi
|
||||
if ! [ -t 0 ]; then
|
||||
printf '[!] Interactive picker needs a terminal.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
local filter="" ans i n total=${#items[@]} made
|
||||
local -a shown=()
|
||||
while true; do
|
||||
shown=()
|
||||
for ((i = 0; i < total; i++)); do
|
||||
if [ -z "$filter" ] || [[ "${items[$i],,}" == *"${filter,,}"* ]]; then
|
||||
shown+=("${items[$i]}")
|
||||
fi
|
||||
done
|
||||
n=${#shown[@]}
|
||||
{
|
||||
echo
|
||||
if [ -n "$filter" ]; then
|
||||
printf -- "-- %d of %d match '%s' --\n" "$n" "$total" "$filter"
|
||||
else
|
||||
printf -- "-- %d available --\n" "$total"
|
||||
fi
|
||||
if [ "$n" -eq 0 ]; then
|
||||
printf '[!] no matches — enter nothing or / to clear the filter\n' >&2
|
||||
else
|
||||
for ((i = 0; i < n; i++)); do
|
||||
printf ' %2d) %s\n' $((i + 1)) "${shown[$i]}"
|
||||
done
|
||||
fi
|
||||
} >&2
|
||||
if ! read -rp "${prompt} [1-${n}], n=new, text=filter, 0=back " ans; then
|
||||
return 1 # EOF — cancel
|
||||
fi
|
||||
case "$ans" in
|
||||
"") [ -z "$filter" ] || filter="" ; continue ;;
|
||||
"/") filter="" ; continue ;;
|
||||
0 | q | Q | b | B) return 1 ;;
|
||||
n | N)
|
||||
made="$(ask_new_mountpoint)" && { echo "$made"; return 0; }
|
||||
continue # declined/invalid/mkdir-failed → redraw
|
||||
;;
|
||||
*[!0-9]*)
|
||||
filter="$ans"
|
||||
continue
|
||||
;;
|
||||
*)
|
||||
if (( ans >= 1 && ans <= n )); then
|
||||
echo "${shown[$((ans - 1))]}"
|
||||
return 0
|
||||
fi
|
||||
echo "Unknown choice." >&2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Create-new-dir flow behind the picker's `n` key. Validates the shape
|
||||
# (absolute, no trailing slash), confirm-gates the creation, then mkdir -p.
|
||||
# Any decline, invalid input, EOF or mkdir failure is a warning + rc 1 —
|
||||
# the picker redraws, the tool never aborts.
|
||||
ask_new_mountpoint() { # stdout: created dir · rc 1 = cancelled/failed
|
||||
# NOTE: runs inside $( ) from the picker — every display line MUST go to
|
||||
# stderr (menu-lib contract: display → stderr, result → stdout).
|
||||
local dir
|
||||
dir="$(share_ask_value "New mountpoint (absolute path)")" || return 1
|
||||
case "$dir" in
|
||||
/*) ;;
|
||||
*) warn "'$dir' is not an absolute path — must start with /" >&2; return 1 ;;
|
||||
esac
|
||||
case "$dir" in
|
||||
*/) warn "'$dir' must not end with a slash" >&2; return 1 ;;
|
||||
esac
|
||||
case "$dir" in
|
||||
/etc|/boot|/bin|/sbin|/lib|/lib64|/usr|/var|/root|/home/*/.ssh*)
|
||||
warn "Refusing system path as mountpoint" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
confirm "Create mountpoint ${dir}?" n || return 1
|
||||
if ! run sudo mkdir -p "$dir"; then
|
||||
warn "Could not create ${dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
log "Created mount point $dir" >&2
|
||||
echo "$dir"
|
||||
}
|
||||
|
||||
menu_ask_mountpoint() { # [server_path] — stdout: absolute path · rc 1 cancelled
|
||||
local srv="${1:-}" idx res cand dir known=0
|
||||
local -a cands=() dirs=()
|
||||
mapfile -t cands < <(share_folder_candidates)
|
||||
for cand in "${cands[@]}"; do
|
||||
dirs+=("${cand%% (*}") # bare path (strip "(mounted fstype)" note)
|
||||
done
|
||||
# Same-as-server suggestion: unless the server-side path already exists
|
||||
# among the local candidates, append it as a synthetic pick so mounting
|
||||
# at a mirrored path is a normal selection.
|
||||
if [ -n "$srv" ] && [ "${#dirs[@]}" -gt 0 ] &&
|
||||
printf '%s\n' "${dirs[@]}" | grep -qxF -- "$srv"; then
|
||||
known=1
|
||||
fi
|
||||
if [ -n "$srv" ] && [ "$known" -eq 0 ]; then
|
||||
cands+=("${srv} (as on server)")
|
||||
fi
|
||||
if [ "${#cands[@]}" -gt 0 ]; then
|
||||
if res="$(pick_mountpoint "Mountpoint" "${cands[@]}")"; then
|
||||
dir="${res%% (*}" # strip "(as on server)"/mount note
|
||||
case "$dir" in
|
||||
/etc|/boot|/bin|/sbin|/lib|/lib64|/usr|/var|/root|/home/*/.ssh*)
|
||||
warn "Refusing system path as mountpoint"
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
echo "$dir"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1
|
||||
[ -n "$dir" ] || { warn "No mountpoint given"; return 1; }
|
||||
echo "$dir"
|
||||
}
|
||||
|
||||
menu_mount() { # ephemeral|persist
|
||||
local mode="$1" host idx share what where
|
||||
host="$(share_ask_value "SMB server (host or IP)")" || return 1
|
||||
[ -n "$host" ] || { warn "No server given"; return 1; }
|
||||
|
||||
# Empty answer = guest enumeration (with an interactive auth retry inside
|
||||
# the helper); a named user authenticates right away. SMB_AUTH_USER tells
|
||||
# us which account ended up being used so the mount reuses it.
|
||||
local user=""
|
||||
read -rp "Samba user (empty = try guest): " user || return 1
|
||||
SMB_AUTH_USER=""
|
||||
|
||||
local -a shares=()
|
||||
if mapfile -t shares < <(share_smb_shares "$host" "$user") && [ "${#shares[@]}" -gt 0 ]; then
|
||||
idx="$(share_pick "Pick share on ${host}" "${shares[@]}")" || return 1
|
||||
share="${shares[$((idx - 1))]}"
|
||||
else
|
||||
share="$(share_ask_value "Share name on ${host} (e.g. media)")" || return 1
|
||||
[ -n "$share" ] || { warn "No share name given"; return 1; }
|
||||
fi
|
||||
user="${SMB_AUTH_USER:-$user}"
|
||||
|
||||
what="//${host}/${share}"
|
||||
# Underlying server-side dir, when knowable (this machine is the server);
|
||||
# empty → picker silently skips the "(as on server)" suggestion.
|
||||
local srv_path=""
|
||||
srv_path="$(smb_server_path "$host" "$share")" || srv_path=""
|
||||
where="$(menu_ask_mountpoint "$srv_path")" || return 1
|
||||
|
||||
if [ "$mode" = "persist" ]; then
|
||||
cmd_persist "$what" "$where" "$user"
|
||||
else
|
||||
cmd_mount "$what" "$where" "$user"
|
||||
fi
|
||||
}
|
||||
|
||||
menu_unmount() {
|
||||
local idx row src where i
|
||||
local -a tgts=() srcs=() items=()
|
||||
# Same enumeration source as the `list` view's active section
|
||||
# (findmnt -t cifs), reduced to TARGET|SOURCE rows.
|
||||
while IFS= read -r row; do
|
||||
[ -n "$row" ] || continue
|
||||
tgts+=("${row%%|*}")
|
||||
srcs+=("${row#*|}")
|
||||
done < <(findmnt -rn -o TARGET,SOURCE -t cifs 2>/dev/null |
|
||||
awk '{ src=$NF; $NF=""; sub(/[ \t]+$/, ""); print $0 "|" src }')
|
||||
if [ "${#tgts[@]}" -eq 0 ]; then
|
||||
log "No active SMB mounts"
|
||||
where="$(share_ask_value "Local mountpoint to unmount")" || return 1
|
||||
[ -n "$where" ] || return 1
|
||||
cmd_unmount "$where"
|
||||
return 0
|
||||
fi
|
||||
for ((i = 0; i < ${#tgts[@]}; i++)); do
|
||||
items+=("${tgts[$i]} ← ${srcs[$i]}")
|
||||
done
|
||||
idx="$(share_pick "Unmount which SMB mount?" "${items[@]}")" || return 1
|
||||
where="${tgts[$((idx - 1))]}"
|
||||
src="${srcs[$((idx - 1))]}"
|
||||
confirm "Unmount ${where} (from ${src})?" n || { log "Cancelled"; return 1; }
|
||||
cmd_unmount "$where"
|
||||
}
|
||||
|
||||
menu_unpersist() {
|
||||
local idx uf where unit_w
|
||||
local -a paths=()
|
||||
for uf in "${UNIT_DIR}"/*.mount; do
|
||||
grep -q '^Type=cifs$' "$uf" 2>/dev/null || continue
|
||||
unit_w="$(sed -n 's/^Where=//p' "$uf")"
|
||||
[ -n "$unit_w" ] || continue
|
||||
paths+=("$unit_w")
|
||||
done
|
||||
if [ "${#paths[@]}" -gt 0 ]; then
|
||||
idx="$(share_pick "Remove which persistent SMB mount?" "${paths[@]}")" || return 1
|
||||
where="${paths[$((idx - 1))]}"
|
||||
else
|
||||
where="$(share_ask_value "Local mountpoint whose units to remove")" || return 1
|
||||
[ -n "$where" ] || return 1
|
||||
fi
|
||||
cmd_unpersist "$where"
|
||||
}
|
||||
|
||||
run_menu() {
|
||||
share_menu_guard || exit 1
|
||||
while true; do
|
||||
local choice
|
||||
choice="$(share_menu_run "SMB client" \
|
||||
"Mount a share (one-shot)" \
|
||||
"Persist a share (automount units)" \
|
||||
"List active + persistent mounts" \
|
||||
"Unmount a mounted share" \
|
||||
"Remove a persistent mount")" || return 0
|
||||
case "$choice" in
|
||||
# Handlers return nonzero on cancel/back — normalized here so a
|
||||
# cancel can never reach set -e and kill the whole session.
|
||||
1) menu_mount ephemeral || true ;;
|
||||
2) menu_mount persist || true ;;
|
||||
3) cmd_list || true ;;
|
||||
4) menu_unmount || true ;;
|
||||
5) menu_unpersist || true ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
case "$cmd" in
|
||||
""|menu)
|
||||
run_menu
|
||||
exit 0
|
||||
;;
|
||||
|
||||
mount)
|
||||
[ $# -ge 3 ] || err "Usage: pos share smb-client mount <//server/share> <local-dir> [user]"
|
||||
cmd_mount "$2" "$3" "${4:-}"
|
||||
;;
|
||||
|
||||
unmount)
|
||||
[ $# -ge 2 ] || err "Usage: pos share smb-client unmount <local-dir>"
|
||||
cmd_unmount "$2"
|
||||
;;
|
||||
|
||||
list)
|
||||
cmd_list
|
||||
;;
|
||||
|
||||
persist)
|
||||
[ $# -ge 3 ] || err "Usage: pos share smb-client persist <//server/share> <local-dir> [user]"
|
||||
cmd_persist "$2" "$3" "${4:-}"
|
||||
;;
|
||||
|
||||
unpersist)
|
||||
where="${2:-}"
|
||||
[ -n "$where" ] || err "Usage: pos share smb-client unpersist <local-dir>"
|
||||
validate_dir "$where"
|
||||
|
||||
unit="$(systemd-escape --path --suffix=mount "$where")"
|
||||
auto_unit="${unit%.mount}.automount"
|
||||
unit_file="${UNIT_DIR}/${unit}"
|
||||
auto_file="${UNIT_DIR}/${auto_unit}"
|
||||
|
||||
if [ ! -f "$unit_file" ] && [ ! -f "$auto_file" ]; then
|
||||
warn "No systemd mount unit for $where (${unit})"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sudo systemctl disable "$auto_unit" 2>/dev/null || true
|
||||
sudo systemctl stop "$auto_unit" 2>/dev/null || true
|
||||
sudo systemctl disable "$unit" 2>/dev/null || true
|
||||
sudo systemctl stop "$unit" 2>/dev/null || true
|
||||
sudo rm -f "$unit_file" "$auto_file"
|
||||
sudo rm -f "$SMB_CREDS_DIR/$(basename "$where")"
|
||||
sudo rmdir "$SMB_CREDS_DIR" 2>/dev/null || true
|
||||
sudo systemctl daemon-reload
|
||||
log "Removed persistent SMB mount: $where"
|
||||
notify_send "SMB persistent mount removed: $where"
|
||||
[ $# -ge 2 ] || err "Usage: pos share smb-client unpersist <local-dir>"
|
||||
cmd_unpersist "$2"
|
||||
;;
|
||||
esac
|
||||
|
||||
+300
-112
@@ -1,9 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: share smb-server — Manage the Samba server (status, share/unshare exports, users, enable/disable)
|
||||
# POS_SUBCMDS: status share unshare list adduser deluser reload enable disable 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/share-lib.sh" 2>/dev/null || source "$(dirname "$0")/share-lib.sh"
|
||||
|
||||
command -v smbd &>/dev/null || err "smbd not found (install samba)"
|
||||
command -v smbcontrol &>/dev/null || err "smbcontrol not found (install samba)"
|
||||
@@ -47,8 +49,7 @@ SMB_CONF="${SMB_CONF:-/etc/samba/smb.conf}"
|
||||
cmd="${1:-}"
|
||||
case "$cmd" in
|
||||
-h|--help) usage ;;
|
||||
status|share|unshare|list|adduser|deluser|reload|enable|disable) ;;
|
||||
"") err "Missing command (status|share|unshare|list|adduser|deluser|reload|enable|disable)" ;;
|
||||
""|menu|status|share|unshare|list|adduser|deluser|reload|enable|disable) ;;
|
||||
*) err "Unknown command '$cmd' (see --help)" ;;
|
||||
esac
|
||||
|
||||
@@ -103,6 +104,27 @@ reload_config() {
|
||||
fi
|
||||
}
|
||||
|
||||
# rc 0 = Samba reachable through the firewall (or nothing to check),
|
||||
# rc 1 = ufw active but no Samba rule → clients can't reach 139/445.
|
||||
# Thin wrapper: the decision core lives in lib/share-lib.sh.
|
||||
ufw_blocks_samba() {
|
||||
share_ufw_blocks_ports 'samba|(^|[^0-9])(137|138|139|445)([^0-9]|$)'
|
||||
}
|
||||
|
||||
# Warn when another managed share already exports the same path under a
|
||||
# different name (two names for one folder = confusing debug sessions).
|
||||
dup_path_share() {
|
||||
local path="$1" name="$2" dup
|
||||
dup="$(awk -v want="$path" -v cur="$name" '
|
||||
$1 == "#" && $2 == ">>>" { n = $5; inb = 1 }
|
||||
$0 == "# <<< end pos-managed share" { inb = 0 }
|
||||
inb && $1 == "path" && $3 == want && n != cur { print n; exit }
|
||||
' "$SMB_CONF")"
|
||||
[ -n "$dup" ] && warn "Path already shared as [$dup] — creating a second export of the same folder.
|
||||
Prefer updating that one: pos share smb-server share $path $dup"
|
||||
return 0
|
||||
}
|
||||
|
||||
list_shares() {
|
||||
local out
|
||||
out="$(testparm -s "$SMB_CONF" 2>/dev/null | grep -F '[' || true)"
|
||||
@@ -113,141 +135,307 @@ list_shares() {
|
||||
printf '%s\n' "$out" | grep -Fv '[global]' | sed 's/\[\(.*\)\]/ \1/'
|
||||
}
|
||||
|
||||
case "$cmd" in
|
||||
status)
|
||||
if systemctl is-active --quiet smbd 2>/dev/null; then
|
||||
ok "smbd: running"
|
||||
cmd_status() {
|
||||
if systemctl is-active --quiet smbd 2>/dev/null; then
|
||||
ok "smbd: running"
|
||||
else
|
||||
warn "smbd: not running (enable with 'pos share smb-server enable')"
|
||||
fi
|
||||
echo
|
||||
section "Shares"
|
||||
list_shares
|
||||
echo
|
||||
section "Users"
|
||||
sudo pdbedit -L 2>/dev/null | cut -d: -f1 | sed 's/^/ /' || echo " (none)"
|
||||
echo
|
||||
section "Ports"
|
||||
if ss -tln 2>/dev/null | awk '$4 ~ /:(139|445)$/ { f = 1 } END { exit !f }'; then
|
||||
ok "smbd listening on :139/:445"
|
||||
else
|
||||
warn "nothing listening on :139/:445 locally (smbd down or bound elsewhere)"
|
||||
fi
|
||||
if command -v ufw >/dev/null 2>&1 && sudo ufw status 2>/dev/null | grep -q "^Status: active"; then
|
||||
if ufw_blocks_samba; then
|
||||
warn "firewall: ufw active, NO Samba rule — clients can't connect. Fix: sudo ufw allow Samba"
|
||||
else
|
||||
warn "smbd: not running (enable with 'pos share smb-server enable')"
|
||||
ok "firewall: ufw active, Samba allowed"
|
||||
fi
|
||||
echo
|
||||
section "Shares"
|
||||
list_shares
|
||||
echo
|
||||
section "Users"
|
||||
sudo pdbedit -L 2>/dev/null | cut -d: -f1 | sed 's/^/ /' || echo " (none)"
|
||||
;;
|
||||
else
|
||||
ok "firewall: no active ufw — nothing blocking SMB ports"
|
||||
fi
|
||||
}
|
||||
|
||||
share)
|
||||
path="${2:-}"
|
||||
[ -n "$path" ] || err "Usage: pos share smb-server share <path> [name] [--read-only|--guest|--users u1,u2]"
|
||||
case "$path" in
|
||||
-*) err "Usage: pos share smb-server share <path> [name] [--read-only|--guest|--users u1,u2]" ;;
|
||||
cmd_share() {
|
||||
local path="$1"
|
||||
[ -n "$path" ] || err "Usage: pos share smb-server share <path> [name] [--read-only|--guest|--users u1,u2]"
|
||||
case "$path" in
|
||||
-*) err "Usage: pos share smb-server share <path> [name] [--read-only|--guest|--users u1,u2]" ;;
|
||||
esac
|
||||
require_root_dir "$path"
|
||||
check_traversal "$path"
|
||||
[ -f "$SMB_CONF" ] || err "No smb.conf at $SMB_CONF (is samba installed?)"
|
||||
|
||||
shift
|
||||
local name="" ro=0 guest=0 users=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--read-only) ro=1; shift ;;
|
||||
--guest) guest=1; shift ;;
|
||||
--users) [ $# -ge 2 ] || err "--users needs a value"; users="$2"; shift 2 ;;
|
||||
-*) err "unknown option '$1'" ;;
|
||||
*) name="$1"; shift ;;
|
||||
esac
|
||||
require_root_dir "$path"
|
||||
check_traversal "$path"
|
||||
[ -f "$SMB_CONF" ] || err "No smb.conf at $SMB_CONF (is samba installed?)"
|
||||
done
|
||||
name="${name:-$(basename "$path")}"
|
||||
validate_share_name "$name"
|
||||
dup_path_share "$path" "$name"
|
||||
|
||||
shift 2
|
||||
name="" ro=0 guest=0 users=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--read-only) ro=1; shift ;;
|
||||
--guest) guest=1; shift ;;
|
||||
--users) [ $# -ge 2 ] || err "--users needs a value"; users="$2"; shift 2 ;;
|
||||
-*) err "unknown option '$1'" ;;
|
||||
*) name="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
name="${name:-$(basename "$path")}"
|
||||
validate_share_name "$name"
|
||||
if [ "$guest" -eq 1 ]; then
|
||||
warn "guest access on — ANY network user can access $path. Restrict with --users."
|
||||
elif [ -z "$users" ]; then
|
||||
warn "No valid users — any Samba account can access $path. Restrict with --users u1,u2."
|
||||
fi
|
||||
[ -n "$users" ] && check_samba_users "$users"
|
||||
if ufw_blocks_samba; then
|
||||
warn "ufw is active but allows no Samba traffic — clients can't reach ports 139/445.
|
||||
Fix on this machine: sudo ufw allow Samba"
|
||||
fi
|
||||
|
||||
if [ "$guest" -eq 1 ]; then
|
||||
warn "guest access on — ANY network user can access $path. Restrict with --users."
|
||||
elif [ -z "$users" ]; then
|
||||
warn "No valid users — any Samba account can access $path. Restrict with --users u1,u2."
|
||||
fi
|
||||
[ -n "$users" ] && check_samba_users "$users"
|
||||
|
||||
ro_val=no; [ "$ro" -eq 1 ] && ro_val=yes
|
||||
guest_val=no; [ "$guest" -eq 1 ] && guest_val=yes
|
||||
block="[$name]
|
||||
local ro_val=no guest_val=no block tmp
|
||||
[ "$ro" -eq 1 ] && ro_val=yes
|
||||
[ "$guest" -eq 1 ] && guest_val=yes
|
||||
block="[$name]
|
||||
path = $path
|
||||
browseable = yes
|
||||
read only = $ro_val
|
||||
guest ok = $guest_val"
|
||||
[ -n "$users" ] && block="$block
|
||||
[ -n "$users" ] && block="$block
|
||||
valid users = $users"
|
||||
|
||||
tmp="$(mktemp)"
|
||||
awk -v s="# >>> pos-managed share: $name" -v e="# <<< end pos-managed share" '
|
||||
$0 == s {inblock=1}
|
||||
$0 == e && inblock == 1 {inblock=0; next}
|
||||
!inblock {print}
|
||||
' "$SMB_CONF" > "$tmp"
|
||||
{
|
||||
echo
|
||||
echo "# >>> pos-managed share: $name"
|
||||
printf '%s\n' "$block"
|
||||
echo "# <<< end pos-managed share"
|
||||
} >> "$tmp"
|
||||
tmp="$(mktemp)"
|
||||
awk -v s="# >>> pos-managed share: $name" -v e="# <<< end pos-managed share" '
|
||||
$0 == s {inblock=1}
|
||||
$0 == e && inblock == 1 {inblock=0; next}
|
||||
!inblock {print}
|
||||
' "$SMB_CONF" > "$tmp"
|
||||
{
|
||||
echo
|
||||
echo "# >>> pos-managed share: $name"
|
||||
printf '%s\n' "$block"
|
||||
echo "# <<< end pos-managed share"
|
||||
} >> "$tmp"
|
||||
|
||||
testparm -s "$tmp" >/dev/null || { rm -f "$tmp"; err "Invalid smb.conf — changes not applied (see testparm -s $SMB_CONF)"; }
|
||||
sudo cp "$tmp" "$SMB_CONF"
|
||||
rm -f "$tmp"
|
||||
reload_config
|
||||
log "Share added: [$name] → $path"
|
||||
notify_send "SMB share added: $name ($path)"
|
||||
testparm -s "$tmp" >/dev/null || { rm -f "$tmp"; err "Invalid smb.conf — changes not applied (see testparm -s $SMB_CONF)"; }
|
||||
sudo cp "$tmp" "$SMB_CONF"
|
||||
rm -f "$tmp"
|
||||
reload_config
|
||||
log "Share added: [$name] → $path"
|
||||
notify_send "SMB share added: $name ($path)"
|
||||
}
|
||||
|
||||
cmd_unshare() {
|
||||
local name="$1" tmp
|
||||
validate_share_name "$name"
|
||||
[ -f "$SMB_CONF" ] || err "No smb.conf at $SMB_CONF (is samba installed?)"
|
||||
|
||||
if ! grep -Fq "# >>> pos-managed share: $name" "$SMB_CONF"; then
|
||||
warn "No share '$name' found in $SMB_CONF"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tmp="$(mktemp)"
|
||||
awk -v s="# >>> pos-managed share: $name" -v e="# <<< end pos-managed share" '
|
||||
$0 == s {inblock=1}
|
||||
$0 == e && inblock == 1 {inblock=0; next}
|
||||
!inblock {print}
|
||||
' "$SMB_CONF" > "$tmp"
|
||||
sudo cp "$tmp" "$SMB_CONF"
|
||||
rm -f "$tmp"
|
||||
reload_config
|
||||
log "Removed share: $name"
|
||||
notify_send "SMB share removed: $name"
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
list_shares
|
||||
}
|
||||
|
||||
cmd_adduser() {
|
||||
local user="$1"
|
||||
[ -n "$user" ] || err "Usage: pos share smb-server adduser <user>"
|
||||
id -u "$user" >/dev/null 2>&1 || err "No system user '$user' — create it first (e.g. sudo adduser $user)"
|
||||
if sudo pdbedit -L 2>/dev/null | cut -d: -f1 | grep -qxF "$user"; then
|
||||
warn "'$user' already has a Samba account — nothing to do."
|
||||
log "Reset the password with: sudo smbpasswd $user"
|
||||
exit 0
|
||||
fi
|
||||
sudo smbpasswd -a "$user"
|
||||
log "Samba user added: $user"
|
||||
notify_send "SMB user added: $user"
|
||||
}
|
||||
|
||||
cmd_deluser() {
|
||||
local user="$1"
|
||||
[ -n "$user" ] || err "Usage: pos share smb-server deluser <user>"
|
||||
if ! sudo pdbedit -L 2>/dev/null | cut -d: -f1 | grep -qxF "$user"; then
|
||||
log "'$user' is not a Samba user — nothing to remove"
|
||||
exit 0
|
||||
fi
|
||||
sudo smbpasswd -x "$user"
|
||||
log "Samba user removed: $user"
|
||||
notify_send "SMB user removed: $user"
|
||||
}
|
||||
|
||||
cmd_reload() {
|
||||
reload_config
|
||||
}
|
||||
|
||||
cmd_enable() {
|
||||
sudo systemctl enable --now smbd
|
||||
log "smbd enabled (starts on boot)"
|
||||
notify_send "SMB server enabled"
|
||||
}
|
||||
|
||||
cmd_disable() {
|
||||
sudo systemctl disable --now smbd
|
||||
log "smbd disabled (will not start on boot)"
|
||||
notify_send "SMB server disabled"
|
||||
}
|
||||
|
||||
# ── Interactive menu flows ─────────────────────────────────────
|
||||
menu_pick_folder() { # stdout: folder path · rc 1 cancelled
|
||||
local idx dir cand
|
||||
local -a cands=()
|
||||
if mapfile -t cands < <(share_folder_candidates) && [ "${#cands[@]}" -gt 0 ]; then
|
||||
if idx="$(share_pick "Share which folder?" "${cands[@]}")"; then
|
||||
cand="${cands[$((idx - 1))]}"
|
||||
dir="${cand%% (*}" # strip "(mounted fstype)" annotation
|
||||
[ -d "$dir" ] || { warn "Folder vanished: $dir"; return 1; }
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
dir="$(share_ask_value "Folder to share (absolute path)")" || return 1
|
||||
[ -n "$dir" ] || { warn "No folder given"; return 1; }
|
||||
echo "$dir"
|
||||
}
|
||||
|
||||
menu_share() {
|
||||
local dir name users
|
||||
local -a args=()
|
||||
dir="$(menu_pick_folder)" || return 1
|
||||
name="$(share_ask_value "Share name" "$(basename "$dir")")" || return 1
|
||||
[ -n "$name" ] || name="$(basename "$dir")"
|
||||
|
||||
if confirm "Read-only share?" n; then
|
||||
args+=(--read-only)
|
||||
fi
|
||||
if confirm "Guest access (ANY network user, no login)?" n; then
|
||||
args+=(--guest)
|
||||
else
|
||||
users="$(share_ask_value "Restrict to Samba users (comma-separated, empty = any account)")" || return 1
|
||||
[ -n "$users" ] && args+=(--users "$users")
|
||||
fi
|
||||
cmd_share "$dir" "$name" ${args[@]+"${args[@]}"}
|
||||
}
|
||||
|
||||
menu_unshare() {
|
||||
local idx name
|
||||
local -a names=()
|
||||
if mapfile -t names < <(awk '/^# >>> pos-managed share: /{ sub(/^# >>> pos-managed share: /, ""); print }' "$SMB_CONF" 2>/dev/null) &&
|
||||
[ "${#names[@]}" -gt 0 ]; then
|
||||
idx="$(share_pick "Remove which share?" "${names[@]}")" || return 1
|
||||
name="${names[$((idx - 1))]}"
|
||||
else
|
||||
name="$(share_ask_value "Share name to remove")" || return 1
|
||||
[ -n "$name" ] || return 1
|
||||
fi
|
||||
cmd_unshare "$name"
|
||||
}
|
||||
|
||||
menu_adduser() {
|
||||
local user
|
||||
user="$(share_ask_value "System user to give a Samba account")" || return 1
|
||||
[ -n "$user" ] || return 1
|
||||
cmd_adduser "$user"
|
||||
}
|
||||
|
||||
menu_deluser() {
|
||||
local idx user
|
||||
local -a users=()
|
||||
if mapfile -t users < <(sudo pdbedit -L 2>/dev/null | cut -d: -f1) &&
|
||||
[ "${#users[@]}" -gt 0 ]; then
|
||||
idx="$(share_pick "Remove which Samba user?" "${users[@]}")" || return 1
|
||||
user="${users[$((idx - 1))]}"
|
||||
else
|
||||
user="$(share_ask_value "Samba user to remove")" || return 1
|
||||
[ -n "$user" ] || return 1
|
||||
fi
|
||||
cmd_deluser "$user"
|
||||
}
|
||||
|
||||
run_menu() {
|
||||
share_menu_guard || exit 1
|
||||
while true; do
|
||||
local choice
|
||||
choice="$(share_menu_run "Samba server" \
|
||||
"Show status (shares/users/ports/firewall)" \
|
||||
"Share a folder" \
|
||||
"Remove a share" \
|
||||
"List current shares" \
|
||||
"Add a Samba user" \
|
||||
"Remove a Samba user" \
|
||||
"Validate + reload config" \
|
||||
"Enable service on boot" \
|
||||
"Disable service")" || return 0
|
||||
case "$choice" in
|
||||
1) cmd_status ;;
|
||||
2) menu_share ;;
|
||||
3) menu_unshare ;;
|
||||
4) cmd_list ;;
|
||||
5) menu_adduser ;;
|
||||
6) menu_deluser ;;
|
||||
7) cmd_reload ;;
|
||||
8) cmd_enable ;;
|
||||
9) cmd_disable ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
case "$cmd" in
|
||||
""|menu)
|
||||
run_menu
|
||||
exit 0
|
||||
;;
|
||||
|
||||
status) cmd_status ;;
|
||||
|
||||
share)
|
||||
[ $# -ge 2 ] || err "Usage: pos share smb-server share <path> [name] [--read-only|--guest|--users u1,u2]"
|
||||
cmd_share "${@:2}"
|
||||
;;
|
||||
|
||||
unshare)
|
||||
name="${2:-}"
|
||||
[ -n "$name" ] || err "Usage: pos share smb-server unshare <name>"
|
||||
validate_share_name "$name"
|
||||
[ -f "$SMB_CONF" ] || err "No smb.conf at $SMB_CONF (is samba installed?)"
|
||||
|
||||
if ! grep -Fq "# >>> pos-managed share: $name" "$SMB_CONF"; then
|
||||
warn "No share '$name' found in $SMB_CONF"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tmp="$(mktemp)"
|
||||
awk -v s="# >>> pos-managed share: $name" -v e="# <<< end pos-managed share" '
|
||||
$0 == s {inblock=1}
|
||||
$0 == e && inblock == 1 {inblock=0; next}
|
||||
!inblock {print}
|
||||
' "$SMB_CONF" > "$tmp"
|
||||
sudo cp "$tmp" "$SMB_CONF"
|
||||
rm -f "$tmp"
|
||||
reload_config
|
||||
log "Removed share: $name"
|
||||
notify_send "SMB share removed: $name"
|
||||
[ $# -ge 2 ] || err "Usage: pos share smb-server unshare <name>"
|
||||
cmd_unshare "$2"
|
||||
;;
|
||||
|
||||
list)
|
||||
list_shares
|
||||
;;
|
||||
list) cmd_list ;;
|
||||
|
||||
adduser)
|
||||
user="${2:-}"
|
||||
[ -n "$user" ] || err "Usage: pos share smb-server adduser <user>"
|
||||
id -u "$user" >/dev/null 2>&1 || err "No system user '$user' — create it first (e.g. sudo adduser $user)"
|
||||
sudo smbpasswd -a "$user"
|
||||
log "Samba user added: $user"
|
||||
notify_send "SMB user added: $user"
|
||||
[ $# -ge 2 ] || err "Usage: pos share smb-server adduser <user>"
|
||||
cmd_adduser "$2"
|
||||
;;
|
||||
|
||||
deluser)
|
||||
user="${2:-}"
|
||||
[ -n "$user" ] || err "Usage: pos share smb-server deluser <user>"
|
||||
sudo smbpasswd -x "$user"
|
||||
log "Samba user removed: $user"
|
||||
notify_send "SMB user removed: $user"
|
||||
[ $# -ge 2 ] || err "Usage: pos share smb-server deluser <user>"
|
||||
cmd_deluser "$2"
|
||||
;;
|
||||
|
||||
reload)
|
||||
reload_config
|
||||
;;
|
||||
reload) cmd_reload ;;
|
||||
|
||||
enable)
|
||||
sudo systemctl enable --now smbd
|
||||
log "smbd enabled (starts on boot)"
|
||||
notify_send "SMB server enabled"
|
||||
;;
|
||||
enable) cmd_enable ;;
|
||||
|
||||
disable)
|
||||
sudo systemctl disable --now smbd
|
||||
log "smbd disabled (will not start on boot)"
|
||||
notify_send "SMB server disabled"
|
||||
;;
|
||||
disable) cmd_disable ;;
|
||||
esac
|
||||
|
||||
+147
-3
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: share usb-server — USB Redirector server control (--ls, --share; prompts when args omitted)
|
||||
# POS_FLAGS: --ls --ls-shared --share --unshare --auto-share --callback --close-callback --auto-connect --disconnect --nickname --timeout --port --info --version
|
||||
# POS_FLAGS: --ls --ls-shared --share --unshare --auto-share --callback --close-callback --auto-connect --disconnect --nickname --timeout --port --info --version menu
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
source "$(dirname "$0")/../lib/share-lib.sh" 2>/dev/null || source "$(dirname "$0")/share-lib.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -188,18 +189,161 @@ cmd_port() {
|
||||
command -v usbsrv &>/dev/null \
|
||||
|| err "usbsrv not found — install the USB Redirector server (https://www.incentivespro.com/usb-server.html)"
|
||||
|
||||
# ── Interactive menu flows ─────────────────────────────────────
|
||||
# Picker-first prompting: parsed listings feed the filtered picker; when the
|
||||
# server is unreachable or nothing parses, fall back to the same raw-listing +
|
||||
# manual-entry prompts the explicit commands have always used.
|
||||
menu_pick_from_records() { # <prompt> <records-cmd...> — stdout: chosen ID · rc 1 cancel/fallback
|
||||
local prompt="$1"; shift
|
||||
local idx rec
|
||||
local -a ids=()
|
||||
if recs="$("$@")" && [ -n "$recs" ]; then
|
||||
while IFS= read -r rec; do
|
||||
[ -n "$rec" ] || continue
|
||||
ids+=("${rec%%|*}")
|
||||
done <<<"$recs"
|
||||
if [ "${#ids[@]}" -gt 0 ]; then
|
||||
if idx="$(share_pick "$prompt" "${ids[@]}")"; then
|
||||
echo "${ids[$((idx - 1))]}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
menu_share() {
|
||||
local dev client
|
||||
if ! dev="$(menu_pick_from_records "Share which USB device?" share_usb_devices)"; then
|
||||
cmd_ls >/dev/null 2>&1 || true
|
||||
read -rp "Enter device ID to share: " dev
|
||||
[ -n "$dev" ] || { warn "Cancelled"; return 1; }
|
||||
fi
|
||||
if ! client="$(menu_pick_from_records "Connect to which client?" share_usb_clients)"; then
|
||||
read -rp "Enter client ID to connect to: " client
|
||||
[ -n "$client" ] || { warn "Cancelled"; return 1; }
|
||||
fi
|
||||
cmd_share "$dev" "$client"
|
||||
}
|
||||
|
||||
menu_unshare() {
|
||||
local dev
|
||||
if ! dev="$(menu_pick_from_records "Stop sharing which device?" share_usb_devices)"; then
|
||||
cmd_ls_shared >/dev/null 2>&1 || true
|
||||
read -rp "Enter device ID to unshare: " dev
|
||||
[ -n "$dev" ] || { warn "Cancelled"; return 1; }
|
||||
fi
|
||||
cmd_unshare "$dev"
|
||||
}
|
||||
|
||||
menu_callback() {
|
||||
local addr
|
||||
addr="$(share_ask_value "Client address:port for callback (e.g. 192.168.1.100:32032)")" || return 1
|
||||
[ -n "$addr" ] || { warn "Cancelled"; return 1; }
|
||||
cmd_callback "$addr"
|
||||
}
|
||||
|
||||
menu_close_callback() {
|
||||
local target
|
||||
if ! target="$(menu_pick_from_records "Close callback of which client?" share_usb_clients)"; then
|
||||
target="$(share_ask_value "Client, client id or 'all' to close callback")" || return 1
|
||||
[ -n "$target" ] || { warn "Cancelled"; return 1; }
|
||||
fi
|
||||
cmd_close_callback "$target"
|
||||
}
|
||||
|
||||
menu_auto_connect() {
|
||||
local mode client
|
||||
mode="$(share_ask_value "Remote auto-connect on or off?" "on")" || return 1
|
||||
case "$mode" in on|off) ;; *) warn "must be on/off"; return 1 ;; esac
|
||||
if ! client="$(menu_pick_from_records "Toggle auto-connect for which client?" share_usb_clients)"; then
|
||||
client="$(share_ask_value "Client or client id")" || return 1
|
||||
[ -n "$client" ] || { warn "Cancelled"; return 1; }
|
||||
fi
|
||||
cmd_auto_connect "$mode" "$client"
|
||||
}
|
||||
|
||||
menu_disconnect() {
|
||||
local dev
|
||||
if ! dev="$(menu_pick_from_records "Disconnect which device?" share_usb_devices)"; then
|
||||
dev="$(share_ask_value "Device ID or 'all' to disconnect from clients")" || return 1
|
||||
[ -n "$dev" ] || { warn "Cancelled"; return 1; }
|
||||
fi
|
||||
cmd_disconnect "$dev"
|
||||
}
|
||||
|
||||
menu_nickname() {
|
||||
local dev nick
|
||||
if ! dev="$(menu_pick_from_records "Nickname which device?" share_usb_devices)"; then
|
||||
dev="$(share_ask_value "Device ID")" || return 1
|
||||
[ -n "$dev" ] || { warn "Cancelled"; return 1; }
|
||||
fi
|
||||
nick="$(share_ask_value "Nickname (empty removes it)")" || return 1
|
||||
cmd_nickname "$dev" "$nick"
|
||||
}
|
||||
|
||||
menu_timeout() {
|
||||
local dev sec
|
||||
if ! dev="$(menu_pick_from_records "Set inactivity timeout for which device?" share_usb_devices)"; then
|
||||
dev="$(share_ask_value "Device ID")" || return 1
|
||||
[ -n "$dev" ] || { warn "Cancelled"; return 1; }
|
||||
fi
|
||||
sec="$(share_ask_value "Timeout in seconds (0 disables)" "0")" || return 1
|
||||
cmd_timeout "$dev" "$sec"
|
||||
}
|
||||
|
||||
menu_port() {
|
||||
local port
|
||||
port="$(share_ask_value "New TCP port")" || return 1
|
||||
[ -n "$port" ] || { warn "Cancelled"; return 1; }
|
||||
cmd_port "$port"
|
||||
}
|
||||
|
||||
run_menu() {
|
||||
share_menu_guard || exit 1
|
||||
while true; do
|
||||
local choice
|
||||
choice="$(share_menu_run "USB Redirector server" \
|
||||
"List host devices and connected clients" \
|
||||
"Share a device with a client" \
|
||||
"Stop sharing a device" \
|
||||
"Disconnect device(s) from clients" \
|
||||
"Auto-share on/off" \
|
||||
"Create a callback connection" \
|
||||
"Close a client callback" \
|
||||
"Client remote auto-connect on/off" \
|
||||
"Set a device nickname" \
|
||||
"Set a device inactivity timeout" \
|
||||
"Set the TCP port")" || return 0
|
||||
case "$choice" in
|
||||
1) cmd_ls ;;
|
||||
2) menu_share ;;
|
||||
3) menu_unshare ;;
|
||||
4) menu_disconnect ;;
|
||||
5) cmd_auto_share ;;
|
||||
6) menu_callback ;;
|
||||
7) menu_close_callback ;;
|
||||
8) menu_auto_connect ;;
|
||||
9) menu_nickname ;;
|
||||
10) menu_timeout ;;
|
||||
11) menu_port ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
cmd="${1:-}"
|
||||
|
||||
case "$cmd" in
|
||||
-h|--help|"") usage ;;
|
||||
-h|--help) usage ;;
|
||||
esac
|
||||
|
||||
case "$cmd" in
|
||||
--ls|--ls-shared|--share|--unshare|--auto-share|--callback|--close-callback|--auto-connect|--disconnect|--nickname|--timeout|--port|--info|--version) ;;
|
||||
""|menu|--ls|--ls-shared|--share|--unshare|--auto-share|--callback|--close-callback|--auto-connect|--disconnect|--nickname|--timeout|--port|--info|--version) ;;
|
||||
*) err "Unknown flag '$cmd'" ;;
|
||||
esac
|
||||
|
||||
case "$cmd" in
|
||||
""|menu) run_menu ;;
|
||||
--ls) cmd_ls ;;
|
||||
--ls-shared) cmd_ls_shared ;;
|
||||
--share) shift; cmd_share "$@" ;;
|
||||
|
||||
+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
|
||||
|
||||
+20
-8
@@ -3,35 +3,47 @@
|
||||
# Install: source this file in ~/.bashrc or place in /etc/bash_completion.d/
|
||||
# GEN:START posflags
|
||||
declare -A _pos_flags
|
||||
_pos_flags[ai-gemini]="--model --session --system"
|
||||
_pos_flags[ai-gemini]="--model --session --system --full --last"
|
||||
_pos_flags[ai-openrouter]="--model --session --system --full --last"
|
||||
_pos_flags[communication-matrix-listener]="--enable --disable --status --run"
|
||||
_pos_flags[communication-telegram-listener]="--enable --disable --status --sync-commands --run"
|
||||
_pos_flags[communication-telegram-sender]="--type --caption --parse-mode --no-preview --token --chat-id --markdown"
|
||||
_pos_flags[docker-stack]="-a --all"
|
||||
_pos_flags[docker-vbox]="--dir --gpu --device --port --cpus --memory --network"
|
||||
_pos_flags[entertainment-send]="--print --markdown"
|
||||
_pos_flags[media-mp3]="--output --no-playlist --cookies --by-artist --dry-run"
|
||||
_pos_flags[media-mp4]="--format --best --worst --output --no-playlist --cookies --dry-run"
|
||||
_pos_flags[media-sync]="--mp3 --mp4 --source --dry-run"
|
||||
_pos_flags[media-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"
|
||||
# GEN:END posflags
|
||||
# GEN:START possubcmds
|
||||
declare -A _pos_subcmds
|
||||
_pos_subcmds[ai-gemini]="ask chat models sessions"
|
||||
_pos_subcmds[ai-gemini]="ask capture chat models sessions"
|
||||
_pos_subcmds[ai-openrouter]="ask capture 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[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[system-schedule]="run list config enable disable status migrate"
|
||||
_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 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)
|
||||
declare -a _pos_config_scopes=(ai ai-openrouter compose entertainment matrix notify scrcpy system telegram ytsync)
|
||||
# GEN:END posconfigscopes
|
||||
|
||||
_pos() {
|
||||
|
||||
+1
-1
@@ -140,7 +140,7 @@ if should_run 2 scripts; then
|
||||
done
|
||||
lib_count=0
|
||||
lib_names=""
|
||||
for lf in common.sh flags.sh notify.sh entertainment-lib.sh scheduler-lib.sh config-ui.sh user-timers-lib.sh entertainment-plugin-lib.sh usb-lib.sh; do
|
||||
for lf in common.sh flags.sh notify.sh entertainment-lib.sh scheduler-lib.sh config-ui.sh user-timers-lib.sh entertainment-plugin-lib.sh usb-lib.sh share-lib.sh menu-lib.sh; do
|
||||
run sudo install -m 644 "lib/$lf" "/usr/local/bin/$lf"
|
||||
lib_count=$((lib_count + 1))
|
||||
lib_names+="$lf "
|
||||
|
||||
+18
-7
@@ -117,15 +117,26 @@ spawn() {
|
||||
}
|
||||
|
||||
# ── Confirmation prompt ────────────────────────────────────────
|
||||
# confirm <prompt> [default] — Enter accepts the DISPLAYED DEFAULT ('y'
|
||||
# when omitted); explicit y/Y or n/N overrides; anything else (invalid
|
||||
# input, EOF/closed stdin) denies. EOF fails closed and rc-safely ($yn is
|
||||
# pre-initialized, so no set -u surprise on shells where read leaves it
|
||||
# unset). Destructive call sites pass explicit 'n'.
|
||||
confirm() {
|
||||
local prompt="$1" default="${2:-y}" yn
|
||||
if [ "$default" = "y" ]; then
|
||||
read -rp "${prompt} [Y/n]: " yn
|
||||
[[ -z "$yn" || "$yn" =~ ^[Yy] ]]
|
||||
else
|
||||
read -rp "${prompt} [y/N]: " yn
|
||||
[[ "$yn" =~ ^[Yy] ]]
|
||||
local prompt="$1" default="${2:-y}" hint="[y/N]" yn=""
|
||||
local d="${default,,}"
|
||||
if [ "$d" = "y" ]; then
|
||||
hint="[Y/n]"
|
||||
fi
|
||||
if ! read -rp "${prompt} ${hint}: " yn; then
|
||||
return 1 # EOF / closed stdin — deny
|
||||
fi
|
||||
case "$yn" in
|
||||
[Yy]) return 0 ;;
|
||||
[Nn]) return 1 ;;
|
||||
"") [ "$d" = "y" ] ;; # Enter → the displayed default
|
||||
*) return 1 ;; # invalid input — deny
|
||||
esac
|
||||
}
|
||||
|
||||
# ── system.env loader ──────────────────────────────────────────
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
# lib/menu-lib.sh — category-neutral interactive menu primitives.
|
||||
#
|
||||
# The generic half of the former share-lib interactive layer (Pattern B),
|
||||
# extracted so any `pos` tool can share one interaction vocabulary: a tty
|
||||
# guard, a looping boxed menu, a type-to-filter picker and a prompt with
|
||||
# optional default. Display goes to stderr, results to stdout; reads are
|
||||
# stdin-based and fail closed (EOF / no terminal → rc 1, never a hang),
|
||||
# so the functions are safe under the dispatcher's logging tee and inside
|
||||
# command substitution.
|
||||
#
|
||||
# Contracts (all of them, no exceptions):
|
||||
# * Defines ONLY `menu_*` functions — sourcing never clobbers a tool's own
|
||||
# helpers (same discipline as lib/notify.sh).
|
||||
# * Requires common.sh to be sourced by the CALLER for colored output;
|
||||
# CYAN/RESET get empty guarded fallbacks here so the lib also works
|
||||
# standalone-sourced (plain text instead of color — never an error).
|
||||
# * NEVER exits and never terminates the caller: every function returns,
|
||||
# failures are signalled through the return code.
|
||||
# * Display goes to stderr, results go to stdout — any function whose result
|
||||
# is meant to be command-substituted prints ONLY the result on stdout.
|
||||
# * Performs NO file writes of its own.
|
||||
#
|
||||
# Function index:
|
||||
# menu_guard rc 0 iff stdin is a terminal
|
||||
# menu_run <title> <item...> numbered menu loop → chosen index
|
||||
# menu_pick <prompt> <item...> type-to-filter picker → chosen index
|
||||
# menu_ask_value <label> [default] prompted value → entered text
|
||||
|
||||
# ── Colors (guarded fallbacks; a sourced common.sh wins) ──────
|
||||
CYAN="${CYAN:-}"
|
||||
RESET="${RESET:-}"
|
||||
|
||||
# ── Terminal guard ────────────────────────────────────────────
|
||||
# rc 0 iff stdin is a tty · rc 1 otherwise, with a one-line pointer to the
|
||||
# scriptable subcommands. Tools that must fail hard without a TTY call this
|
||||
# before entering the loop.
|
||||
menu_guard() {
|
||||
if [ -t 0 ]; then
|
||||
return 0
|
||||
fi
|
||||
printf '[!] Interactive menu needs a terminal — use a subcommand instead (see --help).\n' >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Numbered menu loop (firewall-precedent style) ──────────────
|
||||
# Renders a section-box title + `%2d)` items + separator to stderr and reads
|
||||
# `Choose: `. stdout carries the chosen index ONLY.
|
||||
# rc 0 valid pick (index on stdout)
|
||||
# rc 1 quit (`0`/`q`/`Q`), EOF, or no terminal — callers treat this as a
|
||||
# clean menu exit (tools that must fail hard without a TTY call
|
||||
# menu_guard themselves before entering the loop).
|
||||
menu_run() {
|
||||
local title="$1"; shift
|
||||
local -a items=("$@")
|
||||
if ! menu_guard; then
|
||||
return 1
|
||||
fi
|
||||
local opt i
|
||||
while true; do
|
||||
{
|
||||
echo
|
||||
echo "${CYAN}════════════════════════════════════════════${RESET}"
|
||||
echo "${CYAN} ${title}${RESET}"
|
||||
echo "${CYAN}════════════════════════════════════════════${RESET}"
|
||||
for ((i = 0; i < ${#items[@]}; i++)); do
|
||||
printf ' %2d) %s\n' $((i + 1)) "${items[$i]}"
|
||||
done
|
||||
printf ' %2d) %s\n' 0 "Exit"
|
||||
echo "----------------------------------------"
|
||||
} >&2
|
||||
if ! read -rp "Choose: " opt; then
|
||||
return 1 # EOF — clean menu exit
|
||||
fi
|
||||
case "$opt" in
|
||||
"") ;; # empty input → redraw
|
||||
0 | q | Q) return 1 ;;
|
||||
*)
|
||||
if [[ "$opt" =~ ^[0-9]+$ ]] && (( opt >= 1 && opt <= ${#items[@]} )); then
|
||||
echo "$opt"
|
||||
return 0
|
||||
fi
|
||||
echo "Unknown choice." >&2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# ── Type-to-filter picker ──────────────────────────────────────
|
||||
# Lists items on stderr; numeric choice → index (into the FULL item list) on
|
||||
# stdout; non-numeric input filters case-insensitively and redisplays with a
|
||||
# "-- N of M match 'text' --" banner; empty or `/` while filtered clears back
|
||||
# to the full list; zero matches warn and redisplay.
|
||||
# rc 0 picked · rc 1 back/cancel (`0`/`q`/`b`, EOF) — never out of bounds.
|
||||
menu_pick() {
|
||||
local prompt="${1:-Pick}"; shift
|
||||
local -a items=("$@")
|
||||
if [ "${#items[@]}" -eq 0 ]; then
|
||||
return 1
|
||||
fi
|
||||
if ! [ -t 0 ]; then
|
||||
printf '[!] Interactive picker needs a terminal.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
local filter="" ans i n total=${#items[@]}
|
||||
local -a shown=() orig=()
|
||||
while true; do
|
||||
shown=()
|
||||
orig=()
|
||||
for ((i = 0; i < total; i++)); do
|
||||
if [ -z "$filter" ] || [[ "${items[$i],,}" == *"${filter,,}"* ]]; then
|
||||
shown+=("${items[$i]}")
|
||||
orig+=("$((i + 1))")
|
||||
fi
|
||||
done
|
||||
n=${#shown[@]}
|
||||
{
|
||||
echo
|
||||
if [ -n "$filter" ]; then
|
||||
printf -- "-- %d of %d match '%s' --\n" "$n" "$total" "$filter"
|
||||
else
|
||||
printf -- "-- %d available --\n" "$total"
|
||||
fi
|
||||
if [ "$n" -eq 0 ]; then
|
||||
printf '[!] no matches — enter nothing or / to clear the filter\n' >&2
|
||||
else
|
||||
for ((i = 0; i < n; i++)); do
|
||||
printf ' %2d) %s\n' $((i + 1)) "${shown[$i]}"
|
||||
done
|
||||
fi
|
||||
} >&2
|
||||
if ! read -rp "${prompt} [1-${n}], text=filter, 0=back " ans; then
|
||||
return 1 # EOF — cancel
|
||||
fi
|
||||
case "$ans" in
|
||||
"") [ -z "$filter" ] || filter="" ; continue ;;
|
||||
"/") filter="" ; continue ;;
|
||||
0 | q | Q | b | B) return 1 ;;
|
||||
*[!0-9]*)
|
||||
filter="$ans"
|
||||
continue
|
||||
;;
|
||||
*)
|
||||
if (( ans >= 1 && ans <= n )); then
|
||||
echo "${orig[$((ans - 1))]}"
|
||||
return 0
|
||||
fi
|
||||
echo "Unknown choice." >&2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# ── Prompted value with optional default ───────────────────────
|
||||
# Prints "<label> [<default>]: " (read -p sends prompts to stderr) and echoes
|
||||
# the entered value or the default when the answer is empty.
|
||||
# rc 0 value on stdout · rc 1 EOF, or empty answer with no default.
|
||||
menu_ask_value() {
|
||||
local label="$1" def="${2:-}" val pr="$1"
|
||||
[ -n "$def" ] && pr="$pr [$def]"
|
||||
if ! read -rp "${pr}: " val; then
|
||||
return 1 # EOF — cancel
|
||||
fi
|
||||
if [ -z "$val" ]; then
|
||||
[ -n "$def" ] || return 1
|
||||
echo "$def"
|
||||
return 0
|
||||
fi
|
||||
echo "$val"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Optional shell hook for pos ai * --last: auto-captures terminal output.
|
||||
# Usage: add to ~/.bashrc:
|
||||
# source /usr/local/bin/pos-ai-hook.sh
|
||||
# — or —
|
||||
# source /path/to/Linux_post_install/lib/pos-ai-hook.sh
|
||||
#
|
||||
# After sourcing, every command's stdout+stderr is silently tee'd to
|
||||
# ~/.local/share/linux_post_install/last_cmd_output (truncated at 1 MB).
|
||||
# Then pos ai gemini ask --last / pos ai openrouter ask --last will
|
||||
# pick it up automatically — no 'capture' subcommand needed.
|
||||
# To disable: unset __POS_CAPTURE_ACTIVE
|
||||
|
||||
__POS_CAPTURE_FILE="${HOME}/.local/share/linux_post_install/last_cmd_output"
|
||||
__POS_CAPTURE_MAX=${__POS_CAPTURE_MAX:-1048576} # 1 MB, override with env
|
||||
|
||||
# Truncate if oversized (keep last half)
|
||||
if [ -f "$__POS_CAPTURE_FILE" ]; then
|
||||
__sz=$(stat -c%s "$__POS_CAPTURE_FILE" 2>/dev/null || echo 0)
|
||||
if [ "$__sz" -gt "$__POS_CAPTURE_MAX" ]; then
|
||||
tail -c $((__POS_CAPTURE_MAX / 2)) "$__POS_CAPTURE_FILE" > "${__POS_CAPTURE_FILE}.tmp" 2>/dev/null
|
||||
mv -- "${__POS_CAPTURE_FILE}.tmp" "$__POS_CAPTURE_FILE"
|
||||
fi
|
||||
else
|
||||
: > "$__POS_CAPTURE_FILE"
|
||||
fi
|
||||
|
||||
# Only activate in interactive terminals, not already redirected
|
||||
if [ -t 1 ] && [ -t 2 ] && [ -z "${__POS_CAPTURE_ACTIVE:-}" ]; then
|
||||
export __POS_CAPTURE_ACTIVE=1
|
||||
exec > >(tee -a "$__POS_CAPTURE_FILE" 2>&1) 2>&1
|
||||
fi
|
||||
@@ -0,0 +1,318 @@
|
||||
# lib/share-lib.sh — precondition probes, remote listings and advisories for
|
||||
# the `pos share` suite (nfs/smb/usb tools), plus compat shims to the
|
||||
# category-neutral menu layer (lib/menu-lib.sh).
|
||||
#
|
||||
# Contracts (all of them, no exceptions):
|
||||
# * Defines ONLY `share_*` functions — sourcing never clobbers a tool's own
|
||||
# helpers (same discipline as lib/notify.sh).
|
||||
# * Requires common.sh to be sourced by the CALLER first (log/warn/err/
|
||||
# confirm/run/spawn + CYAN/RESET colors are used, never defined here).
|
||||
# * NEVER exits and never terminates the caller: every function returns,
|
||||
# failures are signalled through the return code.
|
||||
# * Display goes to stderr, results go to stdout — any function whose result
|
||||
# is meant to be command-substituted prints ONLY the result on stdout
|
||||
# (matches the ui_pick contract in the telegram/matrix listeners).
|
||||
# * Performs NO file writes of its own — every path a tool persists stays
|
||||
# tool-owned (the env-seam surface does not grow here).
|
||||
#
|
||||
# Function index:
|
||||
# share_menu_run <title> <item...> shim → menu_run (lib/menu-lib.sh)
|
||||
# share_pick <prompt> <item...> shim → menu_pick (lib/menu-lib.sh)
|
||||
# share_ask_value <label> [default] shim → menu_ask_value (lib/menu-lib.sh)
|
||||
# share_menu_guard shim → menu_guard (lib/menu-lib.sh)
|
||||
# share_require_bin <bin> <hint> dependency probe (rc only)
|
||||
# share_port_probe <host> <port> TCP reachability probe (rc only)
|
||||
# share_service_active <unit> systemd unit state probe (rc only)
|
||||
# share_ufw_blocks_ports <egrep> ufw-blocking decision (rc only)
|
||||
# share_offer_fix <desc> <cmd...> advisory remediation offer (rc always 0)
|
||||
# share_nfs_exports <host> remote export list via showmount
|
||||
# share_smb_shares <host> [user] remote Disk-share list via smbclient
|
||||
# share_usb_devices / _clients usbsrv listing records as "ID|display"
|
||||
# share_folder_candidates mountpoint/dir candidates for sharing
|
||||
|
||||
# ── Menu primitives live in lib/menu-lib.sh (category-neutral) ─
|
||||
# The generic interactive layer was extracted there; these thin shims keep the
|
||||
# public `share_*` names/contracts identical for all five share tools. Delegation
|
||||
# preserves rc semantics 1:1 (guard rc, EOF → rc 1, index/value → stdout only).
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/../lib/menu-lib.sh" 2>/dev/null \
|
||||
|| source "$(dirname "${BASH_SOURCE[0]}")/menu-lib.sh" 2>/dev/null \
|
||||
|| source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null \
|
||||
|| source "$(dirname "$0")/menu-lib.sh"
|
||||
|
||||
share_menu_guard() { menu_guard "$@"; }
|
||||
share_menu_run() { menu_run "$@"; }
|
||||
share_pick() { menu_pick "$@"; }
|
||||
share_ask_value() { menu_ask_value "$@"; }
|
||||
|
||||
# ── Dependency probe (NOT an err wrapper) ──────────────────────
|
||||
# rc 0 present · rc 1 absent. Callers decide between err() and graceful
|
||||
# degradation; <hint> documents intent at call sites and is intentionally
|
||||
# not printed here (message policy belongs to the caller).
|
||||
share_require_bin() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# ── TCP reachability probe (generalized probe_server core) ─────
|
||||
# rc 0 reachable within 3s · rc 1 unreachable/no-route. Message policy (targeted
|
||||
# hints, firewall wording) belongs to the caller.
|
||||
share_port_probe() {
|
||||
timeout 3 bash -c "exec 3<>/dev/tcp/${1}/${2}" 2>/dev/null
|
||||
}
|
||||
|
||||
# ── systemd unit state probe ───────────────────────────────────
|
||||
# rc 0 active · rc 1 inactive/unqueryable (nonzero systemctl codes normalized).
|
||||
share_service_active() {
|
||||
if systemctl is-active --quiet "$1" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Bounded path probe ─────────────────────────────────────────
|
||||
# share_path_probe <-d|-w> <path> — rc 0 when the stat answers within 2s.
|
||||
# Guards discovery against wedged network mountpoints where a plain
|
||||
# `[ -d … ]` would block forever.
|
||||
share_path_probe() {
|
||||
timeout 2 bash -c '[ "$1" "$2" ]' _ "$1" "$2" 2>/dev/null
|
||||
}
|
||||
|
||||
# ── ufw blocking decision ──────────────────────────────────────
|
||||
# Core moved verbatim from pos-share-smb-server's ufw_blocks_samba, with the
|
||||
# rule pattern parameterized. rc 0 = ufw active but no matching rule (traffic
|
||||
# blocked) · rc 1 = ufw absent/inactive OR a matching rule exists (not blocking).
|
||||
# Callers own all message text.
|
||||
share_ufw_blocks_ports() {
|
||||
command -v ufw >/dev/null 2>&1 || return 1
|
||||
local st
|
||||
st="$(sudo ufw status 2>/dev/null)" || return 1
|
||||
grep -q "^Status: active" <<<"$st" || return 1
|
||||
if grep -qiE "$1" <<<"$st"; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Advisory remediation offer ─────────────────────────────────
|
||||
# On a terminal: confirm "<desc>. Fix it now?" (default No) and run/spawn the
|
||||
# command, reporting the outcome. Without a terminal: print desc + manual
|
||||
# command as a hint-only warning. ALWAYS rc 0 — purely advisory; aborting on a
|
||||
# declined offer stays the caller's confirm/err decision.
|
||||
share_offer_fix() {
|
||||
local desc="$1"; shift
|
||||
if [ -t 0 ]; then
|
||||
echo >&2
|
||||
if confirm "$desc. Fix it now?" n; then
|
||||
if run "$@"; then
|
||||
ok "Fixed: $*"
|
||||
else
|
||||
warn "Fix command failed: $*"
|
||||
fi
|
||||
else
|
||||
warn "Declined — run manually: $*"
|
||||
fi
|
||||
else
|
||||
warn "$desc — run manually: $*"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Remote NFS export enumeration ──────────────────────────────
|
||||
# stdout: export paths, one per row (column 1 of the showmount table, header
|
||||
# skipped). rc 0 ok · rc 1 unavailable (showmount missing / timeout / RPC
|
||||
# failure / no exports) with the reason warned on stderr.
|
||||
share_nfs_exports() {
|
||||
local host="$1" out rows
|
||||
if ! command -v showmount >/dev/null 2>&1; then
|
||||
printf '[!] showmount not found (install nfs-common) — cannot list exports from %s\n' "$host" >&2
|
||||
return 1
|
||||
fi
|
||||
if ! out="$(timeout 5 showmount -e "$host" 2>&1)"; then
|
||||
printf "[!] could not list exports from %s (server down, RPC/firewall blocked, or timeout)\n" "$host" >&2
|
||||
return 1
|
||||
fi
|
||||
rows="$(awk 'NF > 0 && $1 !~ /^Export/ {print $1}' <<<"$out")"
|
||||
if [ -z "$rows" ]; then
|
||||
printf "[!] no exports visible on %s\n" "$host" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$rows"
|
||||
}
|
||||
|
||||
# ── Remote SMB share enumeration ───────────────────────────────
|
||||
# stdout: Disk share names, one per line (-g parse; IPC$/printer `*$` names
|
||||
# dropped). Sets SMB_AUTH_USER="" at entry, and to the account that ended up
|
||||
# authenticating, so callers can reuse it for the actual mount:
|
||||
# * user argument given → authenticate immediately (password prompted,
|
||||
# travels via the PASSWD environment, never argv); no guest attempt.
|
||||
# * no user argument → guest query first; on ACCESS_DENIED /
|
||||
# LOGON_FAILURE a Samba user + password are asked once (TTY required)
|
||||
# and the query retries.
|
||||
# rc 0 ok · rc 1 unavailable / failed-after-retry (reason warned on stderr).
|
||||
share_smb_shares() {
|
||||
local host="$1" user="${2:-}" out names pw u
|
||||
local -r GPARSE='BEGIN { FS = "|" } $1 == "Disk" && $2 != "" && $2 != "IPC$" && $2 !~ /\$$/ { print $2 }'
|
||||
SMB_AUTH_USER=""
|
||||
if ! command -v smbclient >/dev/null 2>&1; then
|
||||
printf '[!] smbclient not found — cannot enumerate shares (install the smbclient package)\n' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -n "$user" ]; then
|
||||
if ! [ -t 0 ]; then
|
||||
printf '[!] Samba login for %s needs a terminal (password prompt)\n' "$host" >&2
|
||||
return 1
|
||||
fi
|
||||
read -rsp "Samba password for $user: " pw || { echo >&2; return 1; }
|
||||
echo >&2
|
||||
if out="$(PASSWD="$pw" smbclient -L "//${host}/" -g -t 5 -U "$user" 2>&1)"; then
|
||||
SMB_AUTH_USER="$user"
|
||||
else
|
||||
printf '[!] share enumeration failed for %s@%s (wrong user/password?)\n' "$user" "$host" >&2
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
if ! out="$(smbclient -L "//${host}/" -N -g -t 5 2>&1)"; then
|
||||
case "$out" in
|
||||
*ACCESS_DENIED*|*LOGON_FAILURE*|*NOT_GRANTED*)
|
||||
if ! [ -t 0 ]; then
|
||||
printf '[!] %s requires authentication — rerun interactively (menu) to enter a Samba user\n' "$host" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '[!] %s rejected guest access — a Samba login is required\n' "$host" >&2
|
||||
u="$(share_ask_value "Samba user for ${host}")" || return 1
|
||||
read -rsp "Samba password for $u: " pw || { echo >&2; return 1; }
|
||||
echo >&2
|
||||
if ! out="$(PASSWD="$pw" smbclient -L "//${host}/" -g -t 5 -U "$u" 2>&1)"; then
|
||||
printf '[!] share enumeration failed for %s@%s after retry\n' "$u" "$host" >&2
|
||||
return 1
|
||||
fi
|
||||
SMB_AUTH_USER="$u"
|
||||
;;
|
||||
*)
|
||||
printf '[!] could not enumerate shares on %s: %s\n' "$host" "$(head -1 <<<"$out")" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
names="$(printf '%s\n' "$out" | awk "$GPARSE")"
|
||||
if [ -z "$names" ]; then
|
||||
printf '[!] no Disk shares visible on %s\n' "$host" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$names"
|
||||
}
|
||||
|
||||
# ── USB Redirector listings ────────────────────────────────────
|
||||
# Both emit blank-line-split records as "ID|display-line" rows; rc 0 parsed ≥1
|
||||
# record · rc 1 unparsable/down — callers fall back to the raw listing plus a
|
||||
# manual ID entry (= today's UX).
|
||||
share_usb_records() { # internal helper: stdin = raw listing, $1 = ID-line regex
|
||||
awk -v idre="$1" '
|
||||
BEGIN { RS = "" }
|
||||
{
|
||||
n = split($0, L, "\n")
|
||||
id = ""; desc = ""; fb = ""
|
||||
for (i = 1; i <= n; i++) {
|
||||
line = L[i]
|
||||
if (line ~ idre && id == "") {
|
||||
id = line
|
||||
sub(/^[^:]*:[ \t]*/, "", id)
|
||||
} else if (line ~ /^Description:/) {
|
||||
desc = line
|
||||
sub(/^Description:[ \t]*/, "", desc)
|
||||
} else if (fb == "") {
|
||||
fb = line
|
||||
}
|
||||
}
|
||||
if (id != "") print id "|" (desc != "" ? desc : fb)
|
||||
}'
|
||||
}
|
||||
|
||||
share_usb_devices() {
|
||||
local raw recs
|
||||
if ! command -v usbsrv >/dev/null 2>&1; then
|
||||
printf '[!] usbsrv not found — install the USB Redirector server first\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! raw="$(usbsrv -list-devices 2>&1)"; then
|
||||
printf '[!] usbsrv -list-devices failed — is the USB Redirector server running?\n' >&2
|
||||
return 1
|
||||
fi
|
||||
recs="$(printf '%s\n' "$raw" | share_usb_records '^ID:')"
|
||||
if [ -z "$recs" ]; then
|
||||
printf '[!] no USB device records could be parsed from the server listing\n' >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$recs"
|
||||
}
|
||||
|
||||
share_usb_clients() {
|
||||
local raw recs
|
||||
if ! command -v usbsrv >/dev/null 2>&1; then
|
||||
printf '[!] usbsrv not found — install the USB Redirector server first\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! raw="$(usbsrv -list-clients 2>&1)"; then
|
||||
printf '[!] usbsrv -list-clients failed — is the USB Redirector server running?\n' >&2
|
||||
return 1
|
||||
fi
|
||||
recs="$(printf '%s\n' "$raw" | share_usb_records '^Client ID:')"
|
||||
if [ -z "$recs" ]; then
|
||||
printf '[!] no connected clients could be parsed from the server listing\n' >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$recs"
|
||||
}
|
||||
|
||||
# ── Share-folder candidates ────────────────────────────────────
|
||||
# stdout: candidate paths, one per line — writable real-filesystem findmnt
|
||||
# targets (pseudo-fs denylist and read-only mounts excluded) ∪ immediate
|
||||
# directories under /mnt,/srv,/media,/export. LC_ALL=C sorted, deduplicated;
|
||||
# entries that are mountpoints carry a "(mounted <fstype>)" annotation.
|
||||
# rc 0 always; an empty list is allowed (caller falls back to manual entry).
|
||||
share_folder_candidates() {
|
||||
local -A seen=()
|
||||
local line fs tgt opts entry child r path fst
|
||||
local -a cands=()
|
||||
|
||||
# findmnt targets: split TARGET FSTYPE OPTIONS (TARGET may hold escaped
|
||||
# spaces, OPTIONS/FSTYPE never do) — pseudo-fs denylist + ro exclusion.
|
||||
while IFS=$'\t' read -r fs tgt opts; do
|
||||
[ -n "$tgt" ] || continue
|
||||
case "$fs" in
|
||||
proc | sysfs | devtmpfs | devpts | tmpfs | cgroup | cgroup2 | squashfs | \
|
||||
overlay | mqueue | hugetlbfs | debugfs | tracefs | configfs | fusectl | \
|
||||
securityfs | pstore | efivarfs | bpf | ramfs | nsfs | binfmt_misc | autofs | \
|
||||
iso9660 | swap | fuse.* | cgroupfs) continue ;;
|
||||
esac
|
||||
case ",$opts," in *,ro,*) continue ;; esac
|
||||
share_path_probe -w "$tgt" || continue
|
||||
cands+=("${tgt}|${fs}")
|
||||
done < <(timeout 5 findmnt -rn -o TARGET,FSTYPE,OPTIONS 2>/dev/null |
|
||||
awk '{ opts=$NF; fstype=$(NF-1); tgt=substr($0, 1, length($0)-length(opts)-length(fstype)-1); sub(/[ \t]+$/, "", tgt); print fstype "\t" tgt "\t" opts }')
|
||||
|
||||
# immediate directories under the conventional share roots
|
||||
for r in /mnt /srv /media /export; do
|
||||
[ -d "$r" ] || continue
|
||||
for child in "$r"/*; do
|
||||
share_path_probe -d "$child" || continue
|
||||
cands+=("${child}|")
|
||||
done
|
||||
done
|
||||
|
||||
# annotate mountpoints, prefer annotated duplicates, sort byte-order
|
||||
for line in "${cands[@]}"; do
|
||||
path="${line%%|*}"
|
||||
fst="${line#*|}"
|
||||
if [ -n "$fst" ]; then
|
||||
seen["$path"]="${path} (mounted ${fst})"
|
||||
elif [ -z "${seen[$path]:-}" ]; then
|
||||
seen["$path"]="$path"
|
||||
fi
|
||||
done
|
||||
for path in "${!seen[@]}"; do
|
||||
printf '%s\n' "${seen[$path]}"
|
||||
done | LC_ALL=C sort -u
|
||||
}
|
||||
+21
-10
@@ -9,9 +9,10 @@
|
||||
# USB_BYID /dev/disk/by-id dir consulted by usb_related_present
|
||||
#
|
||||
# API:
|
||||
# usb_detect fill USB_MOUNTED (mountpoints, one per
|
||||
# removable USB storage) and USB_UNMOUNTED
|
||||
# ("path|label|size|model" entries)
|
||||
# usb_detect fill USB_MOUNTED ("mp|label|size|model|fs",
|
||||
# one per removable USB storage partition;
|
||||
# EFI system partitions are excluded) and
|
||||
# USB_UNMOUNTED ("path|label|size|model")
|
||||
# usb_related_present whole-system "is any USB storage attached?"
|
||||
# usb_mount_offer <devs...> offer to mount detected-but-unmounted sticks
|
||||
# (sudo, mirrors usb-automount: /media/<label>,
|
||||
@@ -32,13 +33,13 @@ USB_BYID="${USB_BYID:-${BACKUP_USB_BYID:-/dev/disk/by-id}}"
|
||||
# usb_related_present() corroborates. Non-USB removables are skipped. The
|
||||
# "TRAN unavailable" warning fires once per scan, not per device.
|
||||
usb_detect() {
|
||||
local out path="" mp="" label="" size="" model="" tran="" type="" children=""
|
||||
local out path="" mp="" label="" size="" model="" tran="" type="" fs="" parttype="" children=""
|
||||
local warned=0
|
||||
USB_MOUNTED=()
|
||||
USB_UNMOUNTED=()
|
||||
out="$(lsblk -J -o NAME,PATH,LABEL,MOUNTPOINT,RM,TYPE,TRAN,SIZE,MODEL 2>/dev/null)" || return 0
|
||||
out="$(lsblk -J -o NAME,PATH,LABEL,MOUNTPOINT,RM,TYPE,TRAN,SIZE,MODEL,FSTYPE,PARTTYPENAME 2>/dev/null)" || return 0
|
||||
|
||||
while IFS=$'\x1f' read -r path mp label size model tran type children; do
|
||||
while IFS=$'\x1f' read -r path mp label size model tran type fs parttype children; do
|
||||
[ -n "$path" ] || continue
|
||||
if [ "$tran" = "usb" ]; then
|
||||
:
|
||||
@@ -50,8 +51,14 @@ usb_detect() {
|
||||
else
|
||||
continue
|
||||
fi
|
||||
# EFI system partitions (Ventoy VTOYEFI, /boot/efi) are boot
|
||||
# machinery, never a storage target — skip them entirely, mounted or
|
||||
# not (else a 32M ESP can be offered as a sync/backup target).
|
||||
if [[ "${parttype^^}" == *EFI* ]] || [[ "${label^^}" == *EFI* ]] || [[ "${mp^^}" == */EFI ]]; then
|
||||
continue
|
||||
fi
|
||||
if [ -n "$mp" ]; then
|
||||
USB_MOUNTED+=("$mp")
|
||||
USB_MOUNTED+=("$mp|$label|$size|$model|$fs")
|
||||
elif [ "$children" = "0" ]; then
|
||||
USB_UNMOUNTED+=("$path|$label|$size|$model")
|
||||
fi
|
||||
@@ -60,6 +67,7 @@ usb_detect() {
|
||||
| select(.rm == true and (.type == "part" or .type == "disk"))
|
||||
| [.path, (.mountpoint // ""), (.label // ""), (.size // ""),
|
||||
(.model // ""), (.tran // ""), (.type // ""),
|
||||
(.fstype // ""), (.parttypename // ""),
|
||||
((.children // []) | length)]
|
||||
| join("\u001f")')
|
||||
}
|
||||
@@ -174,21 +182,24 @@ usb_pick_root() {
|
||||
|
||||
if [ ${#roots[@]} -eq 1 ]; then
|
||||
root="${roots[0]}"
|
||||
if ! confirm "${confirm_prefix} ${root%/}/${subfolder}?" n; then
|
||||
IFS='|' read -r mp label size model fs <<< "$root"
|
||||
if ! confirm "${confirm_prefix} ${mp%/}/${subfolder}? (${size}, ${label:-no label}, ${fs})" n; then
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
echo "Multiple USB storages found:"
|
||||
for i in "${!roots[@]}"; do
|
||||
printf "%2d) %s\n" "$((i + 1))" "${roots[$i]}"
|
||||
IFS='|' read -r mp label size model fs <<< "${roots[$i]}"
|
||||
printf "%2d) %s (%s, %s, %s)\n" "$((i + 1))" "$mp" "$size" "${label:-no label}" "$fs"
|
||||
done
|
||||
read -rp "Use which one? [1-${#roots[@]}] (0 = skip): " resp || return 1
|
||||
if ! [[ "$resp" =~ ^[0-9]+$ ]] || (( resp < 1 || resp > ${#roots[@]} )); then
|
||||
return 1
|
||||
fi
|
||||
root="${roots[$((resp - 1))]}"
|
||||
IFS='|' read -r mp label size model fs <<< "$root"
|
||||
fi
|
||||
|
||||
USB_ROOT="$root"
|
||||
USB_ROOT="$mp"
|
||||
return 0
|
||||
}
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ PACKAGES=(
|
||||
net-tools iputils-ping traceroute tcpdump nmap
|
||||
openssh-client openssh-server ufw fail2ban
|
||||
nfs-common nfs-kernel-server
|
||||
samba cifs-utils
|
||||
samba cifs-utils smbclient
|
||||
hostapd dnsmasq iptables iw
|
||||
ca-certificates gnupg lsb-release
|
||||
lm-sensors smartmontools nvme-cli hdparm
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# `pos media ytsync` — implementation research notes
|
||||
|
||||
Internal reference for maintainers touching `bin/pos-media-ytsync`. The user-facing
|
||||
docs live in `DOC/POS.md → media` and `DOC/howto/media.md`; the behavioral contract
|
||||
is `reportAgents/2026-08-22-designer-ytsync.md` + the Architect decisions D1–D9 in
|
||||
`reportAgents/2026-08-22-architect-ytsync.md`.
|
||||
|
||||
## Probe mechanics
|
||||
|
||||
One fast call per source per run:
|
||||
|
||||
```
|
||||
yt-dlp --flat-playlist -J --no-warnings -- <url>
|
||||
```
|
||||
|
||||
- `--flat-playlist` keeps entries as stubs, so cost is 1–2 HTTP round trips
|
||||
regardless of library size (seconds even for 2000-video channels).
|
||||
- `-J` dumps a single JSON object. Parsed with `jq`:
|
||||
- display name: `.channel // .uploader // .uploader_id // .title`
|
||||
- owner (for playlist subdirs): `.channel // .uploader // .uploader_id` — no title
|
||||
fallback (avoids `Playlist Title/Playlist Title` doubling when a playlist probe
|
||||
has no channel fields)
|
||||
- key (slug source): `.uploader_id // .channel_id // .id`
|
||||
- entries: `.entries[] | (.id) + "\x1f" + (.title)`; a `?v=` URL returns a bare
|
||||
video object with **no** `.entries`, handled as a one-entry list.
|
||||
- The new-list is computed BEFORE any download by diffing entry ids against
|
||||
`archive/<slug>.txt` — exact `[n/N]` counts, exact dry-run plans, zero speculative
|
||||
downloads.
|
||||
- Sign-in-skipped bucket: entries with an empty id OR titles starting with
|
||||
`[Private` / `[Deleted` / `[Unavailable`. Reported as "N videos require sign-in —
|
||||
skipped"; never counted as failures.
|
||||
|
||||
### Why probes don't use spawn()
|
||||
|
||||
`lib/common.sh spawn()` **exits the process on failure** and captures output until
|
||||
completion. ytsync needs recoverable probes (interactive add re-prompts ≤3×; sync
|
||||
must continue other sources after one bad probe) and never wraps the download batch
|
||||
(hours of silence behind a spinner). `_probe_run` mirrors spawn's OK/FAIL line UX but
|
||||
returns rc, and its braille spinner frames are emitted only on a TTY so no `\r`
|
||||
bytes ever reach the dispatcher's tee'd logs.
|
||||
|
||||
## Classification (URL shape, deterministic)
|
||||
|
||||
| URL contains | Type | Behavior |
|
||||
|---|---|---|
|
||||
| `youtu.be/<id>` path (with or without `list=`) | `video` | short links name one video — treated exactly like `v=` |
|
||||
| `v=` present (with or without `list=`) | `video` | single video, downloaded with `--no-playlist`; nobody backfills a 500-video playlist by pasting a watch link |
|
||||
| `list=` without any video-naming part | `playlist` | tracked source; files land as `<videos>/<owner>/<playlist>/<NNN> - <title>.<ext>` |
|
||||
| neither | `channel` | flat source; `<videos>/<channel>/<title>.<ext>` |
|
||||
|
||||
## Download invocation (one yt-dlp call PER NEW VIDEO)
|
||||
|
||||
```
|
||||
yt-dlp -f "bestvideo*+bestaudio/best"
|
||||
--merge-output-format mp4 # parity with pos media mp4
|
||||
--embed-metadata --embed-chapters --embed-thumbnail
|
||||
--convert-thumbnails jpg
|
||||
--no-overwrites # parity; collisions become "exists, kept" warnings
|
||||
--download-archive <state>/archive/<slug>.txt # crash-safe per-video recording
|
||||
--windows-filenames # USB/Samba/TV-safe names
|
||||
--trim-filenames 120 # headroom for the NNN prefix under 255-byte limits
|
||||
--retries 3 --fragment-retries 3
|
||||
[--no-playlist] # only for type=video sources
|
||||
${YTSYNC_EXTRA_ARGS} # appended last — user override hatch
|
||||
-o "<template>" "https://www.youtube.com/watch?v=<id>"
|
||||
```
|
||||
|
||||
- Templates are COMPUTED from stored registry fields (`subdir`), never from yt-dlp
|
||||
placeholders like `%(playlist_title)s` — a stored subdir cannot be NA and cannot
|
||||
drift mid-library.
|
||||
- Playlist template gets a LITERAL zero-padded index injected by the tool (from the
|
||||
probe's entry position), because standalone watch URLs have no live
|
||||
`%(playlist_index)s`. Caveat: inserting a video mid-playlist shifts FUTURE
|
||||
numbering; existing files are never renamed.
|
||||
- Deliberate divergences from mp4: no `--embed-subs --sub-langs all` (library bloat;
|
||||
re-add per-source via `YTSYNC_EXTRA_ARGS`, appended-last wins); `--quiet
|
||||
--no-warnings` plus `--progress` only on a TTY (yt-dlp auto-simplifies progress
|
||||
when piped, keeping `\r` out of logs).
|
||||
- Sequential downloads; batching is the listed future optimization (accepted v1
|
||||
cost: one extra extraction round trip per video).
|
||||
|
||||
## State layout (machine-owned, outside ~/Videos)
|
||||
|
||||
```
|
||||
${YTSYNC_STATE_DIR:-~/.local/share/linux_post_install/ytsync}/
|
||||
├── registry # \x1f-delimited: slug⇥type⇥url⇥subdir⇥playlist_title⇥added_ts
|
||||
├── archive/<slug>.txt # native yt-dlp archive format ("<extractor> <id>"), one per source
|
||||
└── history.log # append-only: "<YYYY-MM-DD HH:MM> · <name> · N new · M skipped · K failed"
|
||||
```
|
||||
|
||||
- `slug`: `[a-z0-9][a-z0-9_-]*`, derived from probed uploader_id/handle; numeric
|
||||
`-2` suffix on collision, checking registry slugs only.
|
||||
- Registry/archive writes are atomic (mktemp inside the state dir + mv).
|
||||
- `remove` drops only the registry line. Keeping the orphaned archive makes a future
|
||||
re-add of the same source an incremental resume instead of a full re-download that
|
||||
would collide with existing files under `--no-overwrites`.
|
||||
- History grammar note: a wholesale probe failure appends `<date> · <name> · FAILED
|
||||
(probe)` instead of the numeric triple — the pass did not complete, and faking
|
||||
zeros would hide it from `grep`.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Mid-playlist inserts** shift future numbering only (see above).
|
||||
- **Title renames on YouTube** never rename local files — the archive is keyed by
|
||||
video id; local files are immutable once written.
|
||||
- **Same-title collision** (`--no-overwrites` refusal): stderr is matched for
|
||||
"already been downloaded" → `[!] exists, kept: <file>`, counted into the
|
||||
"already present" tally of the summary (the fixed summary grammar has three
|
||||
buckets; tools-docs records this folding).
|
||||
- **Disk full (ENOSPC)**: stderr matched for "No space left on device"/Errno 28 →
|
||||
prominent warning, that source stops mid-run (unfetched videos simply stay "new"
|
||||
next run since the archive was untouched), other channels continue.
|
||||
- **Per-video generic failure**: `[!] unavailable: <title>` + first 3 stderr lines,
|
||||
continue, counted as failed. Per-video failures never flip the exit code; rc 1 is
|
||||
reserved for missing deps, invalid explicit URL, unknown/ambiguous `<name>`, or
|
||||
≥1 requested source failing wholesale during sync.
|
||||
- **Slug stability**: the slug derives from the probed `uploader_id`. If YouTube
|
||||
ever changes that id for a channel, a re-add computes a different slug whose
|
||||
archive starts empty → existing files would be "re-downloaded", hitting
|
||||
`--no-overwrites` and spamming `exists, kept` warnings. Manual mitigation if it
|
||||
ever bites: rename the old `archive/<old-slug>.txt` to the new slug before
|
||||
syncing. Accepted v1 limitation (Architect §Remaining uncertainty #4).
|
||||
|
||||
## Testing seams
|
||||
|
||||
- `YTSYNC_STATE_DIR`, `YTSYNC_VIDEOS_DIR` — every written path honors them
|
||||
(written `VAR="${VAR:-default}"`). Fake `yt-dlp` arrives via stub PATH emitting
|
||||
canned `-J` JSON; fake notify via a stubbed sender appending to `sends.log`.
|
||||
- Interactive reads all come from `/dev/tty` (NOT stdin), so the tool is NOT in
|
||||
`bin/pos`'s INTERACTIVE_CMDS and dispatched runs keep full tee logging; non-tty
|
||||
interactive entry points print the guard line and exit 0.
|
||||
Reference in New Issue
Block a user