cloverleaf-larry/lib/nc-set-field.sh
Bryan Johnson 7a715c802a v0.8.31: nc_set_field — change a thread's PORT/HOST/PROCESSNAME/ENCODING (journaled)
New mutating tool, built on the proven journal/rollback foundation. Curated
safe field set only (rejects anything else; never creates a missing field).
Edits are line-number-anchored to the target thread's protocol block via
nc-parse (a shared port/host value in another thread is never touched),
brace-balance-checked before an atomic write, journaled for byte-identical
rollback. Flags: --dry-run (no write), --confirm yes, --site, --netconfig.
Copy-tested: PORT + HOST applied surgically, rollback byte-identical.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:43:27 -07:00

351 lines
15 KiB
Bash
Executable File

#!/usr/bin/env bash
# nc-set-field.sh — change ONE settable field on an existing thread's NetConfig
# protocol block, JOURNALED so it's rollback-reversible.
#
# Bryan's top-requested write feature: changing port numbers and IP addresses on
# an existing interface, safely and auditably. Built on the SAME journal/atomic-
# write foundation proven byte-identical-reversible by the v0.8.30 write/mutate
# pass — identical pattern to nc-table.sh / nc-insert-protocol.sh.
#
# Invocation:
# nc-set-field <thread>[.<site>] <field> <value> [opts]
#
# <thread> bare thread → resolved in $HCISITE
# <thread>.<site> cross-site form (e.g. ADTto_CodaMetrix.ancout)
#
# Options:
# --site SITE site override (alt to thread.site form)
# --dry-run show the before→after WITHOUT writing
# --confirm yes skip the interactive y/N prompt (still JOURNALED)
# --netconfig PATH explicit NetConfig path (overrides site resolution)
# --hciroot PATH override $HCIROOT for site resolution
# --completion emit a bash-completion snippet (thread names + field enum)
#
# SUPPORTED FIELDS (curated + explicit — anything else is REJECTED with a clear
# error; this tool NEVER blind-edits an arbitrary token):
# PORT → the nested PROTOCOL { … PORT <v> … } block's PORT
# HOST → the nested PROTOCOL { … HOST <v> … } block's HOST (alias: IP)
# IP → alias for HOST
# PROCESSNAME → the protocol block's top-level PROCESSNAME
# ENCODING → the protocol block's top-level ENCODING (must already exist)
#
# The edit is ANCHORED to the right thread's block and the right field via
# nc-parse.sh (protocol-block range + the field's canonical one-line render).
# It replaces ONLY that value token, preserves the surrounding TCL brace
# structure, and re-verifies balanced braces before the journal write. If the
# field is not present in the thread's block it DIES (it will not CREATE the
# field — that is nc_add_route / nc-insert-protocol territory).
#
# Every write goes through journal.sh (snapshot + diff + atomic write). Undo:
# larry-rollback.sh --target <netconfig> # newest-first
# larry-rollback.sh --entry <entry-id> # one specific write
#
# Exit codes: 0 OK, 1 generic error, 2 usage, 3 target not found, 4 no change.
set -o pipefail
NC_SELF="$0"
LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)"
NCP="$LIB_DIR/nc-parse.sh"
JOURNAL="$LIB_DIR/journal.sh"
die() { printf 'nc-set-field: %s\n' "$*" >&2; exit 1; }
# v0.8.31: shared CR-safety primitives + the tty-gated control-byte sanitizer.
# awk-emitted line numbers feed head/tail arithmetic; Cygwin awk.exe can taint
# them with a trailing CR. _sanitize_ctl_tty keeps human-readable output from
# corrupting a terminal while passing raw on a pipe.
if [ -r "$LIB_DIR/cygwin-safe.sh" ]; then
# shellcheck disable=SC1090,SC1091
. "$LIB_DIR/cygwin-safe.sh"
else
coerce_int() { local r="${1:-}" d="${2:-0}" c; c=$(printf '%s' "$r" | tr -cd '0-9'); printf '%s' "${c:-$d}"; }
_sanitize_ctl_tty() { cat; }
fi
# Source journal so we can call journal_write directly (same idiom as
# nc-insert-protocol.sh) — keeps entries in the running session.
# shellcheck disable=SC1090
[ -r "$JOURNAL" ] && . "$JOURNAL"
# ─────────────────────────────────────────────────────────────────────────────
# FIELD MAP — friendly name → exact NetConfig location.
# nested : value lives at PROTOCOL.<FIELD> inside the inner { PROTOCOL { … } }
# toplevel: value lives as a depth-1 { FIELD value } in the protocol block
# Anything not in this map is rejected; we never blind-edit arbitrary tokens.
# ─────────────────────────────────────────────────────────────────────────────
# Returns "<scope> <real_field>" on stdout, non-zero if unsupported.
resolve_field() {
case "$1" in
PORT) printf 'nested PORT' ;;
HOST|IP) printf 'nested HOST' ;;
PROCESSNAME) printf 'toplevel PROCESSNAME' ;;
ENCODING) printf 'toplevel ENCODING' ;;
*) return 1 ;;
esac
}
# Block line range for a protocol: "START,END" (1-based, inclusive). Reuses
# nc-parse's _blocks via protocol-line + brace-balanced end walk so the edit is
# scoped to exactly this thread.
block_range() {
local nc="$1" name="$2" start end
start=$("$NCP" protocol-line "$nc" "$name" 2>/dev/null)
start=$(coerce_int "$start" 0)
[ "$start" -gt 0 ] || return 1
end=$(awk -v s="$start" '
NR == s { depth = 1; in_block = 1; next }
in_block {
n_open = gsub(/\{/, "{", $0)
n_close = gsub(/\}/, "}", $0)
depth += n_open - n_close
if (depth == 0) { print NR; exit }
}
' "$nc")
end=$(coerce_int "$end" 0)
[ "$end" -ge "$start" ] || return 1
printf '%s,%s\n' "$start" "$end"
}
# Locate the exact line number of the target field WITHIN [start,end].
# scope=nested → inside the inner { PROTOCOL { … } } sub-block, the canonical
# one-line `{ FIELD value }` at that depth.
# scope=toplevel → the depth-1 `{ FIELD value }` directly under `protocol N {`.
# Prints the absolute line number, or nothing if not found.
field_line() {
local nc="$1" start="$2" end="$3" scope="$4" field="$5"
awk -v S="$start" -v E="$end" -v SCOPE="$scope" -v F="$field" '
BEGIN { depth = 0; in_proto = 0; proto_depth = 0 }
NR < S { next }
NR > E { exit }
{
line = $0
prev = depth
# Detect entry into the nested { PROTOCOL { ... } } sub-block. At NR==S the
# protocol-block opener `protocol N {` set depth to 1 conceptually; we track
# depth deltas from there. A line ` { PROTOCOL {` is the nested opener.
is_proto_open = (line ~ /^[[:space:]]+\{ PROTOCOL \{$/)
if (SCOPE == "toplevel") {
# depth-1 field statement: ` { FIELD value }` entirely on one line,
# at the protocol-block top level (one indent in). prev-depth bookkeeping
# mirrors nc-parse cmd_protocol_field (baseline 1).
if (prev == 1 && line ~ ("^[[:space:]]+\\{ " F " ")) { print NR; found=1; exit }
} else {
# nested: only consider lines once we are inside { PROTOCOL { ... } }.
if (in_proto && line ~ ("^[[:space:]]+\\{ " F " ")) { print NR; found=1; exit }
}
n_open = gsub(/\{/, "{", line)
n_close = gsub(/\}/, "}", line)
depth += n_open - n_close
if (is_proto_open && !in_proto) { in_proto = 1; proto_depth = prev + 1 }
else if (in_proto && depth < proto_depth) { in_proto = 0 }
}
END { if (!found) exit 1 }
' "$nc"
}
# Extract the current value token from a ` { FIELD value }` line.
# Strips the leading `{ FIELD ` and trailing ` }`. Leaves the raw value
# (which may itself be `{}` or a braced list — we replace the whole token).
extract_value() {
local line="$1" field="$2"
line="${line#"${line%%[![:space:]]*}"}" # ltrim
line="${line#\{ "$field" }" # drop "{ FIELD "
line="${line%"${line##*[![:space:]]}"}" # rtrim (drops space before })
line="${line%\}}" # drop the single trailing "}"
line="${line%"${line##*[![:space:]]}"}" # rtrim again (space between value and })
printf '%s' "$line"
}
# Build a replacement line preserving the original indentation + brace shape:
# `<indent>{ FIELD <newval> }`
build_line() {
local orig="$1" field="$2" newval="$3" indent
indent="${orig%%[![:space:]]*}" # leading whitespace
printf '%s{ %s %s }' "$indent" "$field" "$newval"
}
# Verify balanced braces across the whole file (open == close). Cheap structural
# guard run AFTER the edit, BEFORE the journal write.
braces_balanced() {
awk '{ o += gsub(/\{/, "{"); c += gsub(/\}/, "}") } END { exit (o==c)?0:1 }' "$1"
}
emit_completion() {
cat <<'COMP'
# bash completion for `larry tools nc-set-field` and a standalone `nc-set-field`.
# Source this file (or `eval "$(larry tools nc-set-field --completion)"`).
# Completes: thread names (from the resolved NetConfig) for arg 1, and the
# curated field enum for arg 2.
_nc_set_field_complete() {
local cur prev words cword
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
local nfields="PORT HOST IP PROCESSNAME ENCODING"
# Find the lib dir (next to larry.sh) to call nc-parse for thread names.
local lib="${LARRY_LIB_DIR:-}"
[ -z "$lib" ] && lib="$(dirname "$(command -v nc-set-field.sh 2>/dev/null || echo .)")"
local nc="${HCIROOT:-}/${HCISITE:-}/NetConfig"
case "$COMP_CWORD" in
1) # thread name (optionally bare; thread.site also fine)
if [ -f "$nc" ] && [ -x "$lib/nc-parse.sh" ]; then
local threads; threads=$("$lib/nc-parse.sh" list-protocols "$nc" 2>/dev/null)
COMPREPLY=( $(compgen -W "$threads" -- "$cur") )
fi ;;
2) COMPREPLY=( $(compgen -W "$nfields" -- "$cur") ) ;;
esac
}
complete -F _nc_set_field_complete nc-set-field nc-set-field.sh
COMP
}
# ─────────────────────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────────────────────
THREAD=""; FIELD_IN=""; VALUE=""
SITE="${HCISITE:-}"; NC=""; HCIROOT_OVR="${HCIROOT:-}"
DRY=0; CONFIRM=""
POSARGS=()
while [ $# -gt 0 ]; do
case "$1" in
--site) shift; SITE="$1" ;;
--netconfig) shift; NC="$1" ;;
--hciroot) shift; HCIROOT_OVR="$1" ;;
--dry-run) DRY=1 ;;
--confirm) shift; CONFIRM="$1" ;;
--completion) emit_completion; exit 0 ;;
-h|--help) sed -n '2,43p' "$NC_SELF"; exit 0 ;;
--*) die "unknown flag: $1" ;;
*) POSARGS+=("$1") ;;
esac
shift
done
[ "${#POSARGS[@]}" -ge 3 ] || { echo "usage: nc-set-field <thread>[.<site>] <field> <value> [--site S] [--dry-run] [--confirm yes] [--netconfig PATH] [--hciroot PATH]" >&2; exit 2; }
THREAD="${POSARGS[0]}"; FIELD_IN="${POSARGS[1]}"; VALUE="${POSARGS[2]}"
# Parse thread.site form (the dot-suffix wins over a bare --site default, but a
# site supplied via thread.site AND --site that disagree is an error).
if [[ "$THREAD" == *.* ]]; then
dot_site="${THREAD##*.}"
THREAD="${THREAD%.*}"
if [ -n "$SITE" ] && [ "$SITE" != "${HCISITE:-}" ] && [ "$SITE" != "$dot_site" ]; then
die "site conflict: thread says '.$dot_site' but --site says '$SITE'"
fi
SITE="$dot_site"
fi
# Field validation (curated set ONLY — explicit reject otherwise).
FIELD_IN_UC=$(printf '%s' "$FIELD_IN" | tr '[:lower:]' '[:upper:]')
RESOLVED=$(resolve_field "$FIELD_IN_UC") \
|| die "unsupported field: '$FIELD_IN'. Supported: PORT, HOST (alias IP), PROCESSNAME, ENCODING. (This tool only edits a curated safe set; it will not blind-edit arbitrary NetConfig tokens.)"
SCOPE="${RESOLVED%% *}"
REAL_FIELD="${RESOLVED##* }"
[ -n "$VALUE" ] || die "value is required (got empty)"
# Resolve the NetConfig path.
if [ -z "$NC" ]; then
[ -n "$SITE" ] || die "no site: pass <thread>.<site>, --site SITE, or set \$HCISITE (or give --netconfig PATH)"
[ -n "$HCIROOT_OVR" ] || die "no \$HCIROOT (and no --hciroot / --netconfig). Cannot resolve the site's NetConfig."
NC="$HCIROOT_OVR/$SITE/NetConfig"
fi
[ -f "$NC" ] || { printf 'nc-set-field: no such NetConfig: %s\n' "$NC" >&2; exit 3; }
# Confirm the thread exists.
"$NCP" list-protocols "$NC" 2>/dev/null | grep -qx "$THREAD" \
|| { printf 'nc-set-field: no such thread in %s: %s\n' "$NC" "$THREAD" >&2; exit 3; }
# Find the thread's protocol-block line range.
RANGE=$(block_range "$NC" "$THREAD") || die "could not determine block range for thread $THREAD"
B_START="${RANGE%,*}"; B_END="${RANGE#*,}"
# Locate the exact field line within that block.
TARGET_LINE=$(field_line "$NC" "$B_START" "$B_END" "$SCOPE" "$REAL_FIELD")
TARGET_LINE=$(coerce_int "$TARGET_LINE" 0)
if [ "$TARGET_LINE" -le 0 ]; then
case "$SCOPE" in
nested) die "field $REAL_FIELD not found in thread $THREAD's nested PROTOCOL{} block (looked in $NC lines $B_START-$B_END). This tool changes EXISTING fields only — it will not create one." ;;
toplevel) die "top-level field $REAL_FIELD not found in thread $THREAD's protocol block (looked in $NC lines $B_START-$B_END). This tool changes EXISTING fields only — it will not create one." ;;
esac
fi
ORIG_LINE=$(sed -n "${TARGET_LINE}p" "$NC")
OLD_VALUE=$(extract_value "$ORIG_LINE" "$REAL_FIELD")
NEW_LINE=$(build_line "$ORIG_LINE" "$REAL_FIELD" "$VALUE")
# Display form of the old value: an empty value renders as the Tcl empty list
# `{}` (computed separately — inlining `${OLD_VALUE:-{}}` mis-parses the braces).
OLD_DISP="$OLD_VALUE"
[ -z "$OLD_DISP" ] && OLD_DISP='{}'
# No-op guard.
if [ "$OLD_VALUE" = "$VALUE" ]; then
printf 'nc-set-field: %s.%s %s is already %s — nothing to change.\n' \
"$THREAD" "$SITE" "$FIELD_IN_UC" "$VALUE" | _sanitize_ctl_tty
exit 4
fi
# Show the before→after (always — for dry-run AND the confirm prompt).
{
printf '\n=== %s (%s) %s ===\n' "$THREAD" "${SITE:-<site>}" "$NC"
printf 'field: %s (%s %s, line %s)\n' "$FIELD_IN_UC" "$SCOPE" "$REAL_FIELD" "$TARGET_LINE"
printf 'before: %s\n' "$ORIG_LINE"
printf 'after: %s\n' "$NEW_LINE"
printf ' %s → %s\n\n' "$OLD_DISP" "$VALUE"
} | _sanitize_ctl_tty
if [ "$DRY" = "1" ]; then
printf '(dry-run; no write made)\n' | _sanitize_ctl_tty
exit 0
fi
# Build the candidate file: lines 1..(target-1) + new line + (target+1)..EOF.
TMP=$(mktemp)
head -n $((TARGET_LINE - 1)) "$NC" > "$TMP"
printf '%s\n' "$NEW_LINE" >> "$TMP"
tail -n +$((TARGET_LINE + 1)) "$NC" >> "$TMP"
# Structural guard: braces must remain balanced (open == close).
if ! braces_balanced "$TMP"; then
rm -f "$TMP"
die "post-edit brace check FAILED (open != close) — refusing to write. The edit would have broken the TCL structure; nothing was changed."
fi
# Confirm unless --confirm yes.
if [ "$CONFIRM" != "yes" ]; then
printf 'Apply this change? [y/N]: ' | _sanitize_ctl_tty
ans=""
if declare -f read_clean >/dev/null 2>&1; then
read_clean ans
else
read -r ans </dev/tty 2>/dev/null || ans=""
ans="${ans//$'\r'/}"
fi
if ! [[ "$ans" =~ ^[Yy]$ ]]; then
rm -f "$TMP"
printf 'aborted (no change)\n' | _sanitize_ctl_tty
exit 1
fi
fi
# Journaled atomic write.
if declare -f journal_write >/dev/null 2>&1; then
ENTRY_ID=$(journal_write "$NC" "$TMP")
rm -f "$TMP"
{
printf '\nset %s.%s %s = %s (was %s)\n' "$THREAD" "${SITE:-?}" "$FIELD_IN_UC" "$VALUE" "$OLD_DISP"
printf 'journal entry: %s\n' "$ENTRY_ID"
printf 'rollback: larry-rollback.sh --entry %s OR larry-rollback.sh --target %s\n' "$ENTRY_ID" "$NC"
} | _sanitize_ctl_tty
else
# No journal available — degrade with a plain backup (mirrors nc-table.sh).
_ts=$(date +%s | tr -cd '0-9')
cp -p "$NC" "${NC}.larry-bak.${_ts:-0}"
mv -f "$TMP" "$NC"
printf '(no journal available; backup at %s.larry-bak.%s)\n' "$NC" "${_ts:-0}" | _sanitize_ctl_tty
fi