Compare commits

..

3 Commits

Author SHA1 Message Date
Your Name aedc9b5c8f feat: add opencode project skill for Linux_post_install operations
gates / consistency-and-conventions (push) Successful in 29s
- .opencode/skills/linux-post-install/SKILL.md: operational playbook for agents working on the repo
- Covers: repo shape (45 pos tools, lib/, templates, gen blocks), pos tool model
  (# POS: header system, exec-bit, deps-guard-before-help, INTERACTIVE_CMDS, determinism),
  doc authority order (MAINTENANCE Phase 0: templates -> DEV.md -> AGENTS.md -> code),
  Definition of Done (make gen x2 idempotent -> make check -> make lint 0/0 -> make test),
  test conventions (hard-skip contract, negative controls, tests/README table),
  repo commands (ci-status, gitea API, pos tree/config)
- Frontmatter validated: name matches folder, description with trigger keywords
- Auto-discovered at .opencode/skills/ — no opencode.json change needed; restart to load

Verified: git diff --check clean; skill frontmatter name/description valid
2026-09-07 09:06:16 -04:00
Your Name 5b9dea2118 test: share-client ask_mountpoint — regression for t=type manual mountpoint
gates / consistency-and-conventions (push) Successful in 22s
Permanent coverage for the 2026-09-07 share mountpoint UX change
(commit 35eb90a): the REAL ask_mountpoint body is brace-extracted from
each client file (smb + nfs), exercised through the real common.sh +
share-lib.sh helper chain via the deterministic non-TTY stdin path, with
a fake sudo shim (records to SUDO_LOG, honors SUDO_FAIL) so no real
filesystem is touched.

Covers: existing dir used as-is (rc 0, provable NO confirm / NO mkdir
via empty SUDO_LOG) added by 35eb90a; new-dir confirm=y creates via sudo
mkdir; confirm=n and confirm=EOF both fail closed rc 1; existing
non-directory rejected 'not a directory'; relative / trailing-slash /
system paths (/etc, /root, /home/*/.ssh*) and empty-EOF all rc 1 with no
side effects; mkdir failure rc 1. Stdout purity (path only). Static
guards fail if anyone regresses n=new / n|N / ask_new_mountpoint, and
verify the existing-dir [-d] branch precedes confirm/mkdir. Byte-symmetry
guard keeps smb/nfs bodies identical.

117 checks new (18 files / 416 total). Every case can genuinely fail
(negative-control verified by Tester). Hard-skip contract respected: 0
skips — all cases deterministic non-interactively.

Verified: make check OK, make lint 0 FAIL/0 WARN, make test 18 files /
416 checks / 0 fail / 0 skip (~46s), bash -n clean, git diff --check clean.
2026-09-07 08:30:38 -04:00
Your Name 35eb90a58b feat: share clients — t=type manual mountpoint (existing path without create)
gates / consistency-and-conventions (push) Successful in 22s
User report: smb-client/nfs-client mountpoint step could only auto-suggest
candidates, or create a fresh dir behind a hidden 'n=new' key — no way to
type an arbitrary existing path as the mountpoint, so the manual option
was effectively invisible (candidates from /media etc. always populated
the picker, hiding the typing path entirely). Designer framing: capability
gap + discoverability gap; backend already handled arbitrary paths (CLI
cmd_mount + ensure_mountpoint), only the interactive menu blocked it.

Change (identical in bin/pos-share-smb-client and bin/pos-share-nfs-client):
- pick_mountpoint hint 'n=new' -> 't=type'; key arm n -> t
- ask_new_mountpoint generalized to ask_mountpoint: an existing
  directory is now used AS-IS (no create, no confirm); a non-existent
  path keeps the 'Create mountpoint?' confirm + sudo mkdir flow; existing
  non-directory rejected ('has a file there'); shape checks and system-path
  refusal unchanged; stream contract (display->stderr, path->stdout) kept
- menu_ask_mountpoint empty-candidate fall-through now routes through the
  same ask_mountpoint validator (single source of truth)

Docs: DOC/howto/share.md NFS+SMB mountpoint sections updated from n=new to
t=type and describe existing-path-without-create behavior.

Scoped to the two client files + howto doc; persistence/automount units,
unmount/remove flows, cmd_* CLIs, share_folder_candidates, and
lib/menu-lib.sh untouched.

Verified: 9-scenario smoke matrix x2 files (~19 assertions each: existing
dir as-is, new-dir confirm+create, decline, relative/trailing-slash/system/
empty rejections, non-dir reject, mkdir-fail), make gen idempotent, make
check OK, make lint 0 FAIL/0 WARN, make test 17 files / 299 checks green,
bash -n clean, git diff --check clean. Designer ACCEPT framing+spec;
Reviewer ACCEPT after doc fix.
2026-09-07 08:08:02 -04:00
8 changed files with 356 additions and 38 deletions
@@ -0,0 +1,63 @@
---
name: linux-post-install
description: Use when working on the Linux_post_install repo — creating, modifying, or fixing pos CLI tools (bin/pos-*, install.sh, features/, apps/, lib/, systemd/, completions/), regenerating docs via make gen, or running the make check / make lint / make test gates. Front-loads the # POS: header system, the pos tool model, the doc-authority order (templates > DEV.md > AGENTS.md > code), the definition of done, and the test conventions.
---
# Linux_post_install — Toolkit Operations
Operational playbook for changing the Linux_post_install repo (Debian/Ubuntu bootstrap + homelab toolkit, Bash). Read `DOC/AGENT_Context_Project.md` first for any non-trivial task — it opens with a **Document Map** (auto-generated line ranges) to jump straight to the relevant section. For day-to-day *usage* docs see `DOC/HOWTO.md`; for the `pos` CLI reference see `DOC/POS.md`.
## Repo shape
- `bin/pos-<cat>-<cmd>` — tools (45 of them); category-less `bin/pos-<cmd>` for dispatcher/dev-level (`pos-config`, `pos-tree`). Legacy `bin/wr-*`, `mp3`, `mp4`, `vbox`, `ssh-load-all` are thin forwarders — keep them that way.
- `lib/` — shared libraries: `common.sh` (run/spawn/err/warn, `ensure_user_bus`), `registry.sh` (POS_* header query API — consumers source it, never re-implement sed/grep), `menu-lib.sh` (`menu_ask_value`, `--allow-empty`), `jq-seam.sh` (stub-friendly jq), `config.sh` (env files, `pos config` scopes), `share-lib.sh`, `usb-lib.sh`, `ai-lib.sh` (use the `llamafile` seam in tests).
- `features/`, `apps/`, `templates/` (pos-tool.sh, app.sh, feature.sh — starting points for new files), `systemd/`, `completions/pos.bash` (gen output), `tests/` (suite, see below), `x64_bin/`+`arm64_bin/` (prebuilt hotspot binaries), `scripts/` (gen-docs, check-sync, lint-conventions, ci-status), `Makefile` (check/gen/lint/test/hook), `.gitea/workflows/lint.yml` (CI).
- Gen blocks `GEN:START`/`GEN:END` (AGENT_Context filetable/tree/dispatch/selfcontained/docmap + `completions/pos.bash`) are `make gen` output — never hand-edit.
## Tool model
- Format: shebang → `set -euo pipefail``# POS: <cat> <cmd> — <desc>` header (missing header hard-fails `make gen`) → `# POS_FLAGS:` / `# POS_SUBCMDS:` / `# POS_CONFIG:` / `# POS_DEPS:` / `# POS_EXAMPLES:` headers → `command -v` deps guards **before** `-h|--help` dispatch → case-based help.
- File must be executable (`100755`) and run standalone from `/usr/local/bin` after install (source `lib/common.sh` via `$(dirname "$0")/../lib/common.sh` fallback chain).
- New tools are auto-discovered; `make gen` only uses the text after the first `— ` in the header.
- Stdin readers must be added to `INTERACTIVE_CMDS` in `bin/pos` or the logging `tee` pipe hangs/swallows prompts.
- Deps: apt → `PACKAGES` array in `preinstall.sh`; non-apt/manual installers → `command -v <bin> || err` guard inside the tool. Secrets never committed; runtime config `~/.config/linux_post_install/<tool>.env` (chmod 600), env-var precedence.
- Entertainment plugins (`entertainment/`) must NOT source `lib/common.sh` (stdout is the Telegram message); markers `# POS_PLUGIN:` + `# POS_KEYS:`.
- ScaleTail compose is a git submodule — `git submodule update --init` before `pos docker compose *`.
- Generators must be byte-order deterministic (`LC_ALL=C`, sort) or CI `git diff --exit-code` trips.
## Doc authority (MAINTENANCE.md Phase 0)
1. `templates/*.sh` — codified current convention; required starting point for new files (`cp templates/pos-tool.sh bin/pos-<cat>-<cmd>`).
2. `DOC/DEV.md` — convention detail, checklists (wins on detail).
3. `AGENTS.md` — operational/process facts (wins on process).
4. Code + `# POS:` headers — ground truth for behavior and GEN blocks.
Drift in POS/HOWTO/README/SCRIPTS/SYSTEMD/APPS docs is a doc bug — fix the doc.
## Definition of done
After touching `bin/pos-*` (or anything structural):
```bash
make gen # regenerates gen blocks + completions
make gen # run twice — must be byte-idempotent
make check # self-consistency: bash -n + exec bits + doc-sync + dispatch smoke
make lint # convention gate — must end "0 FAIL, 0 WARN"
make test # full suite (18 files / 416 checks) unless tests/README.md says otherwise
```
Also: `bash -n <file>` for new/edited scripts, `git diff --check`, CI gate (`.gitea/workflows/lint.yml`, job `gates`) runs the same four commands on push to main and tags `ci-ok/<sha>`; query with `scripts/ci-status.sh [--wait] [<sha>]`.
## Test conventions (`tests/`)
- Suite lives in `tests/`; run standalone after suite load: `test_run <name> <label> <stdin> <expected_rc>` with `expect_eq`/`expect_ok`/`expect_fail` helpers; each file prints `^ <name>: <n> checks, <m> fail, <k> skip`.
- Read `tests/README.md` — it documents the harness and suite table; add a row when adding a file.
- Hard-skip contract: tests must never fake passes. Infeasible non-TTY cases are documented `skip`; use `# POS:` header checks, `extract_fn` brace extraction, stub-PATH seams, and non-TTY stdin pipes where possible. Zero-skip is preferred but never silence real failures. Negative controls (assert the new check actually catches the old bug) are required for regression tests.
## Commands relevant to this repo
- `make gen` + `make check` + `make lint` (+ `make test`) — the gates above.
- `make hook` — opt-in pre-commit gate.
- `scripts/ci-status.sh [--wait] [<sha>]` — CI tag lookup (0 green / 1 red / 2 pending); reliable path is the Gitea API `https://gitea.skink-platy.ts.net/api/v1/repos/admin/Linux_post_install/commits/<sha>/status`.
- `pos tree` — authoritative structure (needs a built/installed tree; falls back to reading `bin/pos-*` directly).
- `pos config <scope>` — runtime env-file config (`~/.config/linux_post_install/<tool>.env`); mask tokens in output.
+2
View File
@@ -42,6 +42,8 @@ summary (newest last).
## Done
- **2026-09-07** — opencode project skill: created `.opencode/skills/linux-post-install/SKILL.md` (repo had no `.opencode/`). Skill encodes the repo's operational playbook for agents: repo shape, `pos` tool model (`# POS:` header system, exec-bit, deps-guard-before-help, INTERACTIVE_CMDS, determinism), doc authority order (MAINTENANCE Phase 0: templates → DEV.md → AGENTS.md → code), Definition of Done gates (`make gen` ×2 idempotent → `make check``make lint` 0/0 → `make test` 18 files/416 checks), test conventions (hard-skip contract, negative controls), and repo commands (ci-status, gitea API, pos tree/config). Frontmatter validated (name matches folder, description with trigger keywords); auto-discovered at `.opencode/skills/` — no opencode.json change needed; restart opencode to load.
- **2026-09-06** — Repo cleanup: removed 114 temp/process files (AgentsReport/ 86 + reportAgents/ 28 agent reports), stale task/plan/audit docs (tmp_request.md, FINAL_SUMMARY.md, IMPLEMENTATION_PLAN.md, AUDIT.md, AUDIT_TABLE.md, registry-design doc), stray `To`/`.n`/`reports/` artifacts; gitignored AgentsReport/ so agent process reports stay local-only. DOC/, tools-docs/ytsync.md, AGENT_TODO.md, bin/lib/apps/tests/config/scripts etc. kept untouched.
- **2026-09-06** — `pos ai` OpenRouter 402 + unbounded session — Architect→Builder→Reviewer. User hit OpenRouter 402 on the `assist` alias: "You requested up to 131072 tokens, but can only afford 4511" — no provider sent `max_tokens`, so OpenRouter pre-bills the routed model's full worst-case output (131072 on `openrouter/auto`); user also asked to bound session history to last-5 requests. Architect decisions: `AI_MAX_TOKENS` (num, default **2048**, real cost cap) sent as `max_tokens` on OpenRouter and `generationConfig.maxOutputTokens` on Gemini (llamacpp skipped — local/free); `AI_SESSION_TURNS` (messages, 2 per exchange; default 40 kept back-compat; 10 = last 5 conversations) resolved lazily in `session_push` because config loads after the hardcoded line-25 default; both registered in the `bin/pos-ai` `# POS_CONFIG:` `@General` section → visible in `pos config ai`. Reviewer hardening (CHANGES_REQUIRED → fixed): unguarded env input could reach jq `tonumber` (0/-5/010/abc) — both providers + `session_push` now guard with `^[1-9][0-9]*$` fallback-to-default. Verified: fake-curl shim smoke (16 provider-body + 12 session-window checks incl. 010-regression proof), `make gen` idempotent, `make check` OK, `make lint` 0/0, `make test` 17 files/299 checks green; Reviewer ACCEPT (twice). Tester regression round not run this cycle (user's call); permanent coverage remains a follow-up.
+2 -2
View File
@@ -644,9 +644,9 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
| `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` | 504 | Mount NFS shares (ephemeral or persistent systemd mount units) |
| `bin/pos-share-nfs-client` | 511 | 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` | 766 | Mount SMB/CIFS shares (ephemeral or persistent systemd mount units) |
| `bin/pos-share-smb-client` | 773 | 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 |
+12 -9
View File
@@ -155,12 +155,13 @@ up — a down/unreachable NFS server can't break boot (with fstab it could).
(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.
differs from your local layout, and `t=type` lets you type an absolute
mountpoint — an existing directory is used as-is, a non-existent path
creates it 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`
@@ -292,9 +293,11 @@ fstab it could). `enable --now` arms the automount immediately.
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.
picker accepts `t=type` to type an absolute mountpoint — an existing
directory is used as-is, a non-existent path creates it 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).
+20 -13
View File
@@ -223,8 +223,8 @@ cmd_unpersist() {
# ── 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
# two deltas: the hint line offers `t=type`, and typing t runs the
# manual-entry flow below. share_pick cannot intercept `t` (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.
@@ -263,16 +263,16 @@ pick_mountpoint() {
done
fi
} >&2
if ! read -rp "${prompt} [1-${n}], n=new, text=filter, 0=back " ans; then
if ! read -rp "${prompt} [1-${n}], t=type, 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
t | T)
made="$(ask_mountpoint)" && { echo "$made"; return 0; }
continue # cancelled/invalid/mkdir-failed → redraw
;;
*[!0-9]*)
filter="$ans"
@@ -289,15 +289,16 @@ pick_mountpoint() {
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.
# Manual entry flow behind the picker's `t` key. Validates the shape
# (absolute, no trailing slash, system-path refusal), then branches:
# existing dir → use as-is; new dir → confirm-gate the creation + 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
ask_mountpoint() { # stdout: absolute path (existing or newly created) · 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
dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1
case "$dir" in
/*) ;;
*) warn "'$dir' is not an absolute path — must start with /" >&2; return 1 ;;
@@ -311,6 +312,14 @@ ask_new_mountpoint() { # stdout: created dir · rc 1 = cancelled/failed
return 1
;;
esac
if [ -d "$dir" ]; then
echo "$dir"
return 0
fi
if [ -e "$dir" ]; then
warn "$dir exists and is not a directory — pick another mount point" >&2
return 1
fi
confirm "Create mountpoint ${dir}?" n || return 1
if ! run sudo mkdir -p "$dir"; then
warn "Could not create ${dir}" >&2
@@ -371,9 +380,7 @@ menu_ask_mountpoint() { # [server_path] — stdout: absolute path · rc 1 cancel
esac
fi
fi
dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1
[ -n "$dir" ] || { warn "No mountpoint given"; return 1; }
echo "$dir"
ask_mountpoint
}
menu_mount() {
+20 -13
View File
@@ -495,8 +495,8 @@ smb_server_path() { # <host> <share> — stdout: server-side dir · rc 1 = unres
}
# 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
# two deltas: the hint line offers `t=type`, and typing t runs the
# manual-entry flow below. share_pick cannot intercept `t` (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.
@@ -535,16 +535,16 @@ pick_mountpoint() {
done
fi
} >&2
if ! read -rp "${prompt} [1-${n}], n=new, text=filter, 0=back " ans; then
if ! read -rp "${prompt} [1-${n}], t=type, 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
t | T)
made="$(ask_mountpoint)" && { echo "$made"; return 0; }
continue # cancelled/invalid/mkdir-failed → redraw
;;
*[!0-9]*)
filter="$ans"
@@ -561,15 +561,16 @@ pick_mountpoint() {
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.
# Manual entry flow behind the picker's `t` key. Validates the shape
# (absolute, no trailing slash, system-path refusal), then branches:
# existing dir → use as-is; new dir → confirm-gate the creation + 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
ask_mountpoint() { # stdout: absolute path (existing or newly created) · 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
dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1
case "$dir" in
/*) ;;
*) warn "'$dir' is not an absolute path — must start with /" >&2; return 1 ;;
@@ -583,6 +584,14 @@ ask_new_mountpoint() { # stdout: created dir · rc 1 = cancelled/failed
return 1
;;
esac
if [ -d "$dir" ]; then
echo "$dir"
return 0
fi
if [ -e "$dir" ]; then
warn "$dir exists and is not a directory — pick another mount point" >&2
return 1
fi
confirm "Create mountpoint ${dir}?" n || return 1
if ! run sudo mkdir -p "$dir"; then
warn "Could not create ${dir}" >&2
@@ -624,9 +633,7 @@ menu_ask_mountpoint() { # [server_path] — stdout: absolute path · rc 1 cancel
esac
fi
fi
dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1
[ -n "$dir" ] || { warn "No mountpoint given"; return 1; }
echo "$dir"
ask_mountpoint
}
menu_mount() { # ephemeral|persist
+2 -1
View File
@@ -51,4 +51,5 @@ silently.
| `t-config-precedence.sh` | `load_env_file` contract + CLI > env > file > defaults across tools |
| `t-uninstall-manifest.sh` | install.sh ↔ POS_LIBS symmetry, user-unit discovery, marker-driven plugin removal |
| `t-gen-docs-drift.sh` | `make gen` idempotence on a pristine tracked tree (CI drift gate) |
| `t-lint-gate.sh` | `make lint` green on the real tree; planted violations are caught and named |
| `t-lint-gate.sh` | `make lint` green on the real tree; planted violations are caught and named |
| `t-share-mountpoint.sh` | share-client `ask_mountpoint` UX: existing/new/declined/rejected paths, confirm gate, mkdir side effects, non-TTY stdin contract, static `n``t` guards |
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env bash
set -euo pipefail
# t-share-mountpoint.sh — permanent regression for the share-client
# ask_mountpoint() manual-entry flow (2026-09-07 `t=type` UX change in
# bin/pos-share-smb-client + bin/pos-share-nfs-client; byte-identical bodies).
#
# Behaviour contract under test (both clients):
# existing dir → printed as-is, rc 0, NO confirm, NO mkdir
# new dir + y → confirm gate passes, sudo mkdir -p, dir created, rc 0
# new dir + n → rc 1, nothing created (confirm EOF also denies)
# existing file → rc 1 + "not a directory" warning
# relative / trailing-slash / system paths / empty-EOF → rc 1, no side effects
# mkdir failure → rc 1 + "Could not create" warning
# stream → display on stderr, result on stdout
#
# Strategy (production logic is never re-typed): the REAL ask_mountpoint body
# is brace-extracted verbatim from each client file (extract_fn, same pattern
# as t-menu-allow-empty.sh / t-ai-server-validate.sh) and sourced; the REAL
# helper chain lib/common.sh (warn/confirm/run/log) + lib/share-lib.sh
# (share_ask_value → menu_ask_value → menu_read_value) is exercised through
# the deterministic NON-TTY stdin path (stty -g fails on a pipe → plain
# IFS= read, common.sh:181-186). Input is fed as `printf '%b' | fn`, exactly
# like t-menu-allow-empty.sh feeds menu_ask_value. A fake `sudo` on PATH
# records every invocation and honors SUDO_FAIL, so the sandbox never touches
# the real system. NOTE: bash suppresses read -rp prompt text on a pipe, so
# the confirm prompt string is never asserted — the confirm gate is proven
# behaviourally (feed y → created; feed n → rc 1; missing answer → EOF-deny
# rc 1) and by SUDO_LOG (mkdir attempted or not).
# extract_fn <source-file> <fnname> — print one brace-delimited function body.
extract_fn() {
local file="$1" fn="$2"
awk -v fn="$fn" '
BEGIN { found=0; depth=0 }
{
if (!found && $0 ~ ("^" fn "\\(\\)")) { found=1; depth=0 }
if (found) {
n_open = gsub(/\{/, "{")
n_close = gsub(/\}/, "}")
depth = depth + n_open - n_close
print
if (depth <= 0) exit
}
}
' "$file"
}
run_test() {
source "$ROOT/lib/common.sh" # warn / confirm / run / log
source "$ROOT/lib/share-lib.sh" # share_ask_value → REAL menu_ask_value
local sandbox stubs
sandbox="$(mksandbox share-mountpoint)"
stubs="$sandbox/stubs"
mkdir -p "$stubs"
# Fake sudo: records every invocation to $SUDO_LOG; exits 1 (no side
# effect) when SUDO_FAIL=1, otherwise `exec "$@"` (real mkdir) so the
# confirmed-create case genuinely creates the dir inside the sandbox.
cat > "$stubs/sudo" <<'STUB'
#!/usr/bin/env bash
printf 'sudo %s\n' "$*" >> "${SUDO_LOG:?fake sudo needs SUDO_LOG}"
if [ "${SUDO_FAIL:-0}" = "1" ]; then
printf 'sudo: permission denied (fake)\n' >&2
exit 1
fi
[ $# -gt 0 ] && exec "$@"
exit 0
STUB
chmod +x "$stubs/sudo"
export PATH="$stubs:$PATH"
export SUDO_LOG="$sandbox/sudo.log"
export SUDO_FAIL=0
: > "$SUDO_LOG"
# ═══ Part A0: non-TTY stdin contract of the real reader chain ═══
# The whole matrix below is only deterministic if menu_read_value falls
# back to plain `IFS= read` on a pipe and confirm() also reads plain
# stdin. Prove both against the REAL helpers.
local probe_out probe_rc
set +e
probe_out="$(printf 'x\n' | menu_read_value "probe" 2>/dev/null)"; probe_rc=$?
set -e
check_rc "non-TTY: menu_read_value pipe → rc 0 (plain-read fallback)" 0 "$probe_rc"
check_eq "non-TTY: menu_read_value pipe → value" "x" "$probe_out"
set +e
printf '' | stty -g >/dev/null 2>&1; probe_rc=$?
set -e
check_rc "non-TTY: stty -g fails on a pipe (plain-read path active)" 1 "$probe_rc"
set +e
printf 'y\n' | confirm probe n >/dev/null 2>&1; probe_rc=$?
set -e
check_rc "non-TTY: confirm y → rc 0" 0 "$probe_rc"
set +e
printf 'n\n' | confirm probe n >/dev/null 2>&1; probe_rc=$?
set -e
check_rc "non-TTY: confirm n → rc 1" 1 "$probe_rc"
set +e
printf '' | confirm probe n >/dev/null 2>&1; probe_rc=$?
set -e
check_rc "non-TTY: confirm EOF → rc 1 (fail-closed)" 1 "$probe_rc"
# ═══ Part A/B runner over the REAL extracted ask_mountpoint ═══
# mp_case <prefix> <desc> <feed> <expect_rc> <expect_out>
# [<stderr-needle> [<stderr-absent> [<sudo_fail>]]]
# feed = literal bytes: line 1 = mountpoint, line 2 (when present) = the
# confirm answer. SUDO_LOG resets per case; side effects asserted by the
# caller. SUDO_FAIL applies to the ask_mountpoint call only (assignment
# prefix on the pipeline element; exported attribute carries it to the
# fake sudo child process).
mp_case() {
local prefix="$1" desc="$2" feed="$3" expect_rc="$4" expect_out="$5"
local needle="${6:-}" absent="${7:-}" failflag="${8:-0}"
local out rc errf="$sandbox/mp.err"
: > "$SUDO_LOG"
set +e
out="$(printf '%b' "$feed" | SUDO_FAIL="$failflag" ask_mountpoint 2>"$errf")"
rc=$?
set -e
check_rc "$prefix: $desc (rc)" "$expect_rc" "$rc"
check_eq "$prefix: $desc (stdout)" "$expect_out" "$out"
if [ -n "$needle" ]; then
check_contains "$prefix: $desc (stderr)" "$needle" "$(cat "$errf")"
fi
if [ -n "$absent" ]; then
check_not_contains "$prefix: $desc (stderr)" "$absent" "$(cat "$errf")"
fi
}
local client file
for client in smb nfs; do
case "$client" in
smb) file="$ROOT/bin/pos-share-smb-client" ;;
nfs) file="$ROOT/bin/pos-share-nfs-client" ;;
esac
local fn_file="$sandbox/ask_mountpoint-$client.sh"
extract_fn "$file" ask_mountpoint > "$fn_file"
source "$fn_file" # REAL body — a syntax error here aborts (FAIL, never silent)
local cdir="$sandbox/$client"
mkdir -p "$cdir/existing"
touch "$cdir/file"
# 1. existing dir → as-is, rc 0. Feed has NO y/n line, so reaching the
# confirm gate would hit EOF → rc 1; rc 0 + empty SUDO_LOG proves
# the confirm/mkdir branch was never entered.
mp_case "$client" "existing dir → used as-is, no create" \
"$cdir/existing\n" 0 "$cdir/existing" "" "Created mount point"
check_eq "$client: existing dir → sudo NOT attempted" "" "$(cat "$SUDO_LOG")"
check_file_exists "$client: existing dir → fixture intact" "$cdir/existing"
# 2. new dir + confirm y → sudo mkdir -p, dir created, printed, logged
mp_case "$client" "new dir confirm=y → rc 0 + created" \
"$cdir/newdir\ny\n" 0 "$cdir/newdir" "Created mount point"
check_contains "$client: new dir → sudo mkdir -p attempted" \
"sudo mkdir -p $cdir/newdir" "$(cat "$SUDO_LOG")"
check_file_exists "$client: new dir → created" "$cdir/newdir"
# 3. new dir + confirm n → rc 1, nothing created
mp_case "$client" "new dir confirm=n → rc 1" \
"$cdir/declined\nn\n" 1 "" "" ""
check_eq "$client: declined → sudo NOT attempted" "" "$(cat "$SUDO_LOG")"
check_file_absent "$client: declined → no dir created" "$cdir/declined"
# 4. new dir + confirm EOF → rc 1 (gate fails closed)
mp_case "$client" "new dir confirm=EOF → rc 1 (fail-closed)" \
"$cdir/eof\n" 1 "" "" ""
check_eq "$client: confirm-EOF → sudo NOT attempted" "" "$(cat "$SUDO_LOG")"
check_file_absent "$client: confirm-EOF → no dir created" "$cdir/eof"
# 5. existing non-directory → rc 1 + not-a-directory warning
mp_case "$client" "existing file → rc 1 not-a-directory" \
"$cdir/file\n" 1 "" "not a directory" ""
check_eq "$client: file → sudo NOT attempted" "" "$(cat "$SUDO_LOG")"
# 6. relative path → rc 1, warned, no write
mp_case "$client" "relative path → rc 1" \
"relative/path\n" 1 "" "not an absolute path" ""
check_eq "$client: relative → sudo NOT attempted" "" "$(cat "$SUDO_LOG")"
# 7. trailing slash → rc 1, warned, no write
mp_case "$client" "trailing slash → rc 1" \
"$cdir/existing/\n" 1 "" "must not end with a slash" ""
check_eq "$client: trailing slash → sudo NOT attempted" "" "$(cat "$SUDO_LOG")"
# 8. system paths → rc 1 (shape glob fires before any FS access)
mp_case "$client" "system path /etc → rc 1" "/etc\n" 1 "" "Refusing system path" ""
mp_case "$client" "system path /root → rc 1" "/root\n" 1 "" "Refusing system path" ""
mp_case "$client" "system path /home/*/.ssh* → rc 1" \
"/home/ci-user/.ssh/authorized_keys\n" 1 "" "Refusing system path" ""
check_eq "$client: system paths → sudo NEVER attempted" "" "$(cat "$SUDO_LOG")"
# 9. empty/EOF on the path input → rc 1 (share_ask_value cancel)
mp_case "$client" "EOF on path → rc 1" "" 1 "" "" ""
check_eq "$client: EOF → sudo NOT attempted" "" "$(cat "$SUDO_LOG")"
# 10. mkdir failure (sudo shim fails) → rc 1 + could-not-create warning
mp_case "$client" "mkdir fails → rc 1" \
"$cdir/failmkdir\ny\n" 1 "" "Could not create" "" 1
check_file_absent "$client: mkdir-fail → no dir created" "$cdir/failmkdir"
# ═══ Part B: static guards — the old n=new flow must not regress ═══
local thint=0 tarm=0
grep -q 't=type' "$file" && thint=1
grep -q 't | T)' "$file" && tarm=1
check_eq "$client: t=type hint present" 1 "$thint"
check_eq "$client: t | T arm present" 1 "$tarm"
check_eq "$client: no n=new hint" 0 "$(grep -c 'n=new' "$file" || true)"
check_eq "$client: no n | N arm" 0 "$(grep -c 'n | N)' "$file" || true)"
check_eq "$client: no ask_new_mountpoint" 0 "$(grep -c 'ask_new_mountpoint' "$file" || true)"
# Behavioural contract: the existing-dir branch must precede the
# confirm/mkdir create flow inside the extracted body, so an existing
# dir can never reach confirm/mkdir.
check_eq "$client: ask_mountpoint has -d branch" 1 \
"$(grep -cF '[ -d "$dir" ]' "$fn_file" || true)"
check_eq "$client: ask_mountpoint has create flow" 1 \
"$(grep -cF 'confirm "Create mountpoint' "$fn_file" || true)"
local dline cline order=0
dline="$(grep -nF '[ -d "$dir" ]' "$fn_file" | head -1 | cut -d: -f1 || true)"
cline="$(grep -nF 'confirm "Create mountpoint' "$fn_file" | head -1 | cut -d: -f1 || true)"
[ -n "$dline" ] && [ -n "$cline" ] && [ "$dline" -lt "$cline" ] && order=1
check_eq "$client: existing-dir branch precedes confirm/mkdir" 1 "$order"
unset -f ask_mountpoint
done
# ═══ Part C: symmetry — the two shipped bodies must stay identical ═══
local same=0
cmp -s "$sandbox/ask_mountpoint-smb.sh" "$sandbox/ask_mountpoint-nfs.sh" && same=1
check_eq "ask_mountpoint bodies byte-identical (smb == nfs)" 1 "$same"
}