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>
351 lines
15 KiB
Bash
Executable File
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
|