Compare commits

..

23 Commits

Author SHA1 Message Date
Your Name a5c19e842d revert: remove e(dit) option from AI command prompt
gates / consistency-and-conventions (push) Successful in 1m40s
Keep only Y/n (run or skip). The edit feature was unreliable across
different terminal contexts (tee pipes, SSH, CLI). May revisit later.
2026-08-26 04:58:08 -04:00
Your Name d84a35efce fix: read -e -i stores into variable directly, not stdout
gates / consistency-and-conventions (push) Successful in 2m34s
edited="\$(read ...)" was always empty because read writes to a variable
name, not stdout. Changed to: read -e -p "Command: " -i "\$flat" edited
which stores directly into \$edited.
2026-08-26 04:42:18 -04:00
Your Name 9564880ebf fix: AI command edit - flatten multi-line for readline
gates / consistency-and-conventions (push) Successful in 1m47s
read -e -i only handles single-line text. Multi-line commands (docker
install etc) broke it. Now flattens newlines to spaces before pre-filling
the readline buffer. User sees a single editable line.
2026-08-26 04:37:52 -04:00
Your Name d0299d3f98 feat: AI command edit via clipboard + xdotool fallback
gates / consistency-and-conventions (push) Successful in 2m14s
- _inject_command tries: xclip/wl-copy (clipboard) -> xdotool (typing) -> tmux -> history
- Clipboard is primary: user pastes with Ctrl+Shift+V
- preinstall.sh: add xdotool and xclip to PACKAGES
2026-08-26 04:13:36 -04:00
Your Name c1f1c4109f feat: AI command prompt adds e(dit) option with keyboard simulation
gates / consistency-and-conventions (push) Successful in 2m11s
- e: xdotool type (X11/Wayland) -> tmux send-keys -> history fallback
- Command appears on active terminal line for editing before Enter
- Y/Enter: execute, n: add to history
2026-08-26 03:51:16 -04:00
Your Name 710b626f47 feat: AI command prompt - run or edit detected shell commands
gates / consistency-and-conventions (push) Successful in 2m6s
- _extract_commands() parses bash/sh/shell fenced code blocks
- _prompt_run_command() prompts [Y/n] via /dev/tty after AI response
- Y/Enter: execute via run helper (respects DRY_RUN)
- n: command added to history (press up-arrow to recall, edit, run)
- Integrated in both cmd_ask() and cmd_chat()
- Skipped when output is piped/redirected
2026-08-26 03:26:11 -04:00
Your Name 1c19c0de59 fix: _cfg_provider_keys path resolution for installed layout
gates / consistency-and-conventions (push) Successful in 1m47s
Try both repo (../lib/ai-providers/) and installed (./ai-providers/) paths.
Installed layout copies ai-providers/ to same dir as config-ui.sh.
2026-08-26 03:07:29 -04:00
Your Name e0c9ba384a feat: dynamic provider config — pos config ai auto-discovers provider keys
gates / consistency-and-conventions (push) Successful in 1m30s
- lib/ai-providers/*.sh declare # PROVIDER_CONFIG: headers
- lib/config-ui.sh: _cfg_provider_keys() scans providers at runtime
- bin/pos-ai: POS_CONFIG uses *providers marker (no hardcoded keys)
- Adding a new provider auto-populates config UI — no main tool edits needed
2026-08-26 02:58:45 -04:00
Your Name 7ae2e77a44 fix: ai — per-provider API keys (remove shared AI_API_KEY)
gates / consistency-and-conventions (push) Failing after 11s
Each provider now has its own key: AI_GEMINI_API_KEY and OPENROUTER_API_KEY.
No more shared AI_API_KEY that caused cross-provider key leakage (gemini
getting openrouter key → 400 error). resolve_key() sets AI_API_KEY internally
from the active provider's key for adapter use. Config UI shows both keys.
2026-08-26 02:36:48 -04:00
Your Name 88ea660891 feat: system uninstall — safe interactive pos toolkit remover
gates / consistency-and-conventions (push) Successful in 2m4s
- Three tiers: binaries/services/shell (default), config (--config), data (--data)
- Interactive scan + numbered plan display, confirm per tier
- --yes skips prompts (tier 1 only); --yes --config --data = nuclear
- Shell integration cleanup: bashrc PATH/completion/hook entries
- Systemd services disabled and stopped
- Idempotent, never removes git repo
2026-08-25 11:00:05 -04:00
Your Name 1fbdf7ef2d fix: ai — config UI pipe-in-description bug + render tty detection with shell hook
gates / consistency-and-conventions (push) Failing after 11s
- POS_CONFIG header: replace | with 'or' in AI_PROVIDER description
  (bare | was parsed as field separator, splitting one entry into two)
- render_markdown: check /dev/tty as fallback when shell hook redirects
  stdout through tee (breaks [ -t 1 ] but /dev/tty stays writable)
2026-08-25 10:04:10 -04:00
Your Name 4f79ce123f refactor: ai — merge gemini/openrouter into unified plugin architecture
gates / consistency-and-conventions (push) Successful in 1m59s
- bin/pos-ai: single provider-agnostic tool (ask/chat/sessions/capture/models/providers)
- lib/ai-providers/gemini.sh: Gemini adapter (59 ln)
- lib/ai-providers/openrouter.sh: OpenRouter adapter (59 ln)
- bin/pos-ai-gemini/openrouter: thin forwarders for backward compat
- Provider adapter interface: provider_name/default_model/generate/models_list
- Unified session format (OpenAI messages), auto-migrate old gemini contents
- Config: AI_PROVIDER/AI_API_KEY/AI_MODEL/AI_SYSTEM_PROMPT in ai.env
- Config fallback: AI_API_KEY → provider-specific env var → error
- Default system prompt configurable via AI_SYSTEM_PROMPT
- New subcommand: pos ai providers (lists providers + config status)
- Shell hook (pos-ai-hook.sh) for auto-capture
2026-08-25 09:57:10 -04:00
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
42 changed files with 6877 additions and 1111 deletions
+2 -2
View File
@@ -16,9 +16,9 @@ CRITICAL: real guidance lives in DOC/. When you encounter a reference below, use
- **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`, 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 — check remote tags to see gate status). 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 *`).
+6
View File
@@ -42,6 +42,7 @@ summary (newest last).
## Done
- **2026-08-21** — Share suite interactive layer (`lib/share-lib.sh` + menu modes for all five `pos share *` tools): bare invocation now opens an EOF-safe looping menu instead of printing usage. New `lib/share-lib.sh` (436 lines) owns the shared primitives — `share_menu_guard`/`share_menu_run`/`share_pick`/`share_ask_value` (quit on EOF so non-tty callers can't hang), `share_require_bin`/`share_port_probe`/`share_service_active`/`share_path_probe` rc-only probes, `share_usb_records` (blank-line-record parser for usbsrv listings), `share_smb_shares` (smbclient `-g` Disk enumeration incl. guest→auth retry) + `share_usb_devices`/`share_usb_clients`, `share_nfs_exports` (showmount), `share_folder_candidates` (bounded-probe scan of mounted targets + conventional roots; container overlay/tmpfs/nsfs excluded via findmnt; clients build their own mountpoint pickers on top), and advisories `share_ufw_blocks_ports`+`share_offer_fix`. Tools keep every legacy flag/subcommand byte-compatible (verbatim command bodies, thin menu layer on top): nfs-server gains a client-spec presets picker + inactive-service/UFW offers, nfs-client gains idempotent unmount/unpersist (already-absent = report, rc0) + persist-verify-with-rollback + replace-confirm, smb-server gets UFW offer + menu tree, smb-client gets enumerate→pick→mount with account reuse (`SMB_AUTH_USER` contract), usb-server picker-first with raw-listing manual-entry fallback when the server listing is unreadable. New seams: `EXPORTS_FILE` (nfs-server), `UNIT_DIR` (nfs-client); `bin/pos` INTERACTIVE_CMDS += both stdin-reading share tools; install.sh lib list += share-lib.sh; preinstall.sh += smbclient (smb-client enumeration dep). Docs: POS.md share rows/detail, howto/share.md per-tool Interactive-menu notes, SCRIPTS.md phase table + new `## lib/share-lib.sh` section, DEV.md lib row + env-seam registry, AGENT_Context hand-maintained spots (lib table row 436, Phase-2 prose). Verified: 80-case stub battery vs recaptured deterministic golden — only the 9 documented intentional deltas differ (additive help lines, seam-path strings in messages, missing-dep message delta, unmount-idle FLAGGED→rc0, unpersist round-trip now works, usbs-bare usage→menu guard); 12/12 PTY tests (menus open/quit non-tty, filter/zero-match/default/cancel picker semantics, full nfs-server share flow writes the export line, usb-server share via pickers + down-server fallback, smb-client guest-enumerate→manual-share flow); real `/etc/exports` + `/etc/systemd/system` md5-verified untouched; `make gen && make check` green, `make lint` 0 FAIL / 0 WARN.
- **2026-08-21** — Refreshed `AGENTS.md` against the codebase: HOWTO category list corrected to match `DOC/howto/*` (ai/share/schedule, no bare "usb"); CI bullet now states only verifiable facts (`lint.yml` job `gates`, push-to-main/PR, `ci-ok/<sha>`/`ci-fail/<sha>` result tags) instead of the uncheckable act-runner naming; new **Doc conflicts** bullet encoding the `MAINTENANCE.md → Phase 0` authority order and `templates/*.sh` as required starting points. Every other claim re-verified against `scripts/{gen-docs,check-sync,lint-conventions}.sh`, `bin/pos` (dispatch loop, INTERACTIVE_CMDS), `.gitignore`/`.gitmodules`, `lib/config-ui.sh`; gates green before and after.
- **2026-08-15** — `pos docker stack` (`bin/pos-docker-stack`) — containers grouped by their Docker Compose project. Each stack is a section (project name, sorted) with lines `container-name status ports`; containers with no compose project land in a `Standalone` section at the end; ends with `Stacks: N containers: N standalone: N`. Running only by default, `-a|--all` includes stopped/exited (like `docker ps -a`). Status colored on a terminal (`Up*` green, `Exited*`/`Dead*`/`Created*` red, `Paused*`/`Restarting*` yellow); exit 0 also when no containers. Data via `docker ps` with `--format '{{.Names}}{{"\u001f"}}{{.Label "com.docker.compose.project"}}{{"\u001f"}}{{.Status}}{{"\u001f"}}{{.Ports}}'` (compose v2 sets the project label; `{{"\u001f"}}` escapes in the Go template), parsed with `awk -F'\x1f'` + `IFS=$'\x1f' read` everywhere — tab/pipe delimiters are IFS whitespace or inside values, so `\x1f` (DEV.md:213 gotcha); dash padding via `sed` not `tr` (tr corrupts multi-byte `─`). Deps guard (`docker`) before `--help`; no stdin → not in `INTERACTIVE_CMDS`; `# POS_FLAGS: -a --all`. Docs: POS.md docker row + detail, howto/docker.md table + section, `bin/pos` usage EXAMPLES, AGENT_Context §14 row. Verified: stub-PATH suite `/tmp/opencode/docker-stack-test/run-tests.sh` 23/23 (grouping, sorted stacks, `-a` shows exited, standalone, empty daemon rc=0, colored status, missing docker rc=1, `--help` after deps guard); live runs against the real daemon (affine/audiobookshelf/convertx/gitea stacks, `affine_migration_job Exited (0)` + `lab1 Exited (137)` under `-a`); dispatch via `pos docker stack`; `make gen && make check`, `make lint` 0 FAIL / 0 WARN.
- **2026-08-15** — Fix `pos media sync` offering a Ventoy stick's **EFI partition** as the sync target: with the data partition unmounted, the 32 MB `VTOYEFI` ESP was the only mounted USB partition, `usb_detect` offered it with no context, and `cp` died mid-copy with `No space left on device` (live-box report). `usb_detect` now fetches `FSTYPE`/`PARTTYPENAME` and excludes EFI system partitions (Ventoy `VTOYEFI`, `/boot/efi`) from **both** the mounted list and the mount-offer list; `USB_MOUNTED` entries carry `mp|label|size|model|fs` and `usb_pick_root` shows that in the single-stick confirm and the multi-stick/partition picker (`1) /media/Ventoy (1.1T, Ventoy, exfat)`), while `USB_ROOT` stays a bare mountpoint (`${root%|*}`) so `pos system backup` (`${root%/}/backups`) is unaffected. `pos-media-sync` gained a pre-flight space check (measures exactly what `needs_copy` would copy vs `df -Pk`, `err`/`warn` before any copy) — no more mid-copy ENOSPC. Docs: howto/media.md target-picking note, SCRIPTS.md usb-lib paragraph, AGENT_Context hand-maintained lib row (194→205). Verified: new stub harness `/tmp/opencode/vtoyefi-run.sh` (ESP filtered from mounted + mount-offer, multi-pick shows only the data partition, space fit/too-small/dry-run-warn) green; `/tmp/opencode/backup-test` still green; live check `printf 'n\ns\n' | bash bin/pos-media-sync --mp3` no longer offers VTOYEFI (offers unmounted `sda1` Ventoy instead); `make gen && make check`, `make lint` 0 FAIL / 0 WARN.
@@ -128,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).
+51 -34
View File
@@ -10,19 +10,19 @@
<!-- GEN:START docmap -->
| ## 1. Project Overview | 2843 |
| ## 2. Directory Structure | 44195 |
| ## 3. Installation Flow | 196247 |
| ## 4. The `pos` CLI System | 248320 |
| ## 5. Shared Library — `lib/common.sh` | 321352 |
| ## 6. Docker Compose / ScaleTail | 353395 |
| ## 7. Optional Apps (`apps/`) | 396425 |
| ## 8. Entertainment Module | 426439 |
| ## 9. Systemd Services | 440451 |
| ## 10. Configuration Files | 452478 |
| ## 11. Coding Conventions | 479511 |
| ## 12. Development Workflow | 512564 |
| ## 13. Key File Quick Reference | 565628 |
| ## 14. Common Tasks for Agents | 629661 |
| ## 2. Directory Structure | 44199 |
| ## 3. Installation Flow | 200253 |
| ## 4. The `pos` CLI System | 254330 |
| ## 5. Shared Library — `lib/common.sh` | 331362 |
| ## 6. Docker Compose / ScaleTail | 363405 |
| ## 7. Optional Apps (`apps/`) | 406435 |
| ## 8. Entertainment Module | 436449 |
| ## 9. Systemd Services | 450461 |
| ## 10. Configuration Files | 462488 |
| ## 11. Coding Conventions | 489521 |
| ## 12. Development Workflow | 522574 |
| ## 13. Key File Quick Reference | 575644 |
| ## 14. Common Tasks for Agents | 645678 |
<!-- 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 # Forward to pos ai --provider gemini (backward compat)
│ ├── pos-ai-openrouter # Forward to pos ai --provider openrouter (backward compat)
│ ├── 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)
@@ -80,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
@@ -95,6 +97,8 @@ Linux_post_install/
│ ├── pos-system-firewall # Interactive UFW management
│ ├── pos-system-health # Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL
│ ├── pos-system-schedule # Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently
│ ├── pos-system-uninstall # Remove pos toolkit binaries, services, shell integration, config, and data
│ ├── pos-ai # AI assistant: ask, chat, sessions, capture, models, providers
│ ├── pos-config # Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry)
│ ├── pos-tree # Show the pos CLI command tree: categories, commands, and subcommands
<!-- GEN:END tree -->
@@ -203,7 +207,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
@@ -264,7 +270,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` | Forward to pos ai --provider gemini (backward compat) |
| ai | openrouter | `pos-ai-openrouter` | Forward to pos ai --provider openrouter (backward compat) |
| 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) |
@@ -283,6 +290,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 |
@@ -298,6 +306,8 @@ All non-interactive `pos` commands log output to `~/.local/share/linux_post_inst
| system | firewall | `pos-system-firewall` | Interactive UFW management |
| system | health | `pos-system-health` | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL |
| system | schedule | `pos-system-schedule` | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently |
| system | uninstall | `pos-system-uninstall` | Remove pos toolkit binaries, services, shell integration, config, and data |
| | ai | `pos-ai` | AI assistant: ask, chat, sessions, capture, models, providers |
| | config | `pos-config` | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) |
| | tree | `pos-tree` | Show the pos CLI command tree: categories, commands, and subcommands |
<!-- GEN:END dispatch -->
@@ -345,7 +355,7 @@ source "$(dirname "$0")/../lib/common.sh"
**Scripts that do NOT source common.sh** (self-contained):
<!-- GEN:START selfcontained -->
`pos`, `pos-communication-matrix-listener`, `pos-communication-matrix-sender`, `pos-communication-telegram-listener`, `pos-communication-telegram-sender`, `pos-network-checkport`, `pos-network-hotspot`, `pos-network-ip`, `pos-network-scan`, `pos-ssh-load-keys`, `pos-system-firewall`.
`pos`, `pos-ai-gemini`, `pos-ai-openrouter`, `pos-communication-matrix-listener`, `pos-communication-matrix-sender`, `pos-communication-telegram-listener`, `pos-communication-telegram-sender`, `pos-network-checkport`, `pos-network-hotspot`, `pos-network-ip`, `pos-network-scan`, `pos-ssh-load-keys`, `pos-system-firewall`.
<!-- GEN:END selfcontained -->
---
@@ -462,7 +472,7 @@ All `.service` files in `systemd/` are automatically copied to `/etc/systemd/sys
- `~/.config/linux_post_install/entertainment.env` — entertainment plugin defaults: weather location + `ENABLED` auto-trigger list (`plugin, interval` pairs scheduled via `pos entertainment enable/disable`, systemd user timers); auto-installed from `config/entertainment.env` by `postinstall.sh` (no clobber, template printed)
- `~/.config/linux_post_install/system.env` — shared "system" tool settings (loaded by `pos system health` / `pos system backup` via `load_system_env()` in `lib/common.sh`; env already exported wins over the file); template `config/system.env`
- `~/.config/linux_post_install/notify.env` — alerting platform selection (`NOTIFY_PLATFORM=telegram,matrix`, comma-separated = fan out); read by `lib/notify.sh`; template `config/notify.env`
- `~/.config/linux_post_install/ai.env`Google Gemini config (`AI_GEMINI_API_KEY` secret, `AI_GEMINI_MODEL` default `gemini-2.5-flash`); read by `pos ai gemini`; template `config/ai.env`, auto-installed by postinstall, edit with `pos config ai`
- `~/.config/linux_post_install/ai.env`AI provider config (`AI_PROVIDER`, `AI_API_KEY` secret, `AI_MODEL`, `AI_SYSTEM_PROMPT`, plus legacy fallbacks `AI_GEMINI_API_KEY`, `AI_GEMINI_MODEL`, `OPENROUTER_API_KEY`, `OPENROUTER_MODEL`); read by `pos ai`; template `config/ai.env`, auto-installed by postinstall, edit with `pos config ai`
- `~/.bashrc` — Modified by postinstall (PATH, bash completion)
### Feature Flags
@@ -577,24 +587,27 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
| `lib/scheduler-lib.sh` | 760 | Scheduler lib (job parsing, notify policies, per-job user timers via user-timers-lib, legacy migrate) |
| `lib/user-timers-lib.sh` | 112 | Shared systemd **user** timer machinery (interval→OnCalendar, unit pair writer, linger) |
| `lib/usb-lib.sh` | 205 | Shared USB-storage detection + pick flow (detect/mount-offer/`usb_pick_root`; EFI system partitions excluded; picker shows size/label/fs) — used by `pos system backup` + `pos media sync` |
| `lib/share-lib.sh` | 318 | Domain layer for the share suite (usbsrv/smbclient record parsers, folder+mountpoint candidates, remote listings, service/firewall advisories; EOF-safe) + compat shims to `lib/menu-lib.sh` — used by all five `pos share *` tools |
| `lib/menu-lib.sh` | 169 | Category-neutral interactive menu primitives (`menu_guard` tty guard, `menu_run` looping boxed menu, `menu_pick` type-to-filter picker, `menu_ask_value` prompt-with-default; stderr render, fail-closed on non-tty/EOF) — sourced by `share-lib.sh`, open to any category |
| `bin/flag-reader` | 58 | Inspect flags (list/status/`--raw`) |
| `bin/flag-set` | 21 | Set a flag (optionally with a value) |
| `bin/flag-clear` | 21 | Unset a flag |
| `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` | 294 | 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` | 7 | Forward to pos ai --provider gemini (backward compat) |
| `bin/pos-ai-openrouter` | 7 | Forward to pos ai --provider openrouter (backward compat) |
| `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-stack` | 101 | Containers grouped by compose stack (project); standalone group; -a/--all includes stopped |
| `bin/pos-docker-vbox` | 158 | Disposable Docker-based VMs (create/enter/start/stop/rm/ls) |
| `bin/pos-docker-vbox` | 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 |
@@ -602,25 +615,28 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
| `bin/pos-entertainment-status` | 62 | Show enabled plugins and scheduler state |
| `bin/pos-media-mp3` | 86 | Download audio as MP3 (yt-dlp) |
| `bin/pos-media-mp4` | 132 | Download video as MP4 (smart/interactive format select) |
| `bin/pos-media-sync` | 164 | Incremental Music → USB sync (mp3/mp4, add/update only) |
| `bin/pos-media-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-system-uninstall` | 415 | Remove pos toolkit binaries, services, shell integration, config, and data |
| `bin/pos-ai` | 680 | AI assistant: ask, chat, sessions, capture, models, providers |
| `bin/pos-config` | 80 | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) |
| `bin/pos-tree` | 112 | Show the pos CLI command tree: categories, commands, and subcommands |
| `completions/pos.bash` | 294 | Dynamic bash completion |
| `completions/pos.bash` | 306 | Dynamic bash completion |
<!-- GEN:END filetable -->
| `apps/install.sh` | 171 | App install/uninstall picker/orchestrator |
@@ -652,8 +668,9 @@ 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 AI logic | Edit `bin/pos-ai` (main tool) + `lib/ai-providers/*.sh` (provider adapters); config scope `ai` via `pos config ai`; `AI_API_KEY`/`AI_MODEL`/`AI_PROVIDER` in `~/.config/linux_post_install/ai.env` |
| Modify UFW/firewall logic | Edit `bin/pos-system-firewall` |
| Modify pos logging | Edit log setup in `bin/pos` |
| Modify install phases/flags | Edit arg parsing in `install.sh` |
+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.
+2 -2
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) |
@@ -42,7 +42,7 @@ templates (without overwriting an existing file):
| `system.env` | `pos system health`, `pos system backup` | `BACKUP_SERVICE_ROOTS`, `HEALTH_BACKUP_MAX_AGE_DAYS` |
| `compose.env` | `pos docker compose` | `TS_AUTHKEY`, `TZ`, `DNS_SERVER`, `SERVICES_BASE` |
| `entertainment.env` | `pos entertainment *` | plugin keys (`WEATHER_LAT`…), `ENABLED` |
| `ai.env` | `pos ai gemini` | `AI_GEMINI_API_KEY`, `AI_GEMINI_MODEL` |
| `ai.env` | `pos ai` | `AI_PROVIDER`, `AI_API_KEY`, `AI_MODEL`, `AI_SYSTEM_PROMPT`, `AI_GEMINI_API_KEY`, `AI_GEMINI_MODEL`, `OPENROUTER_API_KEY`, `OPENROUTER_MODEL` |
| `schedule.d/` | `pos system schedule` | one `<name>.env` per job: `INTERVAL`, `NOTIFY`, `MSG`, `RULE`, `COMMAND` |
```bash
+53 -20
View File
@@ -55,28 +55,42 @@ 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).
**File:** `bin/pos-ai` (provider-agnostic main tool), `bin/pos-ai-gemini` / `bin/pos-ai-openrouter` (backward-compat forwarders → `pos ai --provider <name>`)
**Provider adapters:** `lib/ai-providers/gemini.sh`, `lib/ai-providers/openrouter.sh`
**Purpose:** AI assistant with pluggable providers. Six subcommands: `ask` (scriptable, persistent session), `capture` (run a command and save its output for `--last`), `chat` (interactive multi-turn REPL), `models` (list available models), `providers` (list providers and config status), and `sessions` (list/clear sessions). Providers handle API-specific logic; the main tool handles sessions, rendering, machine context, and all shared logic.
| 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 models` | Lists models that support `generateContent` and flags the configured default |
| `pos ai gemini --model <id> …` | Overrides the model for one invocation |
| `pos ai ask "<prompt>"` | Sends the prompt to the active provider (default: gemini) 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; `AI_SYSTEM_PROMPT` env/config provides a custom default). 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 --provider openrouter ask "<prompt>"` | Same, but uses OpenRouter instead of the default Gemini provider |
| `pos ai 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 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 sessions` | Lists session files with turn counts; `sessions reset <name>` clears one (e.g. `reset default`) |
| `pos ai models` | Lists available models for the active provider and flags the configured default |
| `pos ai providers` | Lists available providers, their config status, and the active provider |
| `pos ai --model <id> …` | Overrides the model for one invocation |
| `pos ai --provider <name> …` | Selects the provider for one invocation (gemini\|openrouter) |
`pos ai gemini` 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.
Backward compatibility: `pos ai gemini` and `pos ai openrouter` still work as shorthands for `pos ai --provider gemini` and `pos ai --provider openrouter`.
`pos ai` 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.env`, edit with `pos config ai`):
| Key | Required | Default | Purpose |
|-----|----------|---------|---------|
| `AI_GEMINI_API_KEY` | yes | — | API key from aistudio.google.com (secret — masked in `pos config ai`) |
| `AI_GEMINI_MODEL` | no | `gemini-2.5-flash` | Model id used by `ask`/`chat`/`models` |
| `AI_PROVIDER` | no | `gemini` | Active provider (gemini\|openrouter) |
| `AI_API_KEY` | yes | — | API key for the active provider (secret — masked in `pos config ai`) |
| `AI_MODEL` | no | per provider | Model id used by `ask`/`chat`/`models` |
| `AI_SYSTEM_PROMPT` | no | built-in terse prompt | Custom system prompt (overrides built-in; empty to reset) |
| `AI_GEMINI_API_KEY` | fallback | — | Legacy: Gemini API key (used when `AI_API_KEY` is empty) |
| `AI_GEMINI_MODEL` | fallback | `gemini-2.5-flash` | Legacy: Gemini model id (used when `AI_MODEL` is empty) |
| `OPENROUTER_API_KEY` | fallback | — | Legacy: OpenRouter API key (used when `AI_API_KEY` is empty) |
| `OPENROUTER_MODEL` | fallback | `openrouter/auto` | Legacy: OpenRouter model id (used when `AI_MODEL` is empty) |
Precedence: `--model` flag > `AI_GEMINI_MODEL` env > config file > `gemini-2.5-flash`. `postinstall.sh` copies the repo's `config/ai.env` template to `~/.config/linux_post_install/ai.env` on install (no clobber). Dependencies: `curl` + `jq` (both in `preinstall.sh` PACKAGES).
Model precedence: `--model` flag > `AI_MODEL` env > provider-specific fallback (`AI_GEMINI_MODEL`/`OPENROUTER_MODEL`) > provider default. API key precedence: `AI_API_KEY` env > provider-specific fallback (`AI_GEMINI_API_KEY`/`OPENROUTER_API_KEY`) > error. `postinstall.sh` copies the repo's `config/ai.env` template to `~/.config/linux_post_install/ai.env` on install (no clobber). Dependencies: `curl` + `jq` (both in `preinstall.sh` PACKAGES). Sessions are stored in OpenAI `messages` format universally; old Gemini-format sessions (`contents[]`) are auto-migrated on load.
**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>`).
**Messaging bridges:** the Telegram and Matrix listeners forward non-command messages starting with `ai ` (case-insensitive) to `pos ai 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>`).
### network
@@ -133,6 +147,8 @@ Runs a persistent `aria2c` JSON-RPC daemon (`localhost:6800`) as a **systemd use
| `pos network download retry <gid\|all>` | Smart retry of errored downloads: waits out internet outages (poll `--interval`, give up after `--max-wait`), then re-queues and re-verifies. Sources failing with aria2 error 3 are marked permanent in `~/.config/linux_post_install/download.retry` (id = `url:<uri>` / `bt:<infohash>`) and skipped by `retry all` — manual `restart` overrides. `--once` (healer timer mode) skips the wait and exits 0 even on failure; `--quiet` silences output. Options `--dir`, `--seed`, `--split`, `--tmux` |
| `pos network download replace <gid> <url>` | Give a dead download a fresh URL: re-queues with the same `dir` + file name (so `--continue=true` resumes the partial), forgets the old dead source from `download.retry`, and verifies the new link — a dead replacement is diagnosed and marked permanent. `status` lists downloads needing this. Single-file HTTP/FTP only (torrents: `restart`); options `--dir`, `--split`, `--tmux` |
**`pos network download menu`** — bare invocation on a terminal (or the explicit `menu` subcommand) opens an interactive hub over the top verbs: daemon status, overview (status + queue snapshot), list, add URL (asked via a prompt, with an optional `--tmux` handover), gid-pick → info / pause / resume / remove / restart (remove behind an explicit y/N confirm naming the download), purge (typed-`purge` confirm), live watch (Ctrl-C leaves the menu), and daemon start/stop (stop behind a y/N confirm). Queue views are gated on a non-fatal RPC liveness probe — with the daemon down you get a graceful hint and stay in the menu instead of an error exit. Arguments stay scriptable; without a terminal the menu fails closed with a pointer to these subcommands.
**`--tmux`:** after enqueueing, `add`/`torrent`/`metalink` open a detached tmux session `dl-<name>` running `watch <gid>` (name from `--out` or the URL basename, sanitized and truncated to 40 chars; `-2` suffix on collision). The session closes itself when the download finishes — attach with `tmux attach -t dl-<name>`.
**Outage resilience:** `watch <gid>` auto-restarts its download when the network comes back (it polls `NET_PROBE`, default `timeout 3 bash -c '</dev/tcp/8.8.8.8/53'`). For unattended machines the **retry healer** timer (`pos-aria2-retry.timer`, systemd **user** scope) runs `retry all --once --quiet` every 2 min; it arms automatically whenever a download starts (`add`/`torrent`/`metalink`/`restart`) and disables itself when no active, waiting, or errored downloads remain. When a source is genuinely gone (aria2 error 3, e.g. a 404), the download is marked permanent — `status` prints a `needs fresh link: <name> (<gid>)` line and `replace <gid> <url>` resumes it with a new URL. Both are dry-run aware.
@@ -172,6 +188,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 |
@@ -196,11 +214,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
@@ -209,17 +229,30 @@ 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 |
| `pos system uninstall` | `bin/pos-system-uninstall` | Safe, interactive uninstaller for the pos toolkit — scans and removes binaries, services, shell integration, config, and data in three tiers | Tier 1 (always): binaries in `/usr/local/bin/` (pos, pos-*, libs, ai-providers, entertainment plugins, prebuilt, features), systemd services (disable+remove), shell integration in `~/.bashrc` (PATH, completion, pos-ai-hook source), completion file. Tier 2 (`--config`): `~/.config/linux_post_install/` (.env files, schedule.d/, authorized_keys, rclone.conf). Tier 3 (`--data`): `~/.local/share/linux_post_install/` (ai sessions, logs, captured output). Flags: `--yes` (skip prompts, tier 1 only), `--config` (include tier 2), `--data` (include tier 3). Combine all three for nuclear removal. Git repo is never removed |
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).
@@ -252,14 +285,14 @@ Share files and devices over the network (USB over network, NFS, SMB/Samba).
| `pos share usb server --port [num]` | Set the TCP port (restart server to apply) |
| `pos share usb server --info` / `--version` | Show server info / version |
Subcommands that need input prompt interactively when args are omitted.
Subcommands that need input prompt interactively when args are omitted. Bare invocation (`pos share usb server`, no args) opens an interactive menu wrapping all of the above — device/client pickers parse the server listing, and when the listing can't be read it is shown raw with manual ID entry as fallback.
| Command | File | Purpose | Configuration |
|---------|------|---------|---------------|
| `pos share nfs server <cmd>` | `bin/pos-share-nfs-server` | Manage the NFS kernel server: `status`, `share <path> [client]`, `unshare <path>`, `list`, `reload`, `enable`, `disable` | Requires `nfs-kernel-server` (added to `preinstall.sh` PACKAGES). Exports live in `/etc/exports`; `share` is idempotent (replaces any existing line for the path) and runs `exportfs -ra`. Default client `*(rw,sync,no_subtree_check)` — the tool warns you to restrict it; help prints Tailscale CGNAT (`100.64.0.0/10`), WireGuard (`10.10.0.0/24`) and LAN examples. Mutating commands announce via `lib/notify.sh` |
| `pos share nfs client <cmd>` | `bin/pos-share-nfs-client` | Mount and manage NFS shares: `mount <server:export> <local-dir>`, `unmount <local-dir>`, `list`, `persist <server:export> <local-dir>`, `unpersist <local-dir>` | Requires `nfs-common` (added to `preinstall.sh` PACKAGES). `persist` writes a systemd `.mount` unit (`systemd-escape --path --suffix=mount`) with `After=network-online.target` / `Wants=network-online.target` — mounts only once all interfaces are up, no fstab edits to break boot — then `daemon-reload` + `enable --now`. `unpersist` stops/disables/removes the unit. `mount`/`persist` announce via `lib/notify.sh` |
| `pos share smb server <cmd>` | `bin/pos-share-smb-server` | Manage the Samba server: `status`, `share <path> [name] [--read-only|--guest|--users u1,u2]`, `unshare <name>`, `list`, `adduser <user>`, `deluser <user>`, `reload`, `enable`, `disable` | Requires `samba` (added to `preinstall.sh` PACKAGES). Shares are idempotent marker blocks (`# >>> pos-managed share: <name>``# <<< end pos-managed share`) in `/etc/samba/smb.conf` — hand edits outside the markers survive; `share` validates with `testparm` before applying and hot-reloads via `smbcontrol smbd reload-config`. Defaults rw + browsable; warns when unrestricted (guest or no `valid users`). `adduser`/`deluser` manage Samba accounts via `smbpasswd`. Mutating commands announce via `lib/notify.sh` |
| `pos share smb client <cmd>` | `bin/pos-share-smb-client` | Mount and manage SMB/CIFS shares: `mount <//server/share> <local-dir> [user]`, `unmount <local-dir>`, `list`, `persist <//server/share> <local-dir> [user]`, `unpersist <local-dir>` | Requires `cifs-utils` (added to `preinstall.sh` PACKAGES). With a user you are prompted for the Samba password — one-shot mounts use a throwaway chmod-600 credentials file, `persist` keeps one at `/etc/samba/credentials/<name>` (chmod 600). `persist` writes systemd `.mount` **and** `.automount` units (`systemd-escape --path --suffix=mount`) with `_netdev` — the automount defers the actual mount until first access, never blocks boot — then `daemon-reload` + `enable --now` the automount. `unpersist` stops/disables/removes both units + credentials. `list` shows active mounts (`findmnt -t cifs`) **and** persistent units (as automount shares aren't mounted until first access, they'd otherwise be invisible). `mount`/`persist` announce via `lib/notify.sh` |
| `pos share nfs server <cmd>` | `bin/pos-share-nfs-server` | Manage the NFS kernel server: `status`, `share <path> [client]`, `unshare <path>`, `list`, `reload`, `enable`, `disable` | Requires `nfs-kernel-server` (added to `preinstall.sh` PACKAGES). Exports live in `/etc/exports`; `share` is idempotent (replaces any existing line for the path) and runs `exportfs -ra`. Default client `*(rw,sync,no_subtree_check)` — the tool warns you to restrict it; help prints Tailscale CGNAT (`100.64.0.0/10`), WireGuard (`10.10.0.0/24`) and LAN examples. Mutating commands announce via `lib/notify.sh`. Bare invocation opens an interactive menu (share/unshare/list/reload/enable/disable) — the share flow picks a folder from mounted candidates, names it, and offers client-spec presets (open/WireGuard/LAN/single-IP); inactive-service and UFW conflicts are surfaced as optional fixes |
| `pos share nfs client <cmd>` | `bin/pos-share-nfs-client` | Mount and manage NFS shares: `mount <server:export> <local-dir>`, `unmount <local-dir>`, `list`, `persist <server:export> <local-dir>`, `unpersist <local-dir>` | Requires `nfs-common` (added to `preinstall.sh` PACKAGES). `persist` writes a systemd `.mount` unit (`systemd-escape --path --suffix=mount`) with `After=network-online.target` / `Wants=network-online.target` — mounts only once all interfaces are up, no fstab edits to break boot — then `daemon-reload` + `enable --now`. `unpersist` stops/disables/removes the unit. `mount`/`persist` announce via `lib/notify.sh`. Bare invocation opens an interactive menu (mount/persist/unmount/unpersist/list) with mountpoint candidates + manual entry; unmount/persist removals are idempotent (already-absent targets are reported, not errors) |
| `pos share smb server <cmd>` | `bin/pos-share-smb-server` | Manage the Samba server: `status`, `share <path> [name] [--read-only|--guest|--users u1,u2]`, `unshare <name>`, `list`, `adduser <user>`, `deluser <user>`, `reload`, `enable`, `disable` | Requires `samba` (added to `preinstall.sh` PACKAGES). Shares are idempotent marker blocks (`# >>> pos-managed share: <name>``# <<< end pos-managed share`) in `/etc/samba/smb.conf` — hand edits outside the markers survive; `share` validates with `testparm` before applying and hot-reloads via `smbcontrol smbd reload-config`. Defaults rw + browsable; warns when unrestricted (guest or no `valid users`). `adduser`/`deluser` manage Samba accounts via `smbpasswd`. Mutating commands announce via `lib/notify.sh`. Bare invocation opens an interactive menu (share/unshare/list/users/reload/enable/disable) — the share flow picks a folder from mounted candidates and walks through read-only/guest/valid-users confirms; UFW conflicts are surfaced as an optional fix |
| `pos share smb client <cmd>` | `bin/pos-share-smb-client` | Mount and manage SMB/CIFS shares: `mount <//server/share> <local-dir> [user]`, `unmount <local-dir>`, `list`, `persist <//server/share> <local-dir> [user]`, `unpersist <local-dir>` | Requires `cifs-utils` (added to `preinstall.sh` PACKAGES). With a user you are prompted for the Samba password — one-shot mounts use a throwaway chmod-600 credentials file, `persist` keeps one at `/etc/samba/credentials/<name>` (chmod 600). `persist` writes systemd `.mount` **and** `.automount` units (`systemd-escape --path --suffix=mount`) with `_netdev` — the automount defers the actual mount until first access, never blocks boot — then `daemon-reload` + `enable --now` the automount. `unpersist` stops/disables/removes both units + credentials. `list` shows active mounts (`findmnt -t cifs`) **and** persistent units (as automount shares aren't mounted until first access, they'd otherwise be invisible). `mount`/`persist` announce via `lib/notify.sh`. Bare invocation opens an interactive menu (enumerate/mount/persist/unmount/unpersist/list): it can enumerate Disk shares via smbclient (empty user = guest try, auth retry on denial), pick a share + mountpoint from candidates (manual entry fallback), and reuses the authenticated account for the mount |
### communication
+15 -1
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) |
@@ -214,6 +214,20 @@ Sourced by `bin/pos-entertainment-send|config|enable|disable|status` (after `lib
---
## lib/share-lib.sh — share-suite domain layer + compat shims
**File:** `lib/share-lib.sh` (installed to `/usr/local/bin/share-lib.sh`)
**Purpose:** the domain probes/listings behind the five share tools (`pos share nfs server`, `pos share nfs client`, `pos share smb client`, `pos share smb server`, `pos share usb server`) — rc-only probes `share_require_bin`/`share_port_probe`/`share_service_active`/`share_path_probe`, remote listings `share_nfs_exports` (showmount) / `share_smb_shares` (smbclient `-g` Disk enumeration incl. guest→auth retry) / `share_usb_records` + `share_usb_devices`/`share_usb_clients` (blank-line-record usbsrv parsers), `share_folder_candidates` (bounded-probe scan — findmnt targets minus pseudo-fs/ro plus immediate dirs under `/mnt` `/srv` `/media` `/export`, annotated + byte-order sorted; the client tools layer their own mountpoint pickers on top), and advisories (`share_ufw_blocks_ports` for firewall conflicts + `share_offer_fix`, which also uses `share_service_active` to offer starting inactive services). Also sources `lib/menu-lib.sh` and re-exports its primitives under the historical names (`share_menu_guard`, `share_menu_run`, `share_pick`, `share_ask_value`) so the five tools' menus need no changes. Defines only `share_*`; never exits, writes nothing of its own, display→stderr/result→stdout; no seams of its own — the tools own their file/service seams.
---
## lib/menu-lib.sh — category-neutral menu primitives
**File:** `lib/menu-lib.sh` (installed to `/usr/local/bin/menu-lib.sh`)
**Purpose:** the generic interactive half extracted from the former share-lib interactive layer, open to any category's tool — `menu_guard` (non-tty guard: one-line hint instead of a hanging menu), `menu_run` (looping numbered boxed menu, quits on EOF), `menu_pick` (numbered picker with type-to-filter `/filter`, `0` cancel, EOF-safe), and `menu_ask_value` (prompted string with optional default). Sourced by `lib/share-lib.sh` (compat shims keep the `share_*` names working); defines only `menu_*`. Display→stderr / result→stdout, reads fail closed on EOF or non-tty, so the functions are safe under the dispatcher's logging tee and inside command substitution; standalone-sourced it degrades to plain text via guarded `CYAN`/`RESET` fallbacks.
---
## features/autostart.sh — boot-time feature
**File:** `features/autostart.sh` (installed to `/usr/local/bin/autostart.sh` by `./install.sh --feature`)
+166 -30
View File
@@ -1,18 +1,60 @@
# 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.
Tool: `pos ai` with pluggable provider adapters (`gemini`, `openrouter`).
| 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 models` | List available model ids |
| `pos ai gemini sessions` | List/clear persistent sessions (`reset <name>`) |
| Command | What it does |
|---------|--------------|
| `pos ai 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 --provider openrouter ask "<prompt>"` | Same, but uses OpenRouter instead of the default Gemini provider |
| `pos ai 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 capture <cmd>` | Run any command, capture its output for `--last`, and show it on screen |
| `pos ai ask --session <name> "…"` | Same, but uses a named session instead of `default` |
| `pos ai chat` | Interactive multi-turn conversation (session `default` unless `--session`) |
| `pos ai models` | List available model ids for the active provider |
| `pos ai providers` | List all available providers and their config status |
| `pos ai 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: `--provider <name>` selects the backend (gemini|openrouter;
default: gemini; also settable via `AI_PROVIDER` env/config); `--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`.
Backward compatibility: `pos ai gemini` and `pos ai openrouter` still work as
shorthand for `pos ai --provider gemini` and `pos ai --provider openrouter`.
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 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.
---
@@ -23,23 +65,84 @@ system instruction to every turn (kept out of the session file).
2. Configure it (masked input):
```bash
pos config ai # enter AI_GEMINI_API_KEY
pos config ai # enter AI_API_KEY (or AI_GEMINI_API_KEY)
```
3. Test:
```bash
pos ai gemini ask "Explain DNS in one line"
pos ai gemini models # verify the default model id is live
pos ai gemini chat # multi-turn conversation
pos ai ask "Explain DNS in one line"
pos ai models # verify the default model id is live
pos ai chat # multi-turn conversation
```
`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. Use `--provider openrouter` to switch:
```bash
pos ai --provider openrouter ask "hi"
# or the legacy shorthand:
pos ai openrouter ask "hi"
```
Configure the API key:
```bash
pos config ai # enter AI_API_KEY (or OPENROUTER_API_KEY)
```
The default model is `openrouter/auto` (OpenRouter picks the best available
provider automatically). Override with `--model provider/model-name`:
```bash
pos ai --provider openrouter ask --model anthropic/claude-sonnet-4 "explain DNS"
```
All features work the same way across providers — `--last` for diagnosing
failures, `--system` for custom instructions, `--full` for long-form answers,
persistent sessions, tty markdown rendering, and machine context. Sessions are
shared in `~/.local/share/linux_post_install/ai/` (universal messages format).
Switch providers per-invocation:
```bash
pos ai ask "hello" # uses gemini (default)
pos ai --provider openrouter ask "hello" # uses openrouter
```
Or set the default via config:
```bash
pos config ai # set AI_PROVIDER=openrouter
```
## Provider architecture
`pos ai` uses a pluggable provider system. Each provider is a thin adapter
in `lib/ai-providers/<name>.sh` that handles the API-specific logic (auth,
request format, response parsing). The main tool handles sessions, rendering,
machine context, and all shared logic.
Available providers:
| Provider | API | Default model | Config key |
|----------|-----|---------------|------------|
| `gemini` | Google Gemini REST API | `gemini-2.5-flash` | `AI_GEMINI_API_KEY` |
| `openrouter` | OpenRouter (OpenAI-compatible) | `openrouter/auto` | `OPENROUTER_API_KEY` |
Adding a new provider: create `lib/ai-providers/<name>.sh` implementing
`provider_name()`, `provider_default_model()`, `provider_generate()`, and
`provider_models_list()`. See the existing adapters for the interface contract.
## From the Telegram bot
Once `pos ai gemini ask` works, any non-command message starting with `ai ` is
Once `pos ai ask` works, any non-command message starting with `ai ` is
answered by the model — no bot map entry needed:
```
@@ -48,7 +151,7 @@ bot: NVIDIA is a company best known for GPUs...
```
The bridge lives in the Telegram listener's `handle_message` (it calls
`pos ai gemini ask`); only the owner chat is served, so your key stays private.
`pos ai ask`); only the owner chat is served, so your key stays private.
Set a different model per message:
```
@@ -57,7 +160,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 +177,55 @@ you: ai check this details about my linux ← reply to the /status message
## Recipes
- **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`
- **Diagnose the last failed pos run:** `pos ai 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 ask how do I fix this`
- **Answer from a file:** `pos ai ask "$(cat notes.txt)"`
- **Answer in a cron job:** `pos ai ask "summarize today's git log" > /tmp/ai_digest.txt`
- **Long-form on demand:** `pos ai ask --full "compare ext4 and zfs in depth"`
- **Forget what the terminal asked:** `pos ai sessions reset default`
- **Switch to OpenRouter:** `pos ai --provider openrouter ask "hi"`
- **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"
pos config ai # set AI_MODEL, or:
AI_MODEL=gemini-2.5-flash pos ai ask "hi"
```
- **List available providers:** `pos ai providers`
## 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 capture pip install xyz
pos ai 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
`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.
- `ask` sends the session history (OpenAI `messages` format) to the active
provider's API. Gemini converts to `contents` format internally; OpenRouter
sends `messages` directly. The answer text is printed to stdout.
- 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. Old
Gemini-format sessions (`contents[]`) are auto-migrated to `messages` format
on load.
- `chat` keeps the whole conversation in memory as a growing `messages[]`
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.
@@ -98,7 +234,7 @@ you: ai check this details about my linux ← reply to the /status message
- `ask` errors "No Gemini API key — run 'pos config ai'" → the key isn't set
(or `ai.env` isn't readable). Run `pos config ai`.
- `API error 400` → the model id is wrong or the prompt is too long for the
model's context window; check `pos ai gemini models`.
model's context window; check `pos ai models`.
- `API error 429` → rate limit (free tier); wait and retry, or use a different
model.
- Nothing in Telegram for `ai …` → the listener daemon must be running
+14
View File
@@ -136,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
@@ -143,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
@@ -154,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`).
---
+92 -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
@@ -147,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
@@ -157,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
+33 -2
View File
@@ -1,13 +1,14 @@
# How-To: `pos system`
Host care: encrypted backups, firewall, and the health dashboard. Tools:
`backup`, `firewall`, `health`.
Host care: encrypted backups, firewall, health dashboard, and uninstall. Tools:
`backup`, `firewall`, `health`, `uninstall`.
| Tool | What it does |
|------|--------------|
| `pos system health` | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker) |
| `pos system backup` | gpg-encrypted (AES-256) folder snapshots |
| `pos system firewall` | Interactive UFW ("UFW POWER") management |
| `pos system uninstall` | Safe, interactive uninstaller for the pos toolkit |
---
@@ -204,6 +205,36 @@ not).
`sudo ufw allow 22/tcp`, then `sudo ufw reload`.
- `ufw reset` requires typing `RESET` — deliberate.
## `pos system uninstall` — remove the pos toolkit
```bash
pos system uninstall # interactive scan + confirm tier 1
pos system uninstall --yes # non-interactive, tier 1 only
pos system uninstall --yes --config --data # remove everything (nuclear option)
```
Scans the system for installed pos components and removes them in three tiers:
| Tier | What it removes | How to include |
|------|----------------|----------------|
| **Tier 1** | Binaries (`/usr/local/bin/pos*`, libs, entertainment plugins, prebuilt, features), systemd services (disable+remove), shell integration (`~/.bashrc` PATH/completion/pos-ai-hook entries), completion file | Always (default) |
| **Tier 2** | Config files (`~/.config/linux_post_install/` — `.env` files, `schedule.d/`, `authorized_keys`, `rclone.conf`) | `--config` flag |
| **Tier 3** | Session/log data (`~/.local/share/linux_post_install/` — AI sessions, logs, captured output) | `--data` flag |
The default mode is interactive: it shows what will be removed and asks for
confirmation. The git repo is **never** removed — delete it manually if desired.
**Recipes:**
- Quick cleanup: `pos system uninstall --yes`
- Full wipe: `pos system uninstall --yes --config --data`
- Safe preview: run `pos system uninstall` without `--yes` to see the plan first
**Troubleshooting:**
- "Nothing to remove" → pos toolkit is not installed (or already removed)
- After uninstall, run `source ~/.bashrc` or restart your shell
---
## Related
- Reference: [DOC/POS.md → system](../POS.md)
+2 -1
View File
@@ -153,6 +153,7 @@ EXAMPLES
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
@@ -258,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 system-uninstall share-usb-server share-smb-server share-smb-client share-nfs-client share-nfs-server communication-telegram-listener communication-matrix-listener ai ai-gemini ai-openrouter system-schedule entertainment-config config"
for ((i=n-1; i>=0; i--)); do
cmd="pos"
Executable
+680
View File
@@ -0,0 +1,680 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: ai ask — AI assistant: ask, chat, sessions, capture, models, providers
# POS_SUBCMDS: ask chat sessions capture models providers
# POS_FLAGS: --provider --model --session --system --full --last
# POS_CONFIG: ai | ai.env | AI_PROVIDER=:Provider (gemini or openrouter, default gemini) | *providers | AI_SYSTEM_PROMPT=:Custom system prompt (overrides built-in, empty to reset)
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
# ── Paths & constants ──────────────────────────────────────────
CONFIG_FILE="$HOME/.config/linux_post_install/ai.env"
SESSION_DIR="$HOME/.local/share/linux_post_install/ai"
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)
PROVIDER_DIR="$(dirname "$0")/../lib/ai-providers"
# Fallback for installed layout (flat /usr/local/bin)
[ -d "$PROVIDER_DIR" ] || PROVIDER_DIR="$(dirname "$0")/ai-providers"
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_HARD="You are a Linux CLI assistant. Rules:
1. Lead with exact command(s) — no explanations unless asked
2. One line max per command; short bullets for multi-step only
3. No greetings, no pleasantries, no closing offers
4. For errors: diagnose and give the fix command first
5. Match the user's OS/package manager (apt/dnf/pacman)"
# Legacy: kept for session migration and backward compat config
LEGACY_GEMINI_CONFIG="$HOME/.config/linux_post_install/ai.env"
LEGACY_OPENROUTER_CONFIG="$HOME/.config/linux_post_install/ai-openrouter.env"
usage() {
cat <<EOF
Usage: pos ai [subcommand] [--provider <name>] [--model <id>] [--session <name>] [--system <text>] [--full] [--last]
AI assistant with pluggable providers (gemini, openrouter).
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 for the active provider.
providers List available providers and their config status.
sessions List persistent sessions / clear one:
'sessions' and 'sessions reset <name>'.
Options:
--provider <name> Provider to use (gemini|openrouter; default: gemini).
Can also be set via AI_PROVIDER env/config.
--model <id> Override the model for this invocation.
--session <name> Use a named persistent session instead of 'default':
~/.local/share/linux_post_install/ai/<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')
AI_PROVIDER Provider to use (gemini|openrouter, default gemini)
AI_SYSTEM_PROMPT Custom system prompt (overrides built-in; empty to reset)
Provider keys: auto-discovered from lib/ai-providers/*.sh
(AI_GEMINI_API_KEY, OPENROUTER_API_KEY, etc.)
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 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 ask "check disk space on /"
pos ai --provider gemini ask "Explain DNS in one line"
pos ai --provider openrouter ask "hi"
echo "summarize this log" | pos ai ask
failing-cmd 2>&1 | pos ai ask how do I fix this
pos ai ask --last "why did that fail?" # attach last output
pos ai capture pip install xyz # capture any command
pos ai ask --last "what happened?" # after capture
pos ai chat
pos ai models
pos ai providers
pos ai ask --model gemini-2.5-flash "hi"
pos ai ask --system "Reply like a pirate" "explain chmod"
pos ai ask --session work "my name is joe"
pos ai ask --session work "what is my name?" # remembers
pos ai sessions
pos ai sessions reset default # forget default memory
EOF
exit 0
}
# ── Provider loading ───────────────────────────────────────────
load_provider() {
local p="${PROVIDER:-gemini}"
local f="$PROVIDER_DIR/$p.sh"
[ -f "$f" ] || err "Unknown provider '$p' — available: $(ls "$PROVIDER_DIR"/*.sh 2>/dev/null | xargs -I{} basename {} .sh | tr '\n' ' ')"
# shellcheck source=/dev/null
source "$f"
}
# ── ai.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)
# Legacy provider-specific config files (fallback for old configs)
# Both files are loaded — env-var precedence means unified AI_API_KEY wins.
local legacy_files="$LEGACY_GEMINI_CONFIG $LEGACY_OPENROUTER_CONFIG"
local legacy_env
for legacy_env in $legacy_files; do
[ -f "$legacy_env" ] && [ "$legacy_env" != "$CONFIG_FILE" ] || continue
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_]+=' "$legacy_env" || true)
done
}
# ── Config resolution ──────────────────────────────────────────
resolve_key() {
load_config
local p="${PROVIDER:-gemini}"
# Each provider has its own API key — set AI_API_KEY internally for adapters
case "$p" in
gemini) [ -n "${AI_GEMINI_API_KEY:-}" ] && export AI_API_KEY="$AI_GEMINI_API_KEY" && return 0 ;;
openrouter) [ -n "${OPENROUTER_API_KEY:-}" ] && export AI_API_KEY="$OPENROUTER_API_KEY" && return 0 ;;
esac
return 1
}
require_key() {
if ! resolve_key >/dev/null 2>&1; then
local p="${PROVIDER:-gemini}"
case "$p" in
gemini) err "No Gemini API key — run 'pos config ai' and set AI_GEMINI_API_KEY" ;;
openrouter) err "No OpenRouter API key — run 'pos config ai' and set OPENROUTER_API_KEY" ;;
esac
err "No API key for provider '$p' — run 'pos config ai'"
fi
}
resolve_model() {
local p="${PROVIDER:-gemini}"
if [ -n "${MODEL_OVERRIDE:-}" ]; then
printf '%s' "$MODEL_OVERRIDE"
elif [ -n "${AI_MODEL:-}" ]; then
printf '%s' "$AI_MODEL"
else
# Provider-specific fallback
case "$p" in
gemini) [ -n "${AI_GEMINI_MODEL:-}" ] && printf '%s' "$AI_GEMINI_MODEL" && return ;;
openrouter) [ -n "${OPENROUTER_MODEL:-}" ] && printf '%s' "$OPENROUTER_MODEL" && return ;;
esac
provider_default_model
fi
}
# ── --last: attach the most recent pos command output ───────────
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
}
last_log_context() {
local raw
raw="$(tail -c "$LAST_LOG_MAX_BYTES" "$1")"
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
}
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
}
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
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 ask "what happened"\n' "$age" >&2
fi
}
# ── Persistent session memory (universal OpenAI messages format) ─
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" ]; then
# Check for old Gemini contents format → migrate transparently
if jq -e '.contents' "$f" >/dev/null 2>&1 && ! jq -e '.messages' "$f" >/dev/null 2>&1; then
local migrated
migrated="$(jq -c '{messages: [.contents[]? | {role: (if .role == "model" then "assistant" else .role end), content: (.parts | map(.text) | join(""))}]}' "$f" 2>/dev/null)" || {
printf '{"messages":[]}'; return 0
}
printf '%s\n' "$migrated" > "$f"
chmod 600 "$f"
printf '%s' "$migrated"
elif jq -e '.messages' "$f" >/dev/null 2>&1; then
cat "$f"
else
printf '{"messages":[]}'
fi
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"':]'
}
# ── Terminal markdown rendering (tty-only; raw bytes otherwise) ──
render_markdown() {
local text="$1"
# Check stdout tty OR controlling terminal (/dev/tty) — the shell hook
# (pos-ai-hook.sh) redirects stdout through tee, breaking [ -t 1 ], but
# /dev/tty remains writable in interactive shells.
if [ ! -t 1 ] && [ ! -w /dev/tty ]; then
printf '%s\n' "$text"
return 0
fi
local rendered prog
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, /`[^`]*`/)) {
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"
}
# ── Command extraction from AI responses ────────────────────────
_extract_commands() {
local text="$1"
printf '%s' "$text" | awk '
/^```(bash|sh|shell)/ { in_block=1; next }
/^```/ { if (in_block) in_block=0; next }
in_block && NF > 0 { lines[++n] = $0 }
END {
for (i = 1; i <= n; i++) {
if (i > 1) printf "\n"
printf "%s", lines[i]
}
}
'
}
# ── Interactive prompt to run extracted commands ─────────────────
_prompt_run_command() {
local cmd="$1"
# Only prompt on interactive terminals with a controlling tty
[ -w /dev/tty ] || return 0
printf '\n%s\n' "Command detected:" >&2
printf ' %s\n\n' "$cmd" >&2
printf 'Run this command? [Y/n] ' >&2
local choice
IFS= read -r choice </dev/tty || choice=""
case "${choice,,}" in
n|N)
# Add to shell history so user can press ↑ to recall, edit, run
history -s "$cmd" 2>/dev/null || true
printf '%s\n' "Command added to history — press ↑ to recall, edit, and run." >&2
;;
*)
# Y or Enter: execute
printf '%s\n' "$cmd"
run eval "$cmd"
;;
esac
}
# ── Machine context appended to the built-in default prompt ─────
mc_clean() {
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=""
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
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
[ -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)"
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"
}
# ── Subcommands ────────────────────────────────────────────────
cmd_capture() {
[ $# -gt 0 ] || err "usage: pos ai 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 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"
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 capture <cmd>' first, or pipe: cmd 2>&1 | pos ai 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).
system="$SYSTEM_PROMPT"
if [ -z "$system" ] && [ "$FULL_MODE" -eq 0 ]; then
# Check AI_SYSTEM_PROMPT config first, then fall back to built-in
system="${AI_SYSTEM_PROMPT:-}"
if [ -z "$system" ]; then
system="$DEFAULT_SYSTEM_PROMPT_HARD"
fi
mc="$(machine_context)"
[ -n "$mc" ] && mc=" $mc"
system="$system$mc"
fi
# Persistent session memory ('default' unless --session).
messages="$(session_load)"
messages="$(session_push "$messages" user "$prompt")"
if ! out="$(provider_generate "$(resolve_model)" "$messages" "$system" 2>&1)"; then
err "$out"
fi
messages="$(session_push "$messages" assistant "$out")"
session_save "$messages"
render_markdown "$out"
# Command execution prompt: extract commands from response and offer to run
local _cmd
_cmd="$(_extract_commands "$out")"
[ -n "$_cmd" ] && _prompt_run_command "$_cmd"
}
cmd_chat() {
[ $# -eq 0 ] || err "Unexpected argument for chat: $*"
local model messages text answer provider_display
model="$(resolve_model)"
require_key
provider_display="$(provider_name)"
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 "${provider_display} · ${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="$(provider_generate "$model" "$messages" "$SYSTEM_PROMPT" 2>&1)"; then
warn "AI error: $answer"
continue
fi
messages="$(session_push "$messages" assistant "$answer")"
session_save "$messages"
printf '\n'
render_markdown "$answer"
# Command execution prompt: extract commands from response and offer to run
local _cmd
_cmd="$(_extract_commands "$answer")"
[ -n "$_cmd" ] && _prompt_run_command "$_cmd"
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 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
model="$(resolve_model)"
require_key
provider_models_list "$model"
}
cmd_providers() {
echo "Available providers:"
load_config # ensure env vars are populated
local active="${PROVIDER:-gemini}"
for f in "$PROVIDER_DIR"/*.sh; do
[ -f "$f" ] || continue
local name pname pmodel configured current
name="$(basename "$f" .sh)"
# Source provider in a subshell to get its metadata
local meta
meta="$( ( source "$f"; printf '%s\x00%s' "$(provider_name)" "$(provider_default_model)" ) 2>/dev/null )" || true
pname="${meta%%$'\x00'*}"
pmodel="${meta#*$'\x00'}"
[ -n "$pname" ] || pname="$name"
[ -n "$pmodel" ] || pmodel="unknown"
# Check if API key exists for this provider
configured="not configured"
case "$name" in
gemini) [ -n "${AI_GEMINI_API_KEY:-}" ] && configured="configured" ;;
openrouter) [ -n "${OPENROUTER_API_KEY:-}" ] && configured="configured" ;;
esac
current=""
[ "$name" = "$active" ] && current=" ← active"
printf ' %-16s %s (model: %s)%s\n' "$name" "$configured" "$pmodel" "$current"
done
}
# ── Parse flags + subcommand ────────────────────────────────────
MODEL_OVERRIDE=""
FULL_MODE=0
LAST_MODE=0
PROVIDER=""
cmd=""
args=()
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage ;;
--provider)
[ $# -ge 2 ] || err "--provider needs a value"
PROVIDER="$2"; shift 2 ;;
--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
# Resolve provider: --provider flag > AI_PROVIDER env/config > default gemini
if [ -z "$PROVIDER" ]; then
load_config
PROVIDER="${AI_PROVIDER:-gemini}"
fi
# Load provider adapter functions
load_provider
if [ "$LAST_MODE" -eq 1 ] && [ "${cmd:-}" != "ask" ]; then
err "--last only applies to 'ask' — for capturing output use 'capture': pos ai capture <cmd>"
fi
case "${cmd:-}" in
"") usage ;;
ask) cmd_ask "${args[@]}" ;;
capture) cmd_capture "${args[@]}" ;;
chat) cmd_chat "${args[@]}" ;;
models) cmd_models "${args[@]}" ;;
providers) cmd_providers "${args[@]}" ;;
sessions) cmd_sessions "${args[@]}" ;;
*) err "Unknown ai subcommand '$cmd' (see --help)" ;;
esac
+5 -309
View File
@@ -1,311 +1,7 @@
#!/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_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"
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=""
SYSTEM_PROMPT=""
MAX_SESSION_TURNS=40
usage() {
cat <<EOF
Usage: pos ai gemini <subcommand> [--model <id>] [--session <name>] [--system <text>]
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.
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
~/.local/share/linux_post_install/ai/<name>.json
(capped at $MAX_SESSION_TURNS turns). ask without
--session stays one-shot.
--system <text> System instruction sent with every turn (kept out of the
session file), e.g. "Reply like a friendly Telegram chat".
-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)
Examples:
pos ai gemini ask "Explain DNS in one line"
echo "summarize this log" | pos ai gemini ask
pos ai gemini chat
pos ai gemini models
pos ai gemini ask --model gemini-2.5-flash "hi"
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
EOF
exit 0
}
# ── ai.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 "${AI_GEMINI_API_KEY:-}" ] || err "No Gemini API key — run 'pos config ai'"
}
resolve_model() {
if [ -n "${MODEL_OVERRIDE:-}" ]; then
printf '%s' "$MODEL_OVERRIDE"
elif [ -n "${AI_GEMINI_MODEL:-}" ]; then
printf '%s' "$AI_GEMINI_MODEL"
else
printf '%s' "$DEFAULT_MODEL"
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_file() {
local name="${1:-$SESSION}"
name="${name//[^A-Za-z0-9_-]/_}"
printf '%s/%s.json' "$SESSION_DIR" "$name"
}
session_load() {
[ -n "$SESSION" ] || { printf '{"contents":[]}'; return 0; }
local f
f="$(session_file)"
if [ -s "$f" ] && jq -e '.contents' "$f" >/dev/null 2>&1; then
cat "$f"
else
printf '{"contents":[]}'
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 contents="$1" role="$2" text="$3"
printf '%s' "$contents" | jq -c --arg r "$role" --arg t "$text" \
'.contents += [{role:$r, parts:[{text:$t}]}] | .contents |= .[-'"$MAX_SESSION_TURNS"':]'
}
# One generateContent call. $1 = model, $2 = contents JSON, $3 = optional
# system instruction (added as systemInstruction, not stored in the session).
# stdout = the answer text on success; an error message on failure (exit 1).
gemini_generate() {
local model="$1" contents="$2" system="${3:-}" body
body="$contents"
if [ -n "$system" ]; then
body="$(printf '%s' "$contents" | jq -c --arg s "$system" \
'. + {systemInstruction:{role:"system",parts:[{text:$s}]}}')"
fi
local resp code body_out errmsg
resp="$(curl -sS -m 60 -X POST "${API}/models/${model}:generateContent" \
-H "x-goog-api-key: ${AI_GEMINI_API_KEY}" \
-H "Content-Type: application/json" \
--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 '[.candidates[0].content.parts[]?.text] | join("")'
}
cmd_ask() {
local prompt="" contents out
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}]}]}')"
fi
if ! out="$(gemini_generate "$(resolve_model)" "$contents" "$SYSTEM_PROMPT" 2>&1)"; then
err "$out"
fi
if [ -n "$SESSION" ]; then
contents="$(session_push "$contents" model "$out")"
session_save "$contents"
fi
printf '%s\n' "$out"
}
cmd_chat() {
[ $# -eq 0 ] || err "Unexpected argument for 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
trap 'echo; echo "bye"; exit 0' INT
echo "Gemini · ${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)
contents='{"contents":[]}'
[ -n "$SESSION" ] && session_save "$contents"
echo "[history cleared]"
continue ;;
esac
contents="$(session_push "$contents" user "$text")"
if ! answer="$(gemini_generate "$model" "$contents" "$SYSTEM_PROMPT" 2>&1)"; then
warn "AI error: $answer"
continue
fi
contents="$(session_push "$contents" model "$answer")"
[ -n "$SESSION" ] && session_save "$contents"
printf '\n%s\n\n' "$answer"
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 '.contents | 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 gemini 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 -G "${API}/models" \
-H "x-goog-api-key: ${AI_GEMINI_API_KEY}" \
--data-urlencode "pageSize=1000" \
--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 '.models[]? | select((.supportedGenerationMethods // []) | index("generateContent")) | .name' | sed 's#^models/##' | sort)"
echo "Gemini models (generateContent-capable):"
while IFS= read -r m; do
[ -n "$m" ] || continue
if [ "$m" = "$model" ]; then
printf ' %-32s <- default\n' "$m"
else
printf ' %-32s\n' "$m"
fi
done <<< "$list"
if ! grep -qxF "$model" <<< "$list"; then
warn "configured default '$model' is not in the list — set AI_GEMINI_MODEL"
fi
}
# ── Parse flags + subcommand ────────────────────────────────────
MODEL_OVERRIDE=""
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 ;;
-*) err "Unknown option '$1' (see --help)" ;;
*)
if [ -z "$cmd" ]; then
cmd="$1"
else
args+=("$1")
fi
shift ;;
esac
done
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)" ;;
esac
# POS: ai gemini — Forward to pos ai --provider gemini (backward compat)
# POS_SUBCMDS: ask chat models sessions capture
# Thin forwarder — all logic lives in bin/pos-ai + lib/ai-providers/gemini.sh
case "${1:-}" in -h|--help) exec pos ai --provider gemini --help ;; esac
exec pos ai --provider gemini "$@"
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: ai openrouter — Forward to pos ai --provider openrouter (backward compat)
# POS_SUBCMDS: ask chat sessions capture
# Thin forwarder — all logic lives in bin/pos-ai + lib/ai-providers/openrouter.sh
case "${1:-}" in -h|--help) exec pos ai --provider openrouter --help ;; esac
exec pos ai --provider openrouter "$@"
+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
+973 -6
View File
File diff suppressed because it is too large Load Diff
+146 -94
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,97 +213,4 @@ while [[ $# -gt 0 ]]; do
esac
done
[ "$MP3" -eq 1 ] || [ "$MP4" -eq 1 ] || { MP3=1; MP4=1; }
[ -d "$SRC" ] || err "Source not found: $SRC"
trap 'notify_send "Music sync FAILED"' ERR
section "Music sync"
echo "Source : $SRC"
if [ "$MP3" -eq 1 ] && [ "$MP4" -eq 1 ]; then
echo "Filter : mp3 + mp4"
find_expr=(-type f \( -iname '*.mp3' -o -iname '*.mp4' \))
elif [ "$MP3" -eq 1 ]; then
echo "Filter : mp3 only"
find_expr=(-type f -iname '*.mp3')
else
echo "Filter : mp4 only"
find_expr=(-type f -iname '*.mp4')
fi
usb_pick_root "Sync to" "$DEST_DIR" "no sync performed" || {
log "Skipped — no sync performed"
exit 0
}
dest_root="${USB_ROOT%/}/$DEST_DIR"
echo "Target : $dest_root"
# Does the destination need this file copied? Missing, or size/mtime differs.
needs_copy() {
local src="$1" dst="$2" ss="" ds="" sm="" dm=""
[ -f "$dst" ] || return 0
ss="$(stat -c %s "$src" 2>/dev/null || printf 0)"
ds="$(stat -c %s "$dst" 2>/dev/null || printf 0)"
sm="$(stat -c %Y "$src" 2>/dev/null || printf 0)"
dm="$(stat -c %Y "$dst" 2>/dev/null || printf 0)"
[ "$ss" = "$ds" ] && [ "$sm" -le "$dm" ] && return 1
return 0
}
# Pre-flight: fail early on "won't fit" instead of a mid-copy ENOSPC that
# leaves a half-written target. Measures exactly what needs_copy would copy.
space_path="$USB_ROOT"
if [ "$DRY_RUN" -eq 0 ]; then
mkdir -p "$dest_root"
space_path="$dest_root"
fi
need_kb=0
while IFS= read -r f; do
rel="${f#"$SRC/"}"
if needs_copy "$f" "$dest_root/$rel"; then
sz="$(stat -c %s "$f" 2>/dev/null || printf 0)"
need_kb=$((need_kb + (sz + 1023) / 1024))
fi
done < <(find -H "$SRC" "${find_expr[@]}")
have_kb="$(df -Pk "$space_path" 2>/dev/null | awk 'NR==2 {print $4}')"
have_kb="${have_kb:-0}"
if [ "$need_kb" -gt "$have_kb" ]; then
msg="Not enough free space on ${USB_ROOT%/}: need ~${need_kb}K, have ${have_kb}K"
if [ "$DRY_RUN" -eq 1 ]; then
warn "$msg"
else
err "$msg"
fi
fi
added=0
updated=0
unchanged=0
while IFS= read -r f; do
rel="${f#"$SRC/"}"
dest="$dest_root/$rel"
if needs_copy "$f" "$dest"; then
if [ -f "$dest" ]; then
updated=$((updated + 1))
else
added=$((added + 1))
fi
if [ "$DRY_RUN" -eq 1 ]; then
log "would copy $rel"
else
mkdir -p "$(dirname "$dest")"
cp --preserve=timestamps "$f" "$dest"
echo " + $rel"
fi
else
unchanged=$((unchanged + 1))
fi
done < <(find -H "$SRC" "${find_expr[@]}" | sort)
if [ "$DRY_RUN" -eq 1 ]; then
echo "DRY RUN — nothing copied. Would sync: ${added} new, ${updated} updated, ${unchanged} unchanged → $dest_root"
else
ok "Sync complete: ${added} added, ${updated} updated, ${unchanged} unchanged → $dest_root"
notify_send "Music sync completed: ${added} added, ${updated} updated → $dest_root"
fi
cmd_sync
+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
+509 -185
View File
@@ -1,14 +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
# 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"
@@ -20,7 +22,8 @@ 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 (idempotent)
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)
@@ -53,8 +56,7 @@ 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
@@ -103,6 +105,23 @@ 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"
@@ -200,156 +219,156 @@ read_test_or_rollback() {
Details: DOC/howto/share.md (SMB troubleshooting)"
}
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"
split_share "$what"
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"
probe_server "$SERVER"
if src="$(mounted_src "$where")"; [ -n "$src" ]; then
err "$where is already mounted (source: ${src:-unknown}) — nothing done.
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
fi
ensure_mountpoint "$where"
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:-}"
}
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)"
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"
;;
unmount)
where="${2:-}"
[ -n "$where" ] || err "Usage: pos share smb-client unmount <local-dir>"
validate_dir "$where"
src="$(mounted_src "$where")"
if [ -z "$src" ]; then
log "Nothing mounted at $where"
exit 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"; exit 0; }
err "Still mounted. Find the blocker: sudo lsof +D $where (or fuser -vm $where)"
fi
err "Unmount failed: $out"
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
log "Unmounted $where"
notify_send "SMB unmounted: $where"
;;
err "Unmount failed: $out"
fi
log "Unmounted $where"
notify_send "SMB 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
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")
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"
split_share "$what"
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"
probe_server "$SERVER"
probe_server "$SERVER"
if src="$(mounted_src "$where")"; [ -n "$src" ]; then
err "$where is already mounted (source: ${src:-unknown}) — nothing done.
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
fi
unit="$(systemd-escape --path --suffix=mount "$where")"
auto_unit="${unit%.mount}.automount"
unit_file="${UNIT_DIR}/${unit}"
auto_file="${UNIT_DIR}/${auto_unit}"
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
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
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
}
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
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
cat <<UNIT | sudo tee "$unit_file" >/dev/null
[Unit]
Description=SMB mount of ${what} at ${where}
After=network-online.target
@@ -361,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}
@@ -371,70 +390,375 @@ Where=${where}
[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
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
# 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.
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
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.
# 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
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}"
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
log "No persistent SMB mount for $where — nothing to do"
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
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"
[ $# -ge 2 ] || err "Usage: pos share smb-client unpersist <local-dir>"
cmd_unpersist "$2"
;;
esac
+284 -154
View File
@@ -1,10 +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
# 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)"
@@ -48,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
@@ -106,15 +106,9 @@ reload_config() {
# 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() {
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 'samba|(^|[^0-9])(137|138|139|445)([^0-9]|$)' <<<"$st"; then
return 1
fi
return 0
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
@@ -141,171 +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"
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
ok "firewall: ufw active, Samba allowed"
fi
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]" ;;
esac
require_root_dir "$path"
check_traversal "$path"
[ -f "$SMB_CONF" ] || err "No smb.conf at $SMB_CONF (is samba installed?)"
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"
dup_path_share "$path" "$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"
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 "ufw is active but allows no Samba traffic — clients can't reach ports 139/445.
Fix on this machine: sudo ufw allow Samba"
warn "firewall: ufw active, NO Samba rule — clients can't connect. Fix: sudo ufw allow Samba"
else
ok "firewall: ufw active, Samba allowed"
fi
else
ok "firewall: no active ufw — nothing blocking SMB ports"
fi
}
ro_val=no; [ "$ro" -eq 1 ] && ro_val=yes
guest_val=no; [ "$guest" -eq 1 ] && guest_val=yes
block="[$name]
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
done
name="${name:-$(basename "$path")}"
validate_share_name "$name"
dup_path_share "$path" "$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
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)"
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"
[ $# -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>"
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"
[ $# -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
+415
View File
@@ -0,0 +1,415 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: system uninstall — Remove pos toolkit binaries, services, shell integration, config, and data
# POS_FLAGS: --yes --config --data
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
usage() {
cat <<'EOF'
Usage: pos system uninstall [--yes] [--config] [--data]
Remove the pos toolkit from this machine. Scans for installed components
and removes them interactively (or non-interactively with --yes).
Tiers:
Tier 1 (always): binaries, entertainment plugins, systemd services, shell integration
Tier 2 (--config): config files in ~/.config/linux_post_install/
Tier 3 (--data): session/log/capture data in ~/.local/share/linux_post_install/
Flags:
--yes Skip confirmation prompts (removes defaults only; combine with --config/--data for more)
--config Include config files (Tier 2) in removal
--data Include session/log data (Tier 3) in removal
Examples:
pos system uninstall # interactive, tier 1 only
pos system uninstall --yes # non-interactive, tier 1 only
pos system uninstall --yes --config --data # nuclear option
EOF
exit 0
}
# ── Parse flags ─────────────────────────────────────────────────
YES_MODE=0
DEL_CONFIG=0
DEL_DATA=0
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage ;;
--yes) YES_MODE=1; shift ;;
--config) DEL_CONFIG=1; shift ;;
--data) DEL_DATA=1; shift ;;
-*) err "Unknown option '$1'" ;;
*) err "Unexpected argument: $1 (no subcommands)" ;;
esac
done
# ── Scan functions ──────────────────────────────────────────────
scan_tier1() {
local found=()
# ── Binaries in /usr/local/bin ──
# Main dispatcher
[ -f /usr/local/bin/pos ] && found+=("/usr/local/bin/pos")
# All pos-* tools (sorted for deterministic display)
local f
while IFS= read -r f; do
found+=("$f")
done < <(compgen -G /usr/local/bin/pos-* 2>/dev/null | sort || true)
# Lib files shipped by install.sh
for f in common.sh menu-lib.sh share-lib.sh; do
[ -f "/usr/local/bin/$f" ] && found+=("/usr/local/bin/$f")
done
# AI providers subdirectory
[ -d /usr/local/bin/ai-providers ] && found+=("/usr/local/bin/ai-providers/")
# Entertainment plugins installed by install.sh
while IFS= read -r f; do
found+=("$f")
done < <(for ep in weather.sh gold.sh joke.sh; do
[ -f "/usr/local/bin/$ep" ] && echo "/usr/local/bin/$ep"
done | sort)
# Legacy forwarders
for f in wr-* mp3 mp4 vbox ssh-load-all; do
if compgen -G "/usr/local/bin/$f" >/dev/null 2>&1; then
while IFS= read -r lf; do
found+=("$lf")
done < <(compgen -G "/usr/local/bin/$f" 2>/dev/null)
fi
done
# Prebuilt binaries (hotspot)
for f in wihotspot wihotspot-gui create_ap; do
[ -f "/usr/local/bin/$f" ] && found+=("/usr/local/bin/$f")
done
# Feature scripts installed by install.sh
for f in autostart.sh usb-automount.sh; do
[ -f "/usr/local/bin/$f" ] && found+=("/usr/local/bin/$f")
done
# User-local binaries
[ -f "$HOME/.local/bin/pos-ai-hook.sh" ] && found+=("$HOME/.local/bin/pos-ai-hook.sh")
# Completion file
[ -f /usr/local/share/bash-completion/completions/pos.bash ] && found+=("/usr/local/share/bash-completion/completions/pos.bash")
# ~/.bash_completion entries
if [ -f "$HOME/.bash_completion" ]; then
while IFS= read -r line; do
found+=("~/.bash_completion: $(echo "$line" | sed 's/^[[:space:]]*//' | cut -c1-70)")
done < <(grep -n 'pos' "$HOME/.bash_completion" 2>/dev/null || true)
fi
# ── Systemd services ──
for svc in autostart.service ssh-agent.service usb-automount.service; do
if systemctl is-enabled "$svc" &>/dev/null 2>&1; then
found+=("service: $svc")
elif [ -f "/etc/systemd/system/$svc" ]; then
found+=("service: $svc")
fi
done
# Catch any other linux_post_install-related services
while IFS= read -r line; do
local svc_name
svc_name=$(echo "$line" | awk '{print $1}')
# Skip already-listed services
local already=0
for listed in autostart.service ssh-agent.service usb-automount.service; do
[ "$svc_name" = "$listed" ] && already=1 && break
done
[ "$already" -eq 0 ] && found+=("service: $svc_name")
done < <(systemctl list-unit-files --type=service 2>/dev/null | grep -i 'linux_post_install\|pos-' || true)
# ── Shell integration (~/.bashrc) ──
if [ -f "$HOME/.bashrc" ]; then
while IFS= read -r line; do
found+=("~/.bashrc: $(echo "$line" | sed 's/^[[:space:]]*//' | cut -c1-70)")
done < <(grep -n 'source.*pos-ai-hook\|linux_post_install.*PATH\|source.*pos\.bash\|pos completion' "$HOME/.bashrc" 2>/dev/null || true)
fi
printf '%s\n' "${found[@]}"
}
scan_tier2() {
local found=()
local cfg="$HOME/.config/linux_post_install"
if [ -d "$cfg" ]; then
while IFS= read -r f; do
found+=("$f")
done < <(find "$cfg" -maxdepth 2 -type f 2>/dev/null | sort)
fi
printf '%s\n' "${found[@]}"
}
scan_tier3() {
local found=()
local data="$HOME/.local/share/linux_post_install"
if [ -d "$data" ]; then
while IFS= read -r f; do
found+=("$f")
done < <(find "$data" -maxdepth 2 \( -type f -o -type d \) 2>/dev/null | sort)
fi
printf '%s\n' "${found[@]}"
}
# ── Display function ────────────────────────────────────────────
display_plan() {
local -a t1=() t2=() t3=()
# Collect non-empty entries from scan output
while IFS= read -r line; do
[ -n "$line" ] && t1+=("$line")
done <<< "${1:-}"
while IFS= read -r line; do
[ -n "$line" ] && t2+=("$line")
done <<< "${2:-}"
while IFS= read -r line; do
[ -n "$line" ] && t3+=("$line")
done <<< "${3:-}"
echo
echo "${BOLD}pos uninstall — what will be removed:${RESET}"
echo
local n=1
if [ "${#t1[@]}" -gt 0 ]; then
echo "${CYAN}Tier 1 (always):${RESET}"
for item in "${t1[@]}"; do
printf " %3d) %s\n" "$n" "$item"
n=$((n + 1))
done
else
echo "${CYAN}Tier 1 (always):${RESET} (nothing found)"
fi
echo
if [ "${#t2[@]}" -gt 0 ]; then
echo "${YELLOW}Tier 2 (--config to include):${RESET}"
for item in "${t2[@]}"; do
printf " %3d) %s\n" "$n" "$item"
n=$((n + 1))
done
else
echo "${YELLOW}Tier 2 (--config to include):${RESET} (nothing found)"
fi
echo
if [ "${#t3[@]}" -gt 0 ]; then
echo "${YELLOW}Tier 3 (--data to include):${RESET}"
for item in "${t3[@]}"; do
printf " %3d) %s\n" "$n" "$item"
n=$((n + 1))
done
else
echo "${YELLOW}Tier 3 (--data to include):${RESET} (nothing found)"
fi
echo
}
# ── Removal functions ───────────────────────────────────────────
remove_tier1() {
local count=0
# ── Binaries ──
# Main dispatcher
[ -f /usr/local/bin/pos ] && { rm -f /usr/local/bin/pos && count=$((count+1)); }
# All pos-* tools
local f
while IFS= read -r f; do
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
done < <(compgen -G /usr/local/bin/pos-* 2>/dev/null | sort || true)
# Lib files
for f in /usr/local/bin/common.sh /usr/local/bin/menu-lib.sh /usr/local/bin/share-lib.sh; do
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
done
# AI providers directory
if [ -d /usr/local/bin/ai-providers ]; then
rm -rf /usr/local/bin/ai-providers && count=$((count+1))
fi
# Entertainment plugins
for f in /usr/local/bin/weather.sh /usr/local/bin/gold.sh /usr/local/bin/joke.sh; do
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
done
# Legacy forwarders
for pat in 'wr-*' mp3 mp4 vbox ssh-load-all; do
while IFS= read -r f; do
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
done < <(compgen -G "/usr/local/bin/$pat" 2>/dev/null || true)
done
# Prebuilt binaries
for f in /usr/local/bin/wihotspot /usr/local/bin/wihotspot-gui /usr/local/bin/create_ap; do
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
done
# Feature scripts
for f in /usr/local/bin/autostart.sh /usr/local/bin/usb-automount.sh; do
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
done
# User-local binaries
[ -f "$HOME/.local/bin/pos-ai-hook.sh" ] && { rm -f "$HOME/.local/bin/pos-ai-hook.sh" && count=$((count+1)); }
# Completion file
[ -f /usr/local/share/bash-completion/completions/pos.bash ] && { rm -f /usr/local/share/bash-completion/completions/pos.bash && count=$((count+1)); }
# ── Systemd services ──
for svc in autostart.service ssh-agent.service usb-automount.service; do
if systemctl is-enabled "$svc" &>/dev/null 2>&1; then
systemctl disable --now "$svc" 2>/dev/null || true
rm -f "/etc/systemd/system/$svc"
count=$((count+1))
elif [ -f "/etc/systemd/system/$svc" ]; then
rm -f "/etc/systemd/system/$svc"
count=$((count+1))
fi
done
# Additional linux_post_install services
while IFS= read -r svc_file; do
local svc_name
svc_name=$(basename "$svc_file" .service)
# Skip already-handled services
local already=0
for listed in autostart ssh-agent usb-automount; do
[ "$svc_name" = "$listed" ] && already=1 && break
done
if [ "$already" -eq 0 ]; then
systemctl disable --now "$svc_name.service" 2>/dev/null || true
rm -f "$svc_file"
count=$((count+1))
fi
done < <(find /etc/systemd/system/ -name '*linux_post_install*' -o -name 'pos-*' 2>/dev/null || true)
# Reload daemon after service changes
systemctl daemon-reload 2>/dev/null || true
# ── Shell integration (~/.bashrc) ──
if [ -f "$HOME/.bashrc" ]; then
local before
before=$(wc -l < "$HOME/.bashrc")
sed -i '/source.*pos-ai-hook/d' "$HOME/.bashrc"
sed -i '/linux_post_install.*PATH/d' "$HOME/.bashrc"
sed -i '/source.*pos\.bash/d' "$HOME/.bashrc"
local after
after=$(wc -l < "$HOME/.bashrc")
local removed=$((before - after))
count=$((count + removed))
fi
# ── Shell completion (~/.bash_completion) ──
if [ -f "$HOME/.bash_completion" ]; then
local before
before=$(wc -l < "$HOME/.bash_completion")
sed -i '/pos/d' "$HOME/.bash_completion"
local after
after=$(wc -l < "$HOME/.bash_completion")
local removed=$((before - after))
count=$((count + removed))
fi
ok "Removed $count items (tier 1)"
}
remove_tier2() {
local cfg="$HOME/.config/linux_post_install"
local count=0
if [ -d "$cfg" ]; then
local f
while IFS= read -r f; do
rm -f "$f" && count=$((count+1))
done < <(find "$cfg" -maxdepth 2 -type f 2>/dev/null)
# Remove empty directory tree
rmdir "$cfg/schedule.d" 2>/dev/null || true
rmdir "$cfg" 2>/dev/null || true
fi
ok "Removed $count items (tier 2)"
}
remove_tier3() {
local data="$HOME/.local/share/linux_post_install"
local count=0
if [ -d "$data" ]; then
# Remove files first
local f
while IFS= read -r f; do
rm -f "$f" && count=$((count+1))
done < <(find "$data" -maxdepth 2 -type f 2>/dev/null)
# Remove directories bottom-up
while IFS= read -r d; do
rmdir "$d" 2>/dev/null && count=$((count+1)) || true
done < <(find "$data" -mindepth 1 -depth -type d 2>/dev/null)
# Remove top-level directory if empty
rmdir "$data" 2>/dev/null || true
fi
ok "Removed $count items (tier 3)"
}
# ── Main ────────────────────────────────────────────────────────
main() {
section "pos system uninstall"
# Scan all tiers
local scan1 scan2 scan3
scan1="$(scan_tier1)"
scan2="$(scan_tier2)"
scan3="$(scan_tier3)"
# Display the plan
display_plan "$scan1" "$scan2" "$scan3"
# Nothing to do at all?
if [ -z "$scan1" ] && [ -z "$scan2" ] && [ -z "$scan3" ]; then
ok "Nothing to remove — pos toolkit does not appear to be installed."
return 0
fi
# ── Tier 1: always remove (confirm unless --yes) ──
if [ -n "$scan1" ]; then
if [ "$YES_MODE" -eq 1 ]; then
log "Removing tier 1 items (--yes)..."
else
confirm "Remove tier 1 items (binaries, services, shell integration)?" || return 0
fi
remove_tier1
fi
# ── Tier 2: config files ──
if [ -n "$scan2" ]; then
if [ "$DEL_CONFIG" -eq 0 ] && [ "$YES_MODE" -eq 0 ]; then
confirm "Also remove config files (tier 2)?" && DEL_CONFIG=1 || true
fi
[ "$DEL_CONFIG" -eq 1 ] && remove_tier2
fi
# ── Tier 3: session/log data ──
if [ -n "$scan3" ]; then
if [ "$DEL_DATA" -eq 0 ] && [ "$YES_MODE" -eq 0 ]; then
confirm "Also remove session/log data (tier 3)?" && DEL_DATA=1 || true
fi
[ "$DEL_DATA" -eq 1 ] && remove_tier3
fi
echo
ok "Uninstall complete."
echo " The git repo was NOT removed — delete it manually if desired."
echo " Restart your shell or run: source ~/.bashrc"
}
main "$@"
+20 -8
View File
@@ -3,36 +3,48 @@
# 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[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[system-uninstall]="--yes --config --data"
_pos_flags[ai]="--provider --model --session --system --full --last"
_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 chat models sessions capture"
_pos_subcmds[ai-openrouter]="ask chat sessions capture"
_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"
_pos_subcmds[ai]="ask chat sessions capture models providers gemini openrouter"
# 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 compose entertainment matrix notify scrcpy system telegram ytsync)
# GEN:END posconfigscopes
_pos() {
+15 -5
View File
@@ -1,8 +1,18 @@
# ~/.config/linux_post_install/ai.env — Google Gemini config for 'pos ai gemini'
# ~/.config/linux_post_install/ai.env — AI provider config for 'pos ai'
# Copied to ~/.config/linux_post_install/ai.env on install (no clobber).
# Edit with: pos config ai
#
# Syntax:
# AI_GEMINI_API_KEY=<key> # required — API key from aistudio.google.com
# # (never commit this to the repo)
# AI_GEMINI_MODEL=<model> # optional — model id, default gemini-2.5-flash
# Provider selection:
# AI_PROVIDER=gemini # provider: gemini|openrouter (default gemini)
#
# API keys (each provider needs its own):
# AI_GEMINI_API_KEY=<key> # Gemini API key from aistudio.google.com
# OPENROUTER_API_KEY=<key> # OpenRouter API key from openrouter.ai
#
# Model overrides (optional, defaults per provider):
# AI_GEMINI_MODEL=<model> # Gemini model id (default: gemini-2.5-flash)
# OPENROUTER_MODEL=<model> # OpenRouter model id (default: openrouter/auto)
# AI_MODEL=<model> # Override for all providers (takes priority)
#
# System prompt:
# AI_SYSTEM_PROMPT=<prompt> # Custom system prompt (overrides built-in; empty to reset)
+14 -1
View File
@@ -140,13 +140,26 @@ 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 "
done
log "libs -> /usr/local/bin (644): ${lib_names% }"
# ── AI provider adapters ────────────────────────────────────
# Installed into /usr/local/bin/ai-providers/ for pos-ai.
ap_count=0
ap_names=""
run sudo mkdir -p /usr/local/bin/ai-providers
for apf in lib/ai-providers/*.sh; do
[ -f "$apf" ] || continue
run sudo install -m 644 "$apf" "/usr/local/bin/ai-providers/"
ap_count=$((ap_count + 1))
ap_names+="$(basename "$apf") "
done
[ "$ap_count" -gt 0 ] && log "ai-providers -> /usr/local/bin/ai-providers (644): ${ap_names% }"
# ── Entertainment plugins ────────────────────────────────
# Installed into /usr/local/bin so the repo can be deleted afterwards.
pcount=0
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Gemini provider adapter for pos-ai
# Provider-specific: API call, auth, response parsing, models list
# Part of the R8 provider-agnostic architecture (lib/ai-providers/).
# Provider-specific config variables (auto-discovered by pos config ai):
# PROVIDER_CONFIG: AI_GEMINI_API_KEY=secret:Gemini API key from aistudio.google.com
# PROVIDER_CONFIG: AI_GEMINI_MODEL=:Gemini model id (default: gemini-2.5-flash)
provider_name() { printf 'Google Gemini'; }
provider_default_model() { printf 'gemini-2.5-flash'; }
# $1=model $2=messages JSON ({"messages":[{role,content}]}) $3=optional system prompt
provider_generate() {
local model="$1" messages="$2" system="${3:-}" body resp code body_out errmsg
# Convert OpenAI messages format to Gemini contents format
body="$(printf '%s' "$messages" | jq -c '{
contents: [.messages[]? | {role: (.role | gsub("assistant";"model")), parts: [{text: .content}]}]
}')"
if [ -n "$system" ]; then
body="$(printf '%s' "$body" | jq -c --arg s "$system" \
'. + {systemInstruction:{role:"system",parts:[{text:$s}]}}')"
fi
resp="$(curl -sS -m 60 -X POST "https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent" \
-H "x-goog-api-key: ${AI_API_KEY}" \
-H "Content-Type: application/json" \
--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 '[.candidates[0].content.parts[]?.text] | join("")'
}
# $1=current default model → stdout=formatted model list
provider_models_list() {
local model="$1" resp code body m
resp="$(curl -sS -m 30 -G "https://generativelanguage.googleapis.com/v1beta/models" \
-H "x-goog-api-key: ${AI_API_KEY}" \
--data-urlencode "pageSize=1000" \
--write-out $'\n%{http_code}')" || err "request failed (curl exit $?)"
code="${resp##*$'\n'}"
body="${resp%$'\n'*}"
[ "$code" = "200" ] || err "API error $code: $(printf '%s' "$body" | jq -r '.error.message // empty')"
local list
list="$(printf '%s' "$body" | jq -r '.models[]? | select((.supportedGenerationMethods // []) | index("generateContent")) | .name' | sed 's#^models/##' | sort)"
echo "Gemini models (generateContent-capable):"
while IFS= read -r m; do
[ -n "$m" ] || continue
if [ "$m" = "$model" ]; then
printf ' %-32s <- default\n' "$m"
else
printf ' %-32s\n' "$m"
fi
done <<< "$list"
if ! grep -qxF "$model" <<< "$list" 2>/dev/null; then
warn "configured default '$model' is not in the list — set AI_MODEL or AI_GEMINI_MODEL"
fi
}
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# OpenRouter provider adapter for pos-ai
# Provider-specific: API call, auth, response parsing, models list
# Part of the R8 provider-agnostic architecture (lib/ai-providers/).
# Provider-specific config variables (auto-discovered by pos config ai):
# PROVIDER_CONFIG: OPENROUTER_API_KEY=secret:OpenRouter API key from openrouter.ai
# PROVIDER_CONFIG: OPENROUTER_MODEL=:OpenRouter model id (default: openrouter/auto)
provider_name() { printf 'OpenRouter'; }
provider_default_model() { printf 'openrouter/auto'; }
# $1=model $2=messages JSON ({"messages":[{role,content}]}) $3=optional system prompt
provider_generate() {
local model="$1" messages="$2" system="${3:-}" body resp code body_out errmsg
if [ -n "$system" ]; then
body="$(printf '%s' "$messages" | jq -c --arg s "$system" \
'[{role:"system",content:$s}] + .messages')"
else
body="$(printf '%s' "$messages" | 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 "https://openrouter.ai/api/v1/chat/completions" \
-H "Authorization: Bearer ${AI_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 // ""'
}
# $1=current default model → stdout=formatted model list
provider_models_list() {
local model="$1" resp code body m
resp="$(curl -sS -m 30 "https://openrouter.ai/api/v1/models" \
-H "Authorization: Bearer ${AI_API_KEY}" \
--write-out $'\n%{http_code}')" || err "request failed (curl exit $?)"
code="${resp##*$'\n'}"
body="${resp%$'\n'*}"
[ "$code" = "200" ] || err "API error $code: $(printf '%s' "$body" | jq -r '.error.message // empty')"
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" 2>/dev/null; then
warn "configured default '$model' is not in the list — set AI_MODEL or OPENROUTER_MODEL"
fi
}
+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 ──────────────────────────────────────────
+38 -1
View File
@@ -136,6 +136,42 @@ _cfg_plugin_keys() {
return 0
}
# "*providers" expansion: keys declared by the installed AI provider
# adapters' "# PROVIDER_CONFIG:" headers (lib/ai-providers/*.sh).
_cfg_provider_keys() {
local pdir line key desc flags
# Repo layout: lib/config-ui.sh → ../lib/ai-providers/
# Installed layout: /usr/local/bin/config-ui.sh → ./ai-providers/
pdir=""
for candidate in \
"$(dirname "${BASH_SOURCE[0]}")/../lib/ai-providers" \
"$(dirname "${BASH_SOURCE[0]}")/ai-providers"; do
if [ -d "$candidate" ]; then
pdir="$(cd "$candidate" 2>/dev/null && pwd)"
break
fi
done
[ -n "$pdir" ] || return 0
while IFS= read -r line; do
[ -n "$line" ] || continue
# Format: KEY=flags:description (same as POS_CONFIG key fields)
key="${line%%=*}"
[ -n "$key" ] || continue
[ -n "${_cfg_seen[$key]:-}" ] && continue
_cfg_seen[$key]=1
# Parse flags and description from the rest
local rest="${line#*=}" flags="" desc=""
if [[ "$rest" == *":"* ]]; then
flags="${rest%%:*}"
desc="${rest#*:}"
else
flags="$rest"
fi
printf '%s|%s|%s|\n' "$key" "$flags" "$desc"
done < <(grep '^# PROVIDER_CONFIG:' "$pdir"/*.sh 2>/dev/null | sed 's/^.*# PROVIDER_CONFIG:[[:space:]]*//' || true)
return 0
}
# Declared keys for a scope: "KEY|flags|description" lines, deduped.
cfg_scope_keys() {
local scope="$1" dir line s keystring field
@@ -155,7 +191,8 @@ cfg_scope_keys() {
if [ -n "$field" ]; then
if [[ "$field" == "*"* ]]; then
case "$field" in
*plugins*) _cfg_plugin_keys ;;
*plugins*) _cfg_plugin_keys ;;
*providers*) _cfg_provider_keys ;;
esac
else
_cfg_key_line "$field"
+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 ask --last (or pos ai --provider 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
}
+2 -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
@@ -39,6 +39,7 @@ PACKAGES=(
python3 python3-pip rclone
ffmpeg
libqrencode4 libgtk-3-0 adb
xdotool xclip
)
spawn "apt update" sudo apt update
+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.