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.
This commit is contained in:
Your Name
2026-09-07 08:08:02 -04:00
parent 55a911fb37
commit 35eb90a58b
4 changed files with 54 additions and 37 deletions
+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-hotspot` | 93 | Wi-Fi hotspot via create_ap + wihotspot-gui |
| `bin/pos-network-ip` | 69 | Show interfaces, routes, public IP + location | | `bin/pos-network-ip` | 69 | Show interfaces, routes, public IP + location |
| `bin/pos-network-scan` | 272 | Parallel ping sweep of CIDR | | `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-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-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-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-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 (mount / persist / unmount / unpersist / list). Mountpoints are offered from
existing mount-layout candidates with manual entry as fallback — the picker 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 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 differs from your local layout, and `t=type` lets you type an absolute
place (y/N confirmed; a failure just returns to the picker). Unmount lists mountpoint — an existing directory is used as-is, a non-existent path
the active NFS mounts as `<mountpoint> ← <source>` picks and asks for creates it in place (y/N confirmed; a failure just returns to the picker).
confirmation before unmounting (with a typed fallback when nothing is Unmount lists the active NFS mounts as `<mountpoint> ← <source>` picks and
mounted); unmount and unpersist tolerate already-absent targets instead of asks for confirmation before unmounting (with a typed fallback when nothing
erroring. is mounted); unmount and unpersist tolerate already-absent targets instead
of erroring.
**Troubleshooting:** **Troubleshooting:**
- "mount.nfs not found" → `nfs-common` isn't installed; `sudo apt install nfs-common` - "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), an empty user tries guest enumeration first (with an auth retry on denial),
then shares and mountpoints are offered as pickers with manual fallback — then shares and mountpoints are offered as pickers with manual fallback —
the account you authenticated with is reused for the mount. The mountpoint 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; picker accepts `t=type` to type an absolute mountpoint — an existing
a failure just returns to the picker); when the server is this machine, its directory is used as-is, a non-existent path creates it in place (y/N
underlying share directory is offered as a "(as on server)" pick too. 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 Unmount lists the active CIFS mounts as `<mountpoint> ← <source>` picks and
asks for confirmation before unmounting (typed fallback when nothing is asks for confirmation before unmounting (typed fallback when nothing is
mounted). mounted).
+20 -13
View File
@@ -223,8 +223,8 @@ cmd_unpersist() {
# ── Interactive menu flows ───────────────────────────────────── # ── Interactive menu flows ─────────────────────────────────────
# Mountpoint picker — local port of the share_pick primitive with exactly # Mountpoint picker — local port of the share_pick primitive with exactly
# two deltas: the hint line offers `n=new`, and typing n runs the # two deltas: the hint line offers `t=type`, and typing t runs the
# create-new-dir flow below. share_pick cannot intercept `n` (it filters on # 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 # 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. # 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. # stdout: chosen item text (or the freshly created dir) · rc 1 = back/cancel.
@@ -263,16 +263,16 @@ pick_mountpoint() {
done done
fi fi
} >&2 } >&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 return 1 # EOF — cancel
fi fi
case "$ans" in case "$ans" in
"") [ -z "$filter" ] || filter="" ; continue ;; "") [ -z "$filter" ] || filter="" ; continue ;;
"/") filter="" ; continue ;; "/") filter="" ; continue ;;
0 | q | Q | b | B) return 1 ;; 0 | q | Q | b | B) return 1 ;;
n | N) t | T)
made="$(ask_new_mountpoint)" && { echo "$made"; return 0; } made="$(ask_mountpoint)" && { echo "$made"; return 0; }
continue # declined/invalid/mkdir-failed → redraw continue # cancelled/invalid/mkdir-failed → redraw
;; ;;
*[!0-9]*) *[!0-9]*)
filter="$ans" filter="$ans"
@@ -289,15 +289,16 @@ pick_mountpoint() {
done done
} }
# Create-new-dir flow behind the picker's `n` key. Validates the shape # Manual entry flow behind the picker's `t` key. Validates the shape
# (absolute, no trailing slash), confirm-gates the creation, then mkdir -p. # (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 — # Any decline, invalid input, EOF or mkdir failure is a warning + rc 1 —
# the picker redraws, the tool never aborts. # 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 # NOTE: runs inside $( ) from the picker — every display line MUST go to
# stderr (menu-lib contract: display → stderr, result → stdout). # stderr (menu-lib contract: display → stderr, result → stdout).
local dir local dir
dir="$(share_ask_value "New mountpoint (absolute path)")" || return 1 dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1
case "$dir" in case "$dir" in
/*) ;; /*) ;;
*) warn "'$dir' is not an absolute path — must start with /" >&2; return 1 ;; *) 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 return 1
;; ;;
esac 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 confirm "Create mountpoint ${dir}?" n || return 1
if ! run sudo mkdir -p "$dir"; then if ! run sudo mkdir -p "$dir"; then
warn "Could not create ${dir}" >&2 warn "Could not create ${dir}" >&2
@@ -371,9 +380,7 @@ menu_ask_mountpoint() { # [server_path] — stdout: absolute path · rc 1 cancel
esac esac
fi fi
fi fi
dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1 ask_mountpoint
[ -n "$dir" ] || { warn "No mountpoint given"; return 1; }
echo "$dir"
} }
menu_mount() { 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 # Mountpoint picker — local port of the share_pick primitive with exactly
# two deltas: the hint line offers `n=new`, and typing n runs the # two deltas: the hint line offers `t=type`, and typing t runs the
# create-new-dir flow below. share_pick cannot intercept `n` (it filters on # 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 # 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. # 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. # stdout: chosen item text (or the freshly created dir) · rc 1 = back/cancel.
@@ -535,16 +535,16 @@ pick_mountpoint() {
done done
fi fi
} >&2 } >&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 return 1 # EOF — cancel
fi fi
case "$ans" in case "$ans" in
"") [ -z "$filter" ] || filter="" ; continue ;; "") [ -z "$filter" ] || filter="" ; continue ;;
"/") filter="" ; continue ;; "/") filter="" ; continue ;;
0 | q | Q | b | B) return 1 ;; 0 | q | Q | b | B) return 1 ;;
n | N) t | T)
made="$(ask_new_mountpoint)" && { echo "$made"; return 0; } made="$(ask_mountpoint)" && { echo "$made"; return 0; }
continue # declined/invalid/mkdir-failed → redraw continue # cancelled/invalid/mkdir-failed → redraw
;; ;;
*[!0-9]*) *[!0-9]*)
filter="$ans" filter="$ans"
@@ -561,15 +561,16 @@ pick_mountpoint() {
done done
} }
# Create-new-dir flow behind the picker's `n` key. Validates the shape # Manual entry flow behind the picker's `t` key. Validates the shape
# (absolute, no trailing slash), confirm-gates the creation, then mkdir -p. # (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 — # Any decline, invalid input, EOF or mkdir failure is a warning + rc 1 —
# the picker redraws, the tool never aborts. # 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 # NOTE: runs inside $( ) from the picker — every display line MUST go to
# stderr (menu-lib contract: display → stderr, result → stdout). # stderr (menu-lib contract: display → stderr, result → stdout).
local dir local dir
dir="$(share_ask_value "New mountpoint (absolute path)")" || return 1 dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1
case "$dir" in case "$dir" in
/*) ;; /*) ;;
*) warn "'$dir' is not an absolute path — must start with /" >&2; return 1 ;; *) 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 return 1
;; ;;
esac 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 confirm "Create mountpoint ${dir}?" n || return 1
if ! run sudo mkdir -p "$dir"; then if ! run sudo mkdir -p "$dir"; then
warn "Could not create ${dir}" >&2 warn "Could not create ${dir}" >&2
@@ -624,9 +633,7 @@ menu_ask_mountpoint() { # [server_path] — stdout: absolute path · rc 1 cancel
esac esac
fi fi
fi fi
dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1 ask_mountpoint
[ -n "$dir" ] || { warn "No mountpoint given"; return 1; }
echo "$dir"
} }
menu_mount() { # ephemeral|persist menu_mount() { # ephemeral|persist