Compare commits

..

14 Commits

Author SHA1 Message Date
Your Name f0ef13827b fix: ai --last — prefer newer source (auto-capture beats stale pos logs)
gates / consistency-and-conventions (push) Failing after 14s
--last now compares mtime of pos dispatcher logs vs captured output
(last_cmd_output) and uses whichever is newer, instead of always
preferring pos logs even when they are hours old.
2026-08-25 08:49:36 -04:00
Your Name 4af097f5eb docs: sync POS/AGENT_Context/completions for share, vbox, ai features
gates / consistency-and-conventions (push) Successful in 2m28s
- POS.md: new openrouter rows, updated share/vbox entries
- AGENT_Context_Project.md: GEN tree/dispatch/filetable/docmap resync
- completions/pos.bash: new flags/subcommands for openrouter + capture
- bin/pos: INTERACTIVE_CMDS += ai-openrouter (stdin reader)
2026-08-25 08:40:05 -04:00
Your Name 476173ba83 feat: ai — gemini terse+render+last+session+machine, openrouter new tool, capture any command
gemini enhancements:
- built-in terse system prompt with troubleshooting clause + machine context
- markdown→terminal rendering (glow opportunistic + zero-dep awk fallback)
- --last: pos logs + captured output fallback, staleness warning, stderr annotations
- session default always on; --session override; answer separation on tty
- --full flag, --system wholesale override

openrouter (new tool):
- cloned from gemini, adapted for OpenAI-compatible REST API
- Bearer auth, messages array, choices[0].message.content parsing
- config: pos config ai-openrouter → OPENROUTER_API_KEY/MODEL
- default model: openrouter/auto (auto-picks best model)
- all features: ask, chat, sessions, --last, capture

capture subcommand (both tools):
- runs any command, tees output to last_cmd_output for --last
- --last fallback: pos logs (priority) → last_cmd_output (secondary)

shell hook (optional):
- lib/pos-ai-hook.sh: sourceable .bashrc snippet for auto-capture
- exec > >(tee ...) with 1 MB truncation
2026-08-25 08:39:55 -04:00
Your Name 0aaa25150c feat: vbox — categorized create UI with GPU/device/mount/port presets
- category hub with basket counts, review screen, single confirm
- GPU: nvidia-smi → /proc/driver/nvidia → vendor scan detection
- host devices: lsusb/tty/video/snd/lsblk + manual input, dedupe
- dir mounts with (system disk — careful) labels
- SHOULD tier: image/ports/cpus/mem, flag contract --gpu/--device/--dir/--port/--cpus/--memory/--network
- zero-flag run byte-identical to pre-edit
- cmd_unpersist not-found exit 0 → return 0 in both clients
- DOC/howto/docker.md: vbox categorized create section
2026-08-25 08:39:42 -04:00
Your Name 4173fc61e3 feat: share clients — picker enhancements, unmount fixes, confirm default-y convention
- mountpoint picker: synthetic (as on server) candidate + n=new mkdir flow
- unmount-by-pick via findmnt enumeration with confirm
- cmd_unmount idle-persisted branch exit 0 → return 0 + actionable guidance
- all menu handlers normalized … || true
- confirm() rewrite: default-y on Enter, EOF fail-closed, case-insensitive
- latent compose "Y" bug fixed
- DEV.md convention doc for confirm semantics
2026-08-25 08:39:29 -04:00
Your Name 692cb6b362 feat: menu doors for media-sync/backup/compose/schedule/vbox/download; firewall menu → stderr+/dev/tty mechanics
gates / consistency-and-conventions (push) Successful in 2m10s
2026-08-24 14:44:25 -04:00
Your Name 5b2a0304e1 feat: share suite — interactive menus for usb/nfs/smb server+client on share-lib domain layer 2026-08-24 14:44:18 -04:00
Your Name f61766b074 feat: lib/menu-lib.sh — category-neutral menu primitives (guard/run/pick/ask_value); install list += new libs 2026-08-24 14:44:12 -04:00
Your Name b144fe8853 fix: ytsync — multibyte cut delimiter broke last-run rendering; printf dash format rejected 2026-08-24 14:43:57 -04:00
Your Name 010e067935 fix: ytsync — classify youtu.be/<id> short links as videos; sync tools-docs thumbnail flag
gates / consistency-and-conventions (push) Successful in 2m34s
2026-08-23 07:46:51 -04:00
Your Name 5de7a331fe feat: pos media ytsync — incremental YouTube channel/playlist sync into ~/Videos 2026-08-23 07:39:36 -04:00
Your Name a0152fa87c .
gates / consistency-and-conventions (push) Failing after 9s
2026-08-21 09:53:10 -04:00
Your Name 364c5c3687 feat: pos docker stack — containers grouped by compose stack (project)
gates / consistency-and-conventions (push) Failing after 12s
New bin/pos-docker-stack: docker ps output grouped by Docker Compose
project (stack). Each stack is a sorted section (name, status, ports);
containers without a compose project land in a Standalone group at the
end. Running only by default, -a|--all includes stopped/exited. Status
colored on a terminal; summary line 'Stacks: N containers: N
standalone: N'; exit 0 when empty.

Data via docker ps --format with \x1f delimiters (project label
com.docker.compose.project from compose v2); parsed with awk -F'\x1f'
+ IFS=$'\x1f' read — tab/pipe delimiters are IFS whitespace or appear
in values (DEV.md:213). Dash padding via sed, not tr (multi-byte).
Deps guard (docker) before --help; no stdin.

Docs: POS.md docker row + detail, howto/docker.md table + section,
bin/pos usage EXAMPLES, AGENT_Context Common Tasks row. Verified:
stub suite 23/23, live daemon runs, dispatch, make gen && make check,
make lint 0 FAIL / 0 WARN.
2026-08-15 13:01:03 -04:00
Your Name a09f9cfafa fix: usb target picking excludes EFI partitions and pre-flights space
gates / consistency-and-conventions (push) Successful in 59s
usb_detect offered a Ventoy stick's 32M VTOYEFI ESP as a sync/backup
target: with the data partition unmounted it was the only mounted
candidate, and cp died mid-copy with 'No space left on device'.
Detection now reads FSTYPE/PARTTYPENAME and drops EFI system
partitions 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 (USB_ROOT stays a bare mountpoint). pos-media-sync pre-flights
the exact payload size vs df free space (err, or warn under --dry-run)
before any copy.
2026-08-15 04:21:10 -04:00
38 changed files with 6760 additions and 686 deletions
+1
View File
@@ -0,0 +1 @@
1
+4 -3
View File
@@ -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.
+9
View File
@@ -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 D1D9 + 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).
+47 -32
View File
@@ -10,19 +10,19 @@
<!-- GEN:START docmap -->
| ## 1. Project Overview | 2843 |
| ## 2. Directory Structure | 44194 |
| ## 3. Installation Flow | 195246 |
| ## 4. The `pos` CLI System | 247318 |
| ## 5. Shared Library — `lib/common.sh` | 319350 |
| ## 6. Docker Compose / ScaleTail | 351393 |
| ## 7. Optional Apps (`apps/`) | 394423 |
| ## 8. Entertainment Module | 424437 |
| ## 9. Systemd Services | 438449 |
| ## 10. Configuration Files | 450476 |
| ## 11. Coding Conventions | 477509 |
| ## 12. Development Workflow | 510562 |
| ## 13. Key File Quick Reference | 563625 |
| ## 14. Common Tasks for Agents | 626657 |
| ## 2. Directory Structure | 44197 |
| ## 3. Installation Flow | 198251 |
| ## 4. The `pos` CLI System | 252326 |
| ## 5. Shared Library — `lib/common.sh` | 327358 |
| ## 6. Docker Compose / ScaleTail | 359401 |
| ## 7. Optional Apps (`apps/`) | 402431 |
| ## 8. Entertainment Module | 432445 |
| ## 9. Systemd Services | 446457 |
| ## 10. Configuration Files | 458484 |
| ## 11. Coding Conventions | 485517 |
| ## 12. Development Workflow | 518570 |
| ## 13. Key File Quick Reference | 571638 |
| ## 14. Common Tasks for Agents | 639672 |
<!-- 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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".
---
+40
View File
@@ -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
+4 -1
View File
@@ -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
View File
@@ -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
+597
View File
@@ -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
View File
@@ -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
+101
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+146 -68
View File
@@ -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
+1191
View File
File diff suppressed because it is too large Load Diff
+154 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
+32
View File
@@ -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
+318
View File
@@ -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
View File
@@ -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
View File
@@ -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
+131
View File
@@ -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 D1D9 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 12 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.