Edit

title = "Per-subnet serve-state: spt subnet detach <NAME> [--save] / attach <NAME> [--save] — daemon keeps running, stops/starts advertising + connecting for that subnet (peer pump + responder selective); --save persists the startup default in daemon config; the all-attached banner gains per-subnet states (M8 decision 6, --save renamed from --auto per decision 25 session)"
required_stages = []

title = "Per-subnet serve-state: spt subnet detach <NAME> [--save] / attach <NAME> [--save] — daemon keeps running, stops/starts advertising + connecting for that subnet (peer pump + responder selective); --save persists the startup default in daemon config; the all-attached banner gains per-subnet states (M8 decision 6, --save renamed from --auto per decision 25 session)"
required_stages = ["impl", "unit"]  # activated M8-D2: spt_store::attachment::AttachmentStore (identity/attachment.json live state, all-attached default, corrupt-degrades-attached) + daemon boot reset from the new daemon.json detached_subnets knob (--save writes it; unsaved flips don't survive a restart by design); gating at all three serve points re-read per round — pump per-subnet skip (peerloop), pairing responder filter_serving view, meet-rotation name filter; CLI cmd_subnet_attachment (ungated — no trust mutation) + per-subnet banner states (detached / attached / no connection). unit = store flip/roundtrip/corrupt + boot-reset/filter + config knob + arg shapes


The file C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml has been updated successfully. (file state is current in your context — no need to Read it back)

title = "Per-subnet serve-state: spt subnet detach <NAME> [--save] / attach <NAME> [--save] — daemon keeps running, stops/starts advertising + connecting for that subnet (peer pump + responder selective); --save persists the startup default in daemon config; the all-attached banner gains per-subnet states (M8 decision 6, --save renamed from --auto per decision 25 session)"
required_stages = []

title = "Per-subnet serve-state: spt subnet detach <NAME> [--save] / attach <NAME> [--save] — daemon keeps running, stops/starts advertising + connecting for that subnet (peer pump + responder selective); --save persists the startup default in daemon config; the all-attached banner gains per-subnet states (M8 decision 6, --save renamed from --auto per decision 25 session)"
required_stages = ["impl", "unit"]  # activated M8-D2: spt_store::attachment::AttachmentStore (identity/attachment.json live state, all-attached default, corrupt-degrades-attached) + daemon boot reset from the new daemon.json detached_subnets knob (--save writes it; unsaved flips don't survive a restart by design); gating at all three serve points re-read per round — pump per-subnet skip (peerloop), pairing responder filter_serving view, meet-rotation name filter; CLI cmd_subnet_attachment (ungated — no trust mutation) + per-subnet banner states (detached / attached / no connection). unit = store flip/roundtrip/corrupt + boot-reset/filter + config knob + arg shapes

+required_stages = ["impl", "unit"]  # activated M8-D2: spt_store::attachment::AttachmentStore (identity/attachment.json live state, all-attached default, corrupt-degrades-attached) + daemon boot reset from the new daemon.json detached_subnets knob (--save writes it; unsaved flips don't survive a restart by design); gating at all three serve points re-read per round — pump per-subnet skip (peerloop), pairing responder filter_serving view, meet-rotation name filter; CLI cmd_subnet_attachment (ungated — no trust mutation) + per-subnet banner states (detached / attached / no connection). unit = store flip/roundtrip/corrupt + boot-reset/filter + config knob + arg shapes

title = "Trust lifecycle verbs, elevation-gated: spt subnet leave <NAME> (membership exit) and spt subnet prune <node> (removes a dead identity's trust + registry rows, killing its dead dials; trust mutation = security surface, REQ-PAIR-6 gate machinery) (M8 decisions 6-7)"


title = "Trust lifecycle verbs, elevation-gated: spt subnet leave <NAME> (membership exit) and spt subnet prune <node> (removes a dead identity's trust + registry rows, killing its dead dials; trust mutation = security surface, REQ-PAIR-6 gate machinery) (M8 decisions 6-7)"
required_stages = []

title = "Trust lifecycle verbs, elevation-gated: spt subnet leave <NAME> (membership exit) and spt subnet prune <node> (removes a dead identity's trust + registry rows, killing its dead dials; trust mutation = security surface, REQ-PAIR-6 gate machinery) (M8 decisions 6-7)"
required_stages = ["impl", "unit"]  # activated M8-D2: cmd_subnet_leave (gate-first; drops seed + the subnet's trust rows + serve-state row + registry snapshot; remaining members' seed rotation honestly out of reach from here) + cmd_subnet_prune (gate-first; prune_candidates resolves full hex / unambiguous prefix / label, refuses ambiguous + own identity; drops the identity's trust rows everywhere + registry snapshot rows). unit = trust_mutation_gate truth table + prune_candidates decision table + arg shapes. BIGNET's 09ef831e rows = the acceptance fixture (criterion 5)


title = "Trust lifecycle verbs, elevation-gated: spt subnet leave <NAME> (membership exit) and spt subnet prune <node> (removes a dead identity's trust + registry rows, killing its dead dials; trust mutation = security surface, REQ-PAIR-6 gate machinery) (M8 decisions 6-7)"
required_stages = []

title = "Trust lifecycle verbs, elevation-gated: spt subnet leave <NAME> (membership exit) and spt subnet prune <node> (removes a dead identity's trust + registry rows, killing its dead dials; trust mutation = security surface, REQ-PAIR-6 gate machinery) (M8 decisions 6-7)"
required_stages = ["impl", "unit"]  # activated M8-D2: cmd_subnet_leave (gate-first; drops seed + the subnet's trust rows + serve-state row + registry snapshot; remaining members' seed rotation honestly out of reach from here) + cmd_subnet_prune (gate-first; prune_candidates resolves full hex / unambiguous prefix / label, refuses ambiguous + own identity; drops the identity's trust rows everywhere + registry snapshot rows). unit = trust_mutation_gate truth table + prune_candidates decision table + arg shapes. BIGNET's 09ef831e rows = the acceptance fixture (criterion 5)

+required_stages = ["impl", "unit"]  # activated M8-D2: cmd_subnet_leave (gate-first; drops seed + the subnet's trust rows + serve-state row + registry snapshot; remaining members' seed rotation honestly out of reach from here) + cmd_subnet_prune (gate-first; prune_candidates resolves full hex / unambiguous prefix / label, refuses ambiguous + own identity; drops the identity's trust rows everywhere + registry snapshot rows). unit = trust_mutation_gate truth table + prune_candidates decision table + arg shapes. BIGNET's 09ef831e rows = the acceptance fixture (criterion 5)

title = "Per-machine re-pair trust overwrite: registry rows carry a hashed stable machine identifier (OS machine id /etc/machine-id|MachineGuid, domain-separated SHA-256 before gossip, spt-minted persisted UUID fallback; additive serde-default field — old rows parse clean); a COMPLETED pairing ceremony presenting the same node label AND machine id as an existing trusted row evicts the superseded identity's trust + registry rows on the seed-holder and replicates the eviction; a gossiped claim alone never evicts trust (M8 decisions 13, 22)"


title = "Per-machine re-pair trust overwrite: registry rows carry a hashed stable machine identifier (OS machine id /etc/machine-id|MachineGuid, domain-separated SHA-256 before gossip, spt-minted persisted UUID fallback; additive serde-default field — old rows parse clean); a COMPLETED pairing ceremony presenting the same node label AND machine id as an existing trusted row evicts the superseded identity's trust + registry rows on the seed-holder and replicates the eviction; a gossiped claim alone never evicts trust (M8 decisions 13, 22)"
required_stages = []

title = "Per-machine re-pair trust overwrite: registry rows carry a hashed stable machine identifier (OS machine id /etc/machine-id|MachineGuid, domain-separated SHA-256 before gossip, spt-minted persisted UUID fallback; additive serde-default field — old rows parse clean); a COMPLETED pairing ceremony presenting the same node label AND machine id as an existing trusted row evicts the superseded identity's trust + registry rows on the seed-holder and replicates the eviction; a gossiped claim alone never evicts trust (M8 decisions 13, 22)"
required_stages = ["impl", "unit"]  # activated M8-D2: spt_daemon::machineid (OS id → domain-separated sha256, minted-fallback chain; raw never leaves the machine) + Instance.machine_id additive field stamped by advertise_local + the Hello NodeIntro wire extension (trailing additive fields; old responder ignores, half-intro refuses) + ResponderOutcome carrying the ceremony facts + pairhost post-confirm eviction via registryhost::{superseded_identities, repair_evict_superseded} (ceremony-subnet-scoped trust drop + disk snapshot rewrite + repair-evict marker) + RegistryHost::consume_repair_evictions on the pump's registry tick (in-memory rows + heard entry go before the snapshot mirror could resurrect them; peer-side replication = the superseded node's silence under the 4.10 eviction + the fresh identity's rows). unit = hash domain-separation/stability + intro wire roundtrip/half-refusal + superseded both-anchors decision + evict trust/snapshot/memory/marker-once + pre-M8 row serde. Acceptance 7 = the rig re-pair proof


title = "Per-machine re-pair trust overwrite: registry rows carry a hashed stable machine identifier (OS machine id /etc/machine-id|MachineGuid, domain-separated SHA-256 before gossip, spt-minted persisted UUID fallback; additive serde-default field — old rows parse clean); a COMPLETED pairing ceremony presenting the same node label AND machine id as an existing trusted row evicts the superseded identity's trust + registry rows on the seed-holder and replicates the eviction; a gossiped claim alone never evicts trust (M8 decisions 13, 22)"
required_stages = []

title = "Per-machine re-pair trust overwrite: registry rows carry a hashed stable machine identifier (OS machine id /etc/machine-id|MachineGuid, domain-separated SHA-256 before gossip, spt-minted persisted UUID fallback; additive serde-default field — old rows parse clean); a COMPLETED pairing ceremony presenting the same node label AND machine id as an existing trusted row evicts the superseded identity's trust + registry rows on the seed-holder and replicates the eviction; a gossiped claim alone never evicts trust (M8 decisions 13, 22)"
required_stages = ["impl", "unit"]  # activated M8-D2: spt_daemon::machineid (OS id → domain-separated sha256, minted-fallback chain; raw never leaves the machine) + Instance.machine_id additive field stamped by advertise_local + the Hello NodeIntro wire extension (trailing additive fields; old responder ignores, half-intro refuses) + ResponderOutcome carrying the ceremony facts + pairhost post-confirm eviction via registryhost::{superseded_identities, repair_evict_superseded} (ceremony-subnet-scoped trust drop + disk snapshot rewrite + repair-evict marker) + RegistryHost::consume_repair_evictions on the pump's registry tick (in-memory rows + heard entry go before the snapshot mirror could resurrect them; peer-side replication = the superseded node's silence under the 4.10 eviction + the fresh identity's rows). unit = hash domain-separation/stability + intro wire roundtrip/half-refusal + superseded both-anchors decision + evict trust/snapshot/memory/marker-once + pre-M8 row serde. Acceptance 7 = the rig re-pair proof

+required_stages = ["impl", "unit"]  # activated M8-D2: spt_daemon::machineid (OS id → domain-separated sha256, minted-fallback chain; raw never leaves the machine) + Instance.machine_id additive field stamped by advertise_local + the Hello NodeIntro wire extension (trailing additive fields; old responder ignores, half-intro refuses) + ResponderOutcome carrying the ceremony facts + pairhost post-confirm eviction via registryhost::{superseded_identities, repair_evict_superseded} (ceremony-subnet-scoped trust drop + disk snapshot rewrite + repair-evict marker) + RegistryHost::consume_repair_evictions on the pump's registry tick (in-memory rows + heard entry go before the snapshot mirror could resurrect them; peer-side replication = the superseded node's silence under the 4.10 eviction + the fresh identity's rows). unit = hash domain-separation/stability + intro wire roundtrip/half-refusal + superseded both-anchors decision + evict trust/snapshot/memory/marker-once + pre-M8 row serde. Acceptance 7 = the rig re-pair proof

title = "Status render honesty: zero-subnet text is daemon-aware ('No subnets registered — this node is standalone.' + daemon-running-dependent blurb, never implying messaging works while the daemon is down); hint footer prints on bare spt subnet only (status drops it); a stalled pump is surfaced in subnet status, never rendered implied-healthy (M8 decisions 11-12, 23)"


title = "Status render honesty: zero-subnet text is daemon-aware ('No subnets registered — this node is standalone.' + daemon-running-dependent blurb, never implying messaging works while the daemon is down); hint footer prints on bare spt subnet only (status drops it); a stalled pump is surfaced in subnet status, never rendered implied-healthy (M8 decisions 11-12, 23)"
required_stages = []

title = "Status render honesty: zero-subnet text is daemon-aware ('No subnets registered — this node is standalone.' + daemon-running-dependent blurb, never implying messaging works while the daemon is down); hint footer prints on bare spt subnet only (status drops it); a stalled pump is surfaced in subnet status, never rendered implied-healthy (M8 decisions 11-12, 23)"
required_stages = ["impl", "unit"]  # activated M8-D2: standalone_text(daemon_running) keyed on the live socket ping; hints flag threaded from the dispatch (bare subnet = true, explicit status = false); pump_stall_warning over read_pump_heartbeat (3-cadence staleness; absent file = pre-D4, no false alarm) appended to both status views. unit = daemon-up/down standalone variants + hints-only-on-bare + bare-vs-status dispatch


title = "Status render honesty: zero-subnet text is daemon-aware ('No subnets registered — this node is standalone.' + daemon-running-dependent blurb, never implying messaging works while the daemon is down); hint footer prints on bare spt subnet only (status drops it); a stalled pump is surfaced in subnet status, never rendered implied-healthy (M8 decisions 11-12, 23)"
required_stages = []

title = "Status render honesty: zero-subnet text is daemon-aware ('No subnets registered — this node is standalone.' + daemon-running-dependent blurb, never implying messaging works while the daemon is down); hint footer prints on bare spt subnet only (status drops it); a stalled pump is surfaced in subnet status, never rendered implied-healthy (M8 decisions 11-12, 23)"
required_stages = ["impl", "unit"]  # activated M8-D2: standalone_text(daemon_running) keyed on the live socket ping; hints flag threaded from the dispatch (bare subnet = true, explicit status = false); pump_stall_warning over read_pump_heartbeat (3-cadence staleness; absent file = pre-D4, no false alarm) appended to both status views. unit = daemon-up/down standalone variants + hints-only-on-bare + bare-vs-status dispatch

+required_stages = ["impl", "unit"]  # activated M8-D2: standalone_text(daemon_running) keyed on the live socket ping; hints flag threaded from the dispatch (bare subnet = true, explicit status = false); pump_stall_warning over read_pump_heartbeat (3-cadence staleness; absent file = pre-D4, no false alarm) appended to both status views. unit = daemon-up/down standalone variants + hints-only-on-bare + bare-vs-status dispatch

title = "Linux elevation install leg: install.sh symlinks the binary into a sudo-reachable path (/usr/local/bin; graceful print-the-one-liner when unelevated) so sudo spt resolves; first sudo spt detects elevation and prompts ONCE for the default user account — thereafter any elevated daemon launch runs daemon + state under that account, never root (KH 5.7 interplay verified) (M8 decision 8)"


PowerShell

cargo run -q -p xtask -- gen 2>&1 | Select-Object -Last 2; traceable-reqs check 2>&1 | Select-Object -First 1; echo "tr-exit=$LASTEXITCODE"

Regen reference + traceability gate


resources --> new flag under `spt endpoint list`: `--detail` --- change the `spt subnet attach/detach --auto` flag to `--save`. clearer semantics.


Update CLI flags for endpoint and subnet commands


xtask gen: wrote C:\Users\decid\Documents\projects\spt-core\docs-site/src/cli/reference.md
166 requirements: 166 complete, 0 incomplete, 0 findings
tr-exit=0


cargo run -q -p xtask -- check 2>&1 | Select-Object -Last 3; echo "exit=$LASTEXITCODE"

Full xtask drift gates


xtask check: OK
exit=0


git add -A && git commit -m @'
feat(subnet): M8 D2 — serve-state, trust lifecycle, re-pair overwrite, status honesty

Four legs (M8 decisions 6-7, 11-13, 22-23, 25; REQ-SUBNET-5..8 activated):

- Serve-state (REQ-SUBNET-5): spt subnet detach|attach <NAME> [--save].
  New spt-store AttachmentStore (identity/attachment.json, all-attached
  default, corrupt-degrades-attached); daemon boot resets live state from
  the new daemon.json detached_subnets knob (--save writes it — unsaved
  flips deliberately die with the daemon). Gated at all three serve
  points, re-read per round: peer pump subnet skip, pairing responder
  filter_serving view, meet-rotation filter. Banner gains per-subnet
  detached/attached/no-connection states.
- Trust lifecycle (REQ-SUBNET-6): spt subnet leave <NAME> (gate-first
  elevation; drops seed + subnet trust rows + serve-state + snapshot) and
  spt subnet prune <node> (gate-first; full-hex/prefix/label resolution,
  refuses ambiguous + own identity; drops the identity trust rows +
  registry snapshot rows). BIGNET 09ef831e = acceptance fixture.
- Re-pair trust overwrite (REQ-SUBNET-7): registry Instance gains the
  hashed machine_id (additive serde; advertise stamps it; new
  spt-daemon::machineid — OS id, domain-separated SHA-256, minted
  fallback; raw never on the wire). Pairing Hello gains the optional
  trailing NodeIntro (old peers tolerate; half-intro refuses);
  run_responder returns ResponderOutcome; pairhost evicts post-confirm
  via superseded_identities/repair_evict_superseded (ceremony-subnet
  trust drop + snapshot rewrite + repair-evict marker), and the pump
  consumes the marker before the snapshot mirror can resurrect rows.
  A gossiped claim alone never evicts (fires only post-confirm).
- Status honesty (REQ-SUBNET-8): daemon-aware standalone text (never
  "working" while the daemon is down), hint footer on bare `spt subnet`
  only, pump-stall warning in both status views off the heartbeat.

traceable-reqs check + xtask check green; spt-net + spt-daemon + spt
suites green; cli/reference.md regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@

Commit D2


warning: in the working copy of 'crates/spt-daemon/src/config.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/daemon.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/notif.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/pairhost.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/peerloop.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/presence.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/reconcile.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/registryhost.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/shelldisc.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/dispatch.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/peerloop.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/replicate.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/sync.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net/pairing/wire.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net/registry.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net/replicate.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net/wanmsg.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/cli.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/wansend.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs-site/src/cli/reference.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/machineid.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/attachment.rs', LF will be replaced by CRLF the next time Git touches it
[dev-freeform 2d888fa] feat(subnet): M8 D2 — serve-state, trust lifecycle, re-pair overwrite, status honesty
 25 files changed, 1434 insertions(+), 63 deletions(-)
 create mode 100644 crates/spt-daemon/src/machineid.rs
 create mode 100644 crates/spt-store/src/attachment.rs


Updated task #4 status


Updated task #5 status


Get-ChildItem installer | Select-Object Name, Length

List installer dir


Name        Length
----        ------
install.ps1   8295
install.sh    4680


1	#!/bin/sh
2	# spt installer (Linux) — served at the canonical Pages URL (ADR-0014):
3	#
4	#   curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh
5	#
6	# [impl->REQ-INSTALL-5] Non-interactive BY CONSTRUCTION: this same script is
7	# every adapter's pack-in on-demand install (no second mechanism), so it must
8	# never prompt. It downloads the latest release binary, sha256-checks it
9	# against the release's SHA256SUMS (full ed25519 verification is `spt update`'s
10	# job after first install; first-fetch trust = HTTPS + GitHub), places it under
11	# the per-OS install root, and registers the *user* PATH.
12	#
13	# Environment knobs (all optional):
14	#   SPT_INSTALL_DIR        install dir            (default: $HOME/.local/bin)
15	#   SPT_INSTALL_REPO       owner/repo             (default: SaberMage/spt-releases)
16	#   SPT_INSTALL_VERSION    release tag to pin     (default: latest)
17	#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
18	#                          directly (bypasses the release lookup; CI / air-gap)
19	#   SPT_INSTALL_NO_PATH    set to 1 to skip PATH registration
20	set -eu
21
22	REPO="${SPT_INSTALL_REPO:-SaberMage/spt-releases}"
23	INSTALL_DIR="${SPT_INSTALL_DIR:-$HOME/.local/bin}"
24
25	die() { echo "spt-install: error: $*" >&2; exit 1; }
26
27	# ---- platform / arch -> release asset name -------------------------------
28	OS="$(uname -s)"
29	[ "$OS" = "Linux" ] || die "unsupported OS '$OS' (this script installs the Linux binary; Windows: install.ps1; macOS builds are not published yet)"
30	ARCH="$(uname -m)"
31	case "$ARCH" in
32	  x86_64|amd64) ASSET="spt-x86_64-linux" ;;
33	  *) die "no published build for architecture '$ARCH' yet (x86_64 only today)" ;;
34	esac
35
36	command -v curl >/dev/null 2>&1 || die "curl is required"
37	if command -v sha256sum >/dev/null 2>&1; then
38	  SHA256() { sha256sum "$1" | awk '{print $1}'; }
39	elif command -v shasum >/dev/null 2>&1; then
40	  SHA256() { shasum -a 256 "$1" | awk '{print $1}'; }
41	else
42	  die "need sha256sum or shasum to verify the download"
43	fi
44
45	WORK="$(mktemp -d)"
46	trap 'rm -rf "$WORK"' EXIT INT TERM
47
48	# ---- fetch: binary + SHA256SUMS ------------------------------------------
49	# [impl->REQ-INSTALL-1] the standalone one-line script half: latest-release
50	# lookup -> platform asset fetch -> sha256 verify -> place -> user PATH.
51	fetch() { # $1 = file name, $2 = dest path
52	  if [ -n "${SPT_INSTALL_ASSET_BASE:-}" ]; then
53	    case "$SPT_INSTALL_ASSET_BASE" in
54	      http://*|https://*) curl -fsSL "$SPT_INSTALL_ASSET_BASE/$1" -o "$2" ;;
55	      *) cp "$SPT_INSTALL_ASSET_BASE/$1" "$2" ;;
56	    esac
57	  elif [ -n "${SPT_INSTALL_VERSION:-}" ]; then
58	    curl -fsSL "https://github.com/$REPO/releases/download/$SPT_INSTALL_VERSION/$1" -o "$2"
59	  else
60	    curl -fsSL "https://github.com/$REPO/releases/latest/download/$1" -o "$2"
61	  fi
62	}
63
64	echo "spt-install: fetching $ASSET ..."
65	fetch "$ASSET" "$WORK/$ASSET"
66	fetch "SHA256SUMS" "$WORK/SHA256SUMS"
67
68	# ---- verify ----------------------------------------------------------------
69	WANT="$(awk -v a="$ASSET" '$2 == a { print $1 }' "$WORK/SHA256SUMS")"
70	[ -n "$WANT" ] || die "SHA256SUMS has no entry for $ASSET"
71	GOT="$(SHA256 "$WORK/$ASSET")"
72	[ "$GOT" = "$WANT" ] || die "sha256 mismatch for $ASSET (expected $WANT, got $GOT) — refusing to install"
73	echo "spt-install: sha256 verified."
74
75	# ---- place -----------------------------------------------------------------
76	# [impl->REQ-INSTALL-3] idempotent re-run: the binary is replaced atomically
77	# (rename over the old one); the PATH registration below is added at most once.
78	mkdir -p "$INSTALL_DIR"
79	chmod +x "$WORK/$ASSET"
80	mv -f "$WORK/$ASSET" "$INSTALL_DIR/spt"
81
82	# ---- user PATH registration -------------------------------------------------
83	PATH_NOTE=""
84	if [ "${SPT_INSTALL_NO_PATH:-0}" != "1" ]; then
85	  case ":$PATH:" in
86	    *":$INSTALL_DIR:"*) ;; # already reachable in this shell
87	    *)
88	      PROFILE="$HOME/.profile"
89	      MARK="# added by spt installer"
90	      if [ ! -f "$PROFILE" ] || ! grep -F "$MARK" "$PROFILE" >/dev/null 2>&1; then
91	        printf '\n%s\nexport PATH="%s:$PATH"\n' "$MARK" "$INSTALL_DIR" >> "$PROFILE"
92	        PATH_NOTE="Registered $INSTALL_DIR on your PATH via ~/.profile (takes effect in new login shells)."
93	      else
94	        PATH_NOTE="$INSTALL_DIR is registered in ~/.profile but not active in this shell."
95	      fi
96	      ;;
97	  esac
98	fi
99
100	# ---- report ----------------------------------------------------------------
101	# Print the absolute path: until PATH refreshes, invoke it absolutely.
102	echo "spt-install: installed $INSTALL_DIR/spt"
103	"$INSTALL_DIR/spt" --version
104	[ -z "$PATH_NOTE" ] || {
105	  echo "spt-install: $PATH_NOTE"
106	  echo "spt-install: in this shell, run it as $INSTALL_DIR/spt (or: export PATH=\"$INSTALL_DIR:\$PATH\")"
107	}
108

#!/bin/sh
# spt installer (Linux) — served at the canonical Pages URL (ADR-0014):
#
#   curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh
#
# [impl->REQ-INSTALL-5] Non-interactive BY CONSTRUCTION: this same script is
# every adapter's pack-in on-demand install (no second mechanism), so it must
# never prompt. It downloads the latest release binary, sha256-checks it
# against the release's SHA256SUMS (full ed25519 verification is `spt update`'s
# job after first install; first-fetch trust = HTTPS + GitHub), places it under
# the per-OS install root, and registers the *user* PATH.
#
# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir            (default: $HOME/.local/bin)
#   SPT_INSTALL_REPO       owner/repo             (default: SaberMage/spt-releases)
#   SPT_INSTALL_VERSION    release tag to pin     (default: latest)
#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
#                          directly (bypasses the release lookup; CI / air-gap)
#   SPT_INSTALL_NO_PATH    set to 1 to skip PATH registration
set -eu

REPO="${SPT_INSTALL_REPO:-SaberMage/spt-releases}"
INSTALL_DIR="${SPT_INSTALL_DIR:-$HOME/.local/bin}"

die() { echo "spt-install: error: $*" >&2; exit 1; }

# ---- platform / arch -> release asset name -------------------------------
OS="$(uname -s)"
[ "$OS" = "Linux" ] || die "unsupported OS '$OS' (this script installs the Linux binary; Windows: install.ps1; macOS builds are not published yet)"
ARCH="$(uname -m)"
case "$ARCH" in
  x86_64|amd64) ASSET="spt-x86_64-linux" ;;
  *) die "no published build for architecture '$ARCH' yet (x86_64 only today)" ;;
esac

command -v curl >/dev/null 2>&1 || die "curl is required"
if command -v sha256sum >/dev/null 2>&1; then
  SHA256() { sha256sum "$1" | awk '{print $1}'; }
elif command -v shasum >/dev/null 2>&1; then
  SHA256() { shasum -a 256 "$1" | awk '{print $1}'; }
else
  die "need sha256sum or shasum to verify the download"
fi

WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT INT TERM

# ---- fetch: binary + SHA256SUMS ------------------------------------------
# [impl->REQ-INSTALL-1] the standalone one-line script half: latest-release
# lookup -> platform asset fetch -> sha256 verify -> place -> user PATH.
fetch() { # $1 = file name, $2 = dest path
  if [ -n "${SPT_INSTALL_ASSET_BASE:-}" ]; then
    case "$SPT_INSTALL_ASSET_BASE" in
      http://*|https://*) curl -fsSL "$SPT_INSTALL_ASSET_BASE/$1" -o "$2" ;;
      *) cp "$SPT_INSTALL_ASSET_BASE/$1" "$2" ;;
    esac
  elif [ -n "${SPT_INSTALL_VERSION:-}" ]; then
    curl -fsSL "https://github.com/$REPO/releases/download/$SPT_INSTALL_VERSION/$1" -o "$2"
  else
    curl -fsSL "https://github.com/$REPO/releases/latest/download/$1" -o "$2"
  fi
}

echo "spt-install: fetching $ASSET ..."
fetch "$ASSET" "$WORK/$ASSET"
fetch "SHA256SUMS" "$WORK/SHA256SUMS"

# ---- verify ----------------------------------------------------------------
WANT="$(awk -v a="$ASSET" '$2 == a { print $1 }' "$WORK/SHA256SUMS")"
[ -n "$WANT" ] || die "SHA256SUMS has no entry for $ASSET"
GOT="$(SHA256 "$WORK/$ASSET")"
[ "$GOT" = "$WANT" ] || die "sha256 mismatch for $ASSET (expected $WANT, got $GOT) — refusing to install"
echo "spt-install: sha256 verified."

# ---- place -----------------------------------------------------------------
# [impl->REQ-INSTALL-3] idempotent re-run: the binary is replaced atomically
# (rename over the old one); the PATH registration below is added at most once.
mkdir -p "$INSTALL_DIR"
chmod +x "$WORK/$ASSET"
mv -f "$WORK/$ASSET" "$INSTALL_DIR/spt"

# ---- user PATH registration -------------------------------------------------
PATH_NOTE=""
if [ "${SPT_INSTALL_NO_PATH:-0}" != "1" ]; then
  case ":$PATH:" in
    *":$INSTALL_DIR:"*) ;; # already reachable in this shell
    *)
      PROFILE="$HOME/.profile"
      MARK="# added by spt installer"
      if [ ! -f "$PROFILE" ] || ! grep -F "$MARK" "$PROFILE" >/dev/null 2>&1; then
        printf '\n%s\nexport PATH="%s:$PATH"\n' "$MARK" "$INSTALL_DIR" >> "$PROFILE"
        PATH_NOTE="Registered $INSTALL_DIR on your PATH via ~/.profile (takes effect in new login shells)."
      else
        PATH_NOTE="$INSTALL_DIR is registered in ~/.profile but not active in this shell."
      fi
      ;;
  esac
fi

# ---- report ----------------------------------------------------------------
# Print the absolute path: until PATH refreshes, invoke it absolutely.
echo "spt-install: installed $INSTALL_DIR/spt"
"$INSTALL_DIR/spt" --version
[ -z "$PATH_NOTE" ] || {
  echo "spt-install: $PATH_NOTE"
  echo "spt-install: in this shell, run it as $INSTALL_DIR/spt (or: export PATH=\"$INSTALL_DIR:\$PATH\")"
}


1	# spt installer (Windows) - served at the canonical Pages URL (ADR-0014):
2	#
3	#   irm https://sabermage.github.io/spt-releases/install.ps1 | iex
4	#
5	# [impl->REQ-INSTALL-5] Non-interactive BY CONSTRUCTION: this same script is
6	# every adapter's pack-in on-demand install (no second mechanism), so it must
7	# never prompt. It downloads the latest release binary, sha256-checks it
8	# against the release's SHA256SUMS (full ed25519 verification is `spt update`'s
9	# job after first install; first-fetch trust = HTTPS + GitHub), places it under
10	# the per-OS install root, and registers the *user* PATH.
11	#
12	# Compatible with stock Windows PowerShell 5.1 (the `irm | iex` default) and
13	# PowerShell 7+. ASCII only: PS 5.1 reads BOM-less scripts as ANSI.
14	#
15	# Environment knobs (all optional):
16	#   SPT_INSTALL_DIR        install dir          (default: %LOCALAPPDATA%\spt-core\bin)
17	#   SPT_INSTALL_REPO       owner/repo           (default: SaberMage/spt-releases)
18	#   SPT_INSTALL_VERSION    release tag to pin   (default: latest)
19	#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
20	#                          directly (bypasses the release lookup; CI / air-gap)
21	#   SPT_INSTALL_NO_PATH    set to 1 to skip PATH registration
22
23	$ErrorActionPreference = 'Stop'
24	$ProgressPreference = 'SilentlyContinue'
25	# PS 5.1 defaults to TLS 1.0; GitHub requires >= 1.2.
26	[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
27
28	# throw, never `exit`: under `irm | iex` the script body runs in the user's
29	# session, where `exit` would close their terminal. A throw aborts the install
30	# either way, and `powershell -File` still maps it to a nonzero exit code.
31	function Fail([string]$msg) {
32	    throw "spt-install: error: $msg"
33	}
34
35	$repo = if ($env:SPT_INSTALL_REPO) { $env:SPT_INSTALL_REPO } else { 'SaberMage/spt-releases' }
36	# spt-core\bin, matching the state home (%LOCALAPPDATA%\spt-core) - NOT
37	# %LOCALAPPDATA%\spt, which belongs to the legacy SPT plugin (cohabiting its
38	# home was a v0.1.0 mistake; the migration block below cleans it up).
39	$usingDefaultDir = -not $env:SPT_INSTALL_DIR
40	$installDir = if ($env:SPT_INSTALL_DIR) { $env:SPT_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'spt-core\bin' }
41
42	# ---- platform / arch -> release asset name -------------------------------
43	$arch = $env:PROCESSOR_ARCHITECTURE
44	if ($arch -ne 'AMD64') {
45	    Fail "no published build for architecture '$arch' yet (x86_64 only today)"
46	}
47	$asset = 'spt-x86_64-windows.exe'
48
49	$work = Join-Path ([IO.Path]::GetTempPath()) ("spt-install-" + [IO.Path]::GetRandomFileName())
50	New-Item -ItemType Directory -Path $work -Force | Out-Null
51
52	try {
53	    # ---- fetch: binary + SHA256SUMS ----------------------------------------
54	    # [impl->REQ-INSTALL-1] the standalone one-line script half: latest-release
55	    # lookup -> platform asset fetch -> sha256 verify -> place -> user PATH.
56	    function Fetch([string]$name, [string]$dest) {
57	        $base = $env:SPT_INSTALL_ASSET_BASE
58	        if ($base) {
59	            if ($base -match '^https?://') {
60	                Invoke-WebRequest -Uri "$base/$name" -OutFile $dest -UseBasicParsing
61	            } else {
62	                Copy-Item -Path (Join-Path $base $name) -Destination $dest
63	            }
64	        } elseif ($env:SPT_INSTALL_VERSION) {
65	            Invoke-WebRequest -Uri "https://github.com/$repo/releases/download/$($env:SPT_INSTALL_VERSION)/$name" -OutFile $dest -UseBasicParsing
66	        } else {
67	            Invoke-WebRequest -Uri "https://github.com/$repo/releases/latest/download/$name" -OutFile $dest -UseBasicParsing
68	        }
69	    }
70
71	    Write-Output "spt-install: fetching $asset ..."
72	    $binPath = Join-Path $work $asset
73	    $sumsPath = Join-Path $work 'SHA256SUMS'
74	    Fetch $asset $binPath
75	    Fetch 'SHA256SUMS' $sumsPath
76
77	    # ---- verify -------------------------------------------------------------
78	    $want = $null
79	    foreach ($line in (Get-Content $sumsPath)) {
80	        if ($line -match '^([0-9a-fA-F]{64})\s+\*?(.+)$' -and $Matches[2].Trim() -eq $asset) {
81	            $want = $Matches[1].ToLowerInvariant()
82	        }
83	    }
84	    if (-not $want) { Fail "SHA256SUMS has no entry for $asset" }
85	    # .NET directly, not Get-FileHash: cmdlet autoload breaks when a PS7
86	    # parent's PSModulePath leaks into a 5.1 child (and on hardened boxes).
87	    $sha = [Security.Cryptography.SHA256]::Create()
88	    $stream = [IO.File]::OpenRead($binPath)
89	    try { $hashBytes = $sha.ComputeHash($stream) } finally { $stream.Dispose() }
90	    $got = ([BitConverter]::ToString($hashBytes) -replace '-', '').ToLowerInvariant()
91	    if ($got -ne $want) {
92	        Fail "sha256 mismatch for $asset (expected $want, got $got) - refusing to install"
93	    }
94	    Write-Output "spt-install: sha256 verified."
95
96	    # ---- place --------------------------------------------------------------
97	    # [impl->REQ-INSTALL-3] idempotent re-run: the binary is replaced in place;
98	    # the PATH registration below is added at most once.
99	    New-Item -ItemType Directory -Path $installDir -Force | Out-Null
100	    $target = Join-Path $installDir 'spt.exe'
101	    try {
102	        Move-Item -Path $binPath -Destination $target -Force
103	    } catch {
104	        Fail "could not replace $target (is spt running? stop it and re-run): $_"
105	    }
106
107	    # ---- migrate the v0.1.0 install dir ---------------------------------------
108	    # [impl->REQ-INSTALL-3] v0.1.0 installed into %LOCALAPPDATA%\spt\bin -
109	    # inside the LEGACY SPT plugin's state home. Re-running this installer
110	    # relocates cleanly: remove exactly the old binary + its bin dir (never
111	    # the parent - it holds the legacy plugin's live state) and drop the old
112	    # dir from the user PATH. Only fires on the default-dir path; an explicit
113	    # SPT_INSTALL_DIR opts out of any migration opinion.
114	    if ($usingDefaultDir) {
115	        $oldDir = Join-Path $env:LOCALAPPDATA 'spt\bin'
116	        $oldExe = Join-Path $oldDir 'spt.exe'
117	        if (Test-Path $oldExe) {
118	            try {
119	                Remove-Item -Path $oldExe -Force
120	                if (-not (Get-ChildItem -Path $oldDir -Force -ErrorAction SilentlyContinue)) {
121	                    Remove-Item -Path $oldDir -Force
122	                }
123	                Write-Output "spt-install: migrated: removed v0.1.0 binary at $oldExe"
124	            } catch {
125	                Write-Output "spt-install: warning: could not remove old install at $oldExe (is spt running?); remove it manually"
126	            }
127	        }
128	        $userPathNow = [Environment]::GetEnvironmentVariable('Path', 'User')
129	        if ($userPathNow) {
130	            $oldNorm = $oldDir.TrimEnd('\')
131	            $kept = $userPathNow -split ';' | Where-Object { $_ -and ($_.Trim().TrimEnd('\') -ne $oldNorm) }
132	            $cleaned = $kept -join ';'
133	            if ($cleaned -ne $userPathNow) {
134	                [Environment]::SetEnvironmentVariable('Path', $cleaned, 'User')
135	                Write-Output "spt-install: migrated: removed $oldDir from your user PATH"
136	            }
137	        }
138	    }
139
140	    # ---- user PATH registration ----------------------------------------------
141	    $pathNote = $null
142	    if ($env:SPT_INSTALL_NO_PATH -ne '1') {
143	        $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
144	        if (-not $userPath) { $userPath = '' }
145	        $entries = $userPath -split ';' | ForEach-Object { $_.Trim().TrimEnd('\') } | Where-Object { $_ }
146	        if ($entries -notcontains $installDir.TrimEnd('\')) {
147	            $newPath = if ($userPath -and -not $userPath.EndsWith(';')) { "$userPath;$installDir" } else { "$userPath$installDir" }
148	            [Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
149	            $pathNote = "Registered $installDir on your user PATH (takes effect in NEW terminals only)."
150	        }
151	    }
152
153	    # ---- report ---------------------------------------------------------------
154	    # Print the absolute path: the PATH change does not reach this terminal, so
155	    # the first invocation here must use it absolutely.
156	    Write-Output "spt-install: installed $target"
157	    & $target --version
158	    if ($pathNote) {
159	        Write-Output "spt-install: $pathNote"
160	        Write-Output "spt-install: in THIS terminal, invoke it as: $target"
161	    }
162	} finally {
163	    Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue
164	}
165

# spt installer (Windows) - served at the canonical Pages URL (ADR-0014):
#
#   irm https://sabermage.github.io/spt-releases/install.ps1 | iex
#
# [impl->REQ-INSTALL-5] Non-interactive BY CONSTRUCTION: this same script is
# every adapter's pack-in on-demand install (no second mechanism), so it must
# never prompt. It downloads the latest release binary, sha256-checks it
# against the release's SHA256SUMS (full ed25519 verification is `spt update`'s
# job after first install; first-fetch trust = HTTPS + GitHub), places it under
# the per-OS install root, and registers the *user* PATH.
#
# Compatible with stock Windows PowerShell 5.1 (the `irm | iex` default) and
# PowerShell 7+. ASCII only: PS 5.1 reads BOM-less scripts as ANSI.
#
# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir          (default: %LOCALAPPDATA%\spt-core\bin)
#   SPT_INSTALL_REPO       owner/repo           (default: SaberMage/spt-releases)
#   SPT_INSTALL_VERSION    release tag to pin   (default: latest)
#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
#                          directly (bypasses the release lookup; CI / air-gap)
#   SPT_INSTALL_NO_PATH    set to 1 to skip PATH registration

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
# PS 5.1 defaults to TLS 1.0; GitHub requires >= 1.2.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12

# throw, never `exit`: under `irm | iex` the script body runs in the user's
# session, where `exit` would close their terminal. A throw aborts the install
# either way, and `powershell -File` still maps it to a nonzero exit code.
function Fail([string]$msg) {
    throw "spt-install: error: $msg"
}

$repo = if ($env:SPT_INSTALL_REPO) { $env:SPT_INSTALL_REPO } else { 'SaberMage/spt-releases' }
# spt-core\bin, matching the state home (%LOCALAPPDATA%\spt-core) - NOT
# %LOCALAPPDATA%\spt, which belongs to the legacy SPT plugin (cohabiting its
# home was a v0.1.0 mistake; the migration block below cleans it up).
$usingDefaultDir = -not $env:SPT_INSTALL_DIR
$installDir = if ($env:SPT_INSTALL_DIR) { $env:SPT_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'spt-core\bin' }

# ---- platform / arch -> release asset name -------------------------------
$arch = $env:PROCESSOR_ARCHITECTURE
if ($arch -ne 'AMD64') {
    Fail "no published build for architecture '$arch' yet (x86_64 only today)"
}
$asset = 'spt-x86_64-windows.exe'

$work = Join-Path ([IO.Path]::GetTempPath()) ("spt-install-" + [IO.Path]::GetRandomFileName())
New-Item -ItemType Directory -Path $work -Force | Out-Null

try {
    # ---- fetch: binary + SHA256SUMS ----------------------------------------
    # [impl->REQ-INSTALL-1] the standalone one-line script half: latest-release
    # lookup -> platform asset fetch -> sha256 verify -> place -> user PATH.
    function Fetch([string]$name, [string]$dest) {
        $base = $env:SPT_INSTALL_ASSET_BASE
        if ($base) {
            if ($base -match '^https?://') {
                Invoke-WebRequest -Uri "$base/$name" -OutFile $dest -UseBasicParsing
            } else {
                Copy-Item -Path (Join-Path $base $name) -Destination $dest
            }
        } elseif ($env:SPT_INSTALL_VERSION) {
            Invoke-WebRequest -Uri "https://github.com/$repo/releases/download/$($env:SPT_INSTALL_VERSION)/$name" -OutFile $dest -UseBasicParsing
        } else {
            Invoke-WebRequest -Uri "https://github.com/$repo/releases/latest/download/$name" -OutFile $dest -UseBasicParsing
        }
    }

    Write-Output "spt-install: fetching $asset ..."
    $binPath = Join-Path $work $asset
    $sumsPath = Join-Path $work 'SHA256SUMS'
    Fetch $asset $binPath
    Fetch 'SHA256SUMS' $sumsPath

    # ---- verify -------------------------------------------------------------
    $want = $null
    foreach ($line in (Get-Content $sumsPath)) {
        if ($line -match '^([0-9a-fA-F]{64})\s+\*?(.+)$' -and $Matches[2].Trim() -eq $asset) {
            $want = $Matches[1].ToLowerInvariant()
        }
    }
    if (-not $want) { Fail "SHA256SUMS has no entry for $asset" }
    # .NET directly, not Get-FileHash: cmdlet autoload breaks when a PS7
    # parent's PSModulePath leaks into a 5.1 child (and on hardened boxes).
    $sha = [Security.Cryptography.SHA256]::Create()
    $stream = [IO.File]::OpenRead($binPath)
    try { $hashBytes = $sha.ComputeHash($stream) } finally { $stream.Dispose() }
    $got = ([BitConverter]::ToString($hashBytes) -replace '-', '').ToLowerInvariant()
    if ($got -ne $want) {
        Fail "sha256 mismatch for $asset (expected $want, got $got) - refusing to install"
    }
    Write-Output "spt-install: sha256 verified."

    # ---- place --------------------------------------------------------------
    # [impl->REQ-INSTALL-3] idempotent re-run: the binary is replaced in place;
    # the PATH registration below is added at most once.
    New-Item -ItemType Directory -Path $installDir -Force | Out-Null
    $target = Join-Path $installDir 'spt.exe'
    try {
        Move-Item -Path $binPath -Destination $target -Force
    } catch {
        Fail "could not replace $target (is spt running? stop it and re-run): $_"
    }

    # ---- migrate the v0.1.0 install dir ---------------------------------------
    # [impl->REQ-INSTALL-3] v0.1.0 installed into %LOCALAPPDATA%\spt\bin -
    # inside the LEGACY SPT plugin's state home. Re-running this installer
    # relocates cleanly: remove exactly the old binary + its bin dir (never
    # the parent - it holds the legacy plugin's live state) and drop the old
    # dir from the user PATH. Only fires on the default-dir path; an explicit
    # SPT_INSTALL_DIR opts out of any migration opinion.
    if ($usingDefaultDir) {
        $oldDir = Join-Path $env:LOCALAPPDATA 'spt\bin'
        $oldExe = Join-Path $oldDir 'spt.exe'
        if (Test-Path $oldExe) {
            try {
                Remove-Item -Path $oldExe -Force
                if (-not (Get-ChildItem -Path $oldDir -Force -ErrorAction SilentlyContinue)) {
                    Remove-Item -Path $oldDir -Force
                }
                Write-Output "spt-install: migrated: removed v0.1.0 binary at $oldExe"
            } catch {
                Write-Output "spt-install: warning: could not remove old install at $oldExe (is spt running?); remove it manually"
            }
        }
        $userPathNow = [Environment]::GetEnvironmentVariable('Path', 'User')
        if ($userPathNow) {
            $oldNorm = $oldDir.TrimEnd('\')
            $kept = $userPathNow -split ';' | Where-Object { $_ -and ($_.Trim().TrimEnd('\') -ne $oldNorm) }
            $cleaned = $kept -join ';'
            if ($cleaned -ne $userPathNow) {
                [Environment]::SetEnvironmentVariable('Path', $cleaned, 'User')
                Write-Output "spt-install: migrated: removed $oldDir from your user PATH"
            }
        }
    }

    # ---- user PATH registration ----------------------------------------------
    $pathNote = $null
    if ($env:SPT_INSTALL_NO_PATH -ne '1') {
        $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
        if (-not $userPath) { $userPath = '' }
        $entries = $userPath -split ';' | ForEach-Object { $_.Trim().TrimEnd('\') } | Where-Object { $_ }
        if ($entries -notcontains $installDir.TrimEnd('\')) {
            $newPath = if ($userPath -and -not $userPath.EndsWith(';')) { "$userPath;$installDir" } else { "$userPath$installDir" }
            [Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
            $pathNote = "Registered $installDir on your user PATH (takes effect in NEW terminals only)."
        }
    }

    # ---- report ---------------------------------------------------------------
    # Print the absolute path: the PATH change does not reach this terminal, so
    # the first invocation here must use it absolutely.
    Write-Output "spt-install: installed $target"
    & $target --version
    if ($pathNote) {
        Write-Output "spt-install: $pathNote"
        Write-Output "spt-install: in THIS terminal, invoke it as: $target"
    }
} finally {
    Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue
}


Edit

# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir            (default: $HOME/.local/bin)
#   SPT_INSTALL_REPO       owner/repo             (default: SaberMage/spt-releases)
#   SPT_INSTALL_VERSION    release tag to pin     (default: latest)
#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
#                          directly (bypasses the release lookup; CI / air-gap)
#   SPT_INSTALL_NO_PATH    set to 1 to skip PATH registration
set -eu

# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir            (default: $HOME/.local/bin)
#   SPT_INSTALL_REPO       owner/repo             (default: SaberMage/spt-releases)
#   SPT_INSTALL_VERSION    release tag to pin     (default: latest)
#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
#                          directly (bypasses the release lookup; CI / air-gap)
#   SPT_INSTALL_NO_PATH    set to 1 to skip PATH registration
#   SPT_INSTALL_NO_SYMLINK set to 1 to skip the sudo-reachable symlink leg
#   SPT_INSTALL_NO_SERVICE set to 1 to skip the systemd user-service leg
#                          (CI keeps the E2E hermetic with these two)
set -eu


The file C:\Users\decid\Documents\projects\spt-core\installer\install.sh has been updated successfully. (file state is current in your context — no need to Read it back)

# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir            (default: $HOME/.local/bin)
#   SPT_INSTALL_REPO       owner/repo             (default: SaberMage/spt-releases)
#   SPT_INSTALL_VERSION    release tag to pin     (default: latest)
#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
#                          directly (bypasses the release lookup; CI / air-gap)
#   SPT_INSTALL_NO_PATH    set to 1 to skip PATH registration
#   SPT_INSTALL_NO_SYMLINK set to 1 to skip the sudo-reachable symlink leg
#   SPT_INSTALL_NO_SERVICE set to 1 to skip the systemd user-service leg
#                          (CI keeps the E2E hermetic with these two)
set -eu

#!/bin/sh
# spt installer (Linux) — served at the canonical Pages URL (ADR-0014):
#
#   curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh
#
# [impl->REQ-INSTALL-5] Non-interactive BY CONSTRUCTION: this same script is
# every adapter's pack-in on-demand install (no second mechanism), so it must
# never prompt. It downloads the latest release binary, sha256-checks it
# against the release's SHA256SUMS (full ed25519 verification is `spt update`'s
# job after first install; first-fetch trust = HTTPS + GitHub), places it under
# the per-OS install root, and registers the *user* PATH.
#
# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir            (default: $HOME/.local/bin)
#   SPT_INSTALL_REPO       owner/repo             (default: SaberMage/spt-releases)
#   SPT_INSTALL_VERSION    release tag to pin     (default: latest)
#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
#                          directly (bypasses the release lookup; CI / air-gap)
#   SPT_INSTALL_NO_PATH    set to 1 to skip PATH registration
set -eu

REPO="${SPT_INSTALL_REPO:-SaberMage/spt-releases}"
INSTALL_DIR="${SPT_INSTALL_DIR:-$HOME/.local/bin}"

die() { echo "spt-install: error: $*" >&2; exit 1; }

# ---- platform / arch -> release asset name -------------------------------
OS="$(uname -s)"
[ "$OS" = "Linux" ] || die "unsupported OS '$OS' (this script installs the Linux binary; Windows: install.ps1; macOS builds are not published yet)"
ARCH="$(uname -m)"
case "$ARCH" in
  x86_64|amd64) ASSET="spt-x86_64-linux" ;;
  *) die "no published build for architecture '$ARCH' yet (x86_64 only today)" ;;
esac

command -v curl >/dev/null 2>&1 || die "curl is required"
if command -v sha256sum >/dev/null 2>&1; then
  SHA256() { sha256sum "$1" | awk '{print $1}'; }
elif command -v shasum >/dev/null 2>&1; then
  SHA256() { shasum -a 256 "$1" | awk '{print $1}'; }
else
  die "need sha256sum or shasum to verify the download"
fi

WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT INT TERM

# ---- fetch: binary + SHA256SUMS ------------------------------------------
# [impl->REQ-INSTALL-1] the standalone one-line script half: latest-release
# lookup -> platform asset fetch -> sha256 verify -> place -> user PATH.
fetch() { # $1 = file name, $2 = dest path
  if [ -n "${SPT_INSTALL_ASSET_BASE:-}" ]; then
    case "$SPT_INSTALL_ASSET_BASE" in
      http://*|https://*) curl -fsSL "$SPT_INSTALL_ASSET_BASE/$1" -o "$2" ;;
      *) cp "$SPT_INSTALL_ASSET_BASE/$1" "$2" ;;
    esac
  elif [ -n "${SPT_INSTALL_VERSION:-}" ]; then
    curl -fsSL "https://github.com/$REPO/releases/download/$SPT_INSTALL_VERSION/$1" -o "$2"
  else
    curl -fsSL "https://github.com/$REPO/releases/latest/download/$1" -o "$2"
  fi
}

echo "spt-install: fetching $ASSET ..."
fetch "$ASSET" "$WORK/$ASSET"
fetch "SHA256SUMS" "$WORK/SHA256SUMS"

# ---- verify ----------------------------------------------------------------
WANT="$(awk -v a="$ASSET" '$2 == a { print $1 }' "$WORK/SHA256SUMS")"
[ -n "$WANT" ] || die "SHA256SUMS has no entry for $ASSET"
GOT="$(SHA256 "$WORK/$ASSET")"
[ "$GOT" = "$WANT" ] || die "sha256 mismatch for $ASSET (expected $WANT, got $GOT) — refusing to install"
echo "spt-install: sha256 verified."

# ---- place -----------------------------------------------------------------
# [impl->REQ-INSTALL-3] idempotent re-run: the binary is replaced atomically
# (rename over the old one); the PATH registration below is added at most once.
mkdir -p "$INSTALL_DIR"
chmod +x "$WORK/$ASSET"
mv -f "$WORK/$ASSET" "$INSTALL_DIR/spt"

# ---- user PATH registration -------------------------------------------------
PATH_NOTE=""
if [ "${SPT_INSTALL_NO_PATH:-0}" != "1" ]; then
  case ":$PATH:" in
    *":$INSTALL_DIR:"*) ;; # already reachable in this shell
    *)
      PROFILE="$HOME/.profile"
      MARK="# added by spt installer"
      if [ ! -f "$PROFILE" ] || ! grep -F "$MARK" "$PROFILE" >/dev/null 2>&1; then
        printf '\n%s\nexport PATH="%s:$PATH"\n' "$MARK" "$INSTALL_DIR" >> "$PROFILE"
        PATH_NOTE="Registered $INSTALL_DIR on your PATH via ~/.profile (takes effect in new login shells)."
      else
        PATH_NOTE="$INSTALL_DIR is registered in ~/.profile but not active in this shell."
      fi
      ;;
  esac
fi

# ---- report ----------------------------------------------------------------
# Print the absolute path: until PATH refreshes, invoke it absolutely.
echo "spt-install: installed $INSTALL_DIR/spt"
"$INSTALL_DIR/spt" --version
[ -z "$PATH_NOTE" ] || {
  echo "spt-install: $PATH_NOTE"
  echo "spt-install: in this shell, run it as $INSTALL_DIR/spt (or: export PATH=\"$INSTALL_DIR:\$PATH\")"
}

+#   SPT_INSTALL_NO_SYMLINK set to 1 to skip the sudo-reachable symlink leg

+#   SPT_INSTALL_NO_SERVICE set to 1 to skip the systemd user-service leg

+#                          (CI keeps the E2E hermetic with these two)


# ---- report ----------------------------------------------------------------
# Print the absolute path: until PATH refreshes, invoke it absolutely.
echo "spt-install: installed $INSTALL_DIR/spt"
"$INSTALL_DIR/spt" --version
[ -z "$PATH_NOTE" ] || {
  echo "spt-install: $PATH_NOTE"
  echo "spt-install: in this shell, run it as $INSTALL_DIR/spt (or: export PATH=\"$INSTALL_DIR:\$PATH\")"
}

# ---- sudo-reachable symlink (M8 decision 8, REQ-INSTALL-6) -------------------
# [impl->REQ-INSTALL-6] a user-scope install is invisible to sudo's
# secure_path, so bare `sudo spt` dies "command not found" (KNOWN-HAZARDS
# 5.10). Symlink the binary into /usr/local/bin when we can write there
# (an elevated install, or a permissive box); otherwise print the exact
# one-liner instead of prompting (non-interactive by construction).
SYMLINK_NOTE=""
if [ "${SPT_INSTALL_NO_SYMLINK:-0}" != "1" ]; then
  LINK_DIR="/usr/local/bin"
  if ln -sf "$INSTALL_DIR/spt" "$LINK_DIR/spt" 2>/dev/null; then
    SYMLINK_NOTE="Linked $LINK_DIR/spt -> $INSTALL_DIR/spt (sudo spt now resolves)."
  else
    SYMLINK_NOTE="Could not write $LINK_DIR (unelevated install). To make \`sudo spt\` resolve, run:
spt-install:   sudo ln -sf \"$INSTALL_DIR/spt\" $LINK_DIR/spt"
  fi
fi

# ---- systemd user service (M8 decision 17, REQ-INSTALL-8) ---------------------
# [impl->REQ-INSTALL-8] reboot-survivable daemon: a systemd USER unit (the
# daemon + its state stay in the user's universe — KNOWN-HAZARDS 5.7) plus
# enable-linger so it starts at boot, before any login. Linger needs root
# once; when this run is unelevated the exact command prints instead.
# Soft everywhere: a box without systemd (or without a user session bus)
# gets hints, never a failed install.
SERVICE_NOTE=""
if [ "${SPT_INSTALL_NO_SERVICE:-0}" != "1" ] && command -v systemctl >/dev/null 2>&1; then
  UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
  mkdir -p "$UNIT_DIR"
  cat > "$UNIT_DIR/spt-daemon.service" <<UNIT
[Unit]
Description=spt-core per-machine daemon

[Service]
ExecStart=$INSTALL_DIR/spt daemon run
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target
UNIT
  if systemctl --user daemon-reload 2>/dev/null \
     && systemctl --user enable --now spt-daemon.service 2>/dev/null; then
    SERVICE_NOTE="Registered + started the spt-daemon systemd user service."
  else
    SERVICE_NOTE="Wrote $UNIT_DIR/spt-daemon.service; could not enable it from here (no user session bus?). Enable with:
spt-install:   systemctl --user daemon-reload && systemctl --user enable --now spt-daemon"
  fi
  # Linger = start at boot without a login. Root-only; hint otherwise.
  LINGER_USER="${SUDO_USER:-$(id -un)}"
  if [ "$(id -u)" = "0" ] && command -v loginctl >/dev/null 2>&1; then
    if loginctl enable-linger "$LINGER_USER" 2>/dev/null; then
      SERVICE_NOTE="$SERVICE_NOTE
spt-install: Enabled linger for $LINGER_USER (daemon starts at boot, pre-login)."
    fi
  else
    SERVICE_NOTE="$SERVICE_NOTE
spt-install: For a pre-login boot start, run once: sudo loginctl enable-linger $LINGER_USER"
  fi
fi

# ---- report ----------------------------------------------------------------
# Print the absolute path: until PATH refreshes, invoke it absolutely.
echo "spt-install: installed $INSTALL_DIR/spt"
"$INSTALL_DIR/spt" --version
[ -z "$PATH_NOTE" ] || {
  echo "spt-install: $PATH_NOTE"
  echo "spt-install: in this shell, run it as $INSTALL_DIR/spt (or: export PATH=\"$INSTALL_DIR:\$PATH\")"
}
[ -z "$SYMLINK_NOTE" ] || echo "spt-install: $SYMLINK_NOTE"
[ -z "$SERVICE_NOTE" ] || echo "spt-install: $SERVICE_NOTE"


# ---- sudo-reachable symlink (M8 decision 8, REQ-INSTALL-6) -------------------
# [impl->REQ-INSTALL-6] a user-scope install is invisible to sudo's
# secure_path, so bare `sudo spt` dies "command not found" (KNOWN-HAZARDS
# 5.10). Symlink the binary into /usr/local/bin when we can write there
# (an elevated install, or a permissive box); otherwise print the exact
# one-liner instead of prompting (non-interactive by construction).
SYMLINK_NOTE=""
if [ "${SPT_INSTALL_NO_SYMLINK:-0}" != "1" ]; then
  LINK_DIR="/usr/local/bin"
  if ln -sf "$INSTALL_DIR/spt" "$LINK_DIR/spt" 2>/dev/null; then
    SYMLINK_NOTE="Linked $LINK_DIR/spt -> $INSTALL_DIR/spt (sudo spt now resolves)."
  else
    SYMLINK_NOTE="Could not write $LINK_DIR (unelevated install). To make \`sudo spt\` resolve, run:
spt-install:   sudo ln -sf \"$INSTALL_DIR/spt\" $LINK_DIR/spt"
  fi
fi

# ---- systemd user service (M8 decision 17, REQ-INSTALL-8) ---------------------
# [impl->REQ-INSTALL-8] reboot-survivable daemon: a systemd USER unit (the
# daemon + its state stay in the user's universe — KNOWN-HAZARDS 5.7) plus
# enable-linger so it starts at boot, before any login. Linger needs root
# once; when this run is unelevated the exact command prints instead.
# Soft everywhere: a box without systemd (or without a user session bus)
# gets hints, never a failed install.
SERVICE_NOTE=""
if [ "${SPT_INSTALL_NO_SERVICE:-0}" != "1" ] && command -v systemctl >/dev/null 2>&1; then
  UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
  mkdir -p "$UNIT_DIR"
  cat > "$UNIT_DIR/spt-daemon.service" <<UNIT
[Unit]
Description=spt-core per-machine daemon

[Service]
ExecStart=$INSTALL_DIR/spt daemon run
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target
UNIT
  if systemctl --user daemon-reload 2>/dev/null \
     && systemctl --user enable --now spt-daemon.service 2>/dev/null; then
    SERVICE_NOTE="Registered + started the spt-daemon systemd user service."
  else
    SERVICE_NOTE="Wrote $UNIT_DIR/spt-daemon.service; could not enable it from here (no user session bus?). Enable with:
spt-install:   systemctl --user daemon-reload && systemctl --user enable --now spt-daemon"
  fi
  # Linger = start at boot without a login. Root-only; hint otherwise.
  LINGER_USER="${SUDO_USER:-$(id -un)}"
  if [ "$(id -u)" = "0" ] && command -v loginctl >/dev/null 2>&1; then
    if loginctl enable-linger "$LINGER_USER" 2>/dev/null; then
      SERVICE_NOTE="$SERVICE_NOTE
spt-install: Enabled linger for $LINGER_USER (daemon starts at boot, pre-login)."
    fi
  else
    SERVICE_NOTE="$SERVICE_NOTE
spt-install: For a pre-login boot start, run once: sudo loginctl enable-linger $LINGER_USER"
  fi
fi

# ---- report ----------------------------------------------------------------
# Print the absolute path: until PATH refreshes, invoke it absolutely.
echo "spt-install: installed $INSTALL_DIR/spt"
"$INSTALL_DIR/spt" --version
[ -z "$PATH_NOTE" ] || {
  echo "spt-install: $PATH_NOTE"
  echo "spt-install: in this shell, run it as $INSTALL_DIR/spt (or: export PATH=\"$INSTALL_DIR:\$PATH\")"
}
[ -z "$SYMLINK_NOTE" ] || echo "spt-install: $SYMLINK_NOTE"
[ -z "$SERVICE_NOTE" ] || echo "spt-install: $SERVICE_NOTE"

#!/bin/sh
# spt installer (Linux) — served at the canonical Pages URL (ADR-0014):
#
#   curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh
#
# [impl->REQ-INSTALL-5] Non-interactive BY CONSTRUCTION: this same script is
# every adapter's pack-in on-demand install (no second mechanism), so it must
# never prompt. It downloads the latest release binary, sha256-checks it
# against the release's SHA256SUMS (full ed25519 verification is `spt update`'s
# job after first install; first-fetch trust = HTTPS + GitHub), places it under
# the per-OS install root, and registers the *user* PATH.
#
# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir            (default: $HOME/.local/bin)
#   SPT_INSTALL_REPO       owner/repo             (default: SaberMage/spt-releases)
#   SPT_INSTALL_VERSION    release tag to pin     (default: latest)
#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
#                          directly (bypasses the release lookup; CI / air-gap)
#   SPT_INSTALL_NO_PATH    set to 1 to skip PATH registration
#   SPT_INSTALL_NO_SYMLINK set to 1 to skip the sudo-reachable symlink leg
#   SPT_INSTALL_NO_SERVICE set to 1 to skip the systemd user-service leg
#                          (CI keeps the E2E hermetic with these two)
set -eu

REPO="${SPT_INSTALL_REPO:-SaberMage/spt-releases}"
INSTALL_DIR="${SPT_INSTALL_DIR:-$HOME/.local/bin}"

die() { echo "spt-install: error: $*" >&2; exit 1; }

# ---- platform / arch -> release asset name -------------------------------
OS="$(uname -s)"
[ "$OS" = "Linux" ] || die "unsupported OS '$OS' (this script installs the Linux binary; Windows: install.ps1; macOS builds are not published yet)"
ARCH="$(uname -m)"
case "$ARCH" in
  x86_64|amd64) ASSET="spt-x86_64-linux" ;;
  *) die "no published build for architecture '$ARCH' yet (x86_64 only today)" ;;
esac

command -v curl >/dev/null 2>&1 || die "curl is required"
if command -v sha256sum >/dev/null 2>&1; then
  SHA256() { sha256sum "$1" | awk '{print $1}'; }
elif command -v shasum >/dev/null 2>&1; then
  SHA256() { shasum -a 256 "$1" | awk '{print $1}'; }
else
  die "need sha256sum or shasum to verify the download"
fi

WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT INT TERM

# ---- fetch: binary + SHA256SUMS ------------------------------------------
# [impl->REQ-INSTALL-1] the standalone one-line script half: latest-release
# lookup -> platform asset fetch -> sha256 verify -> place -> user PATH.
fetch() { # $1 = file name, $2 = dest path
  if [ -n "${SPT_INSTALL_ASSET_BASE:-}" ]; then
    case "$SPT_INSTALL_ASSET_BASE" in
      http://*|https://*) curl -fsSL "$SPT_INSTALL_ASSET_BASE/$1" -o "$2" ;;
      *) cp "$SPT_INSTALL_ASSET_BASE/$1" "$2" ;;
    esac
  elif [ -n "${SPT_INSTALL_VERSION:-}" ]; then
    curl -fsSL "https://github.com/$REPO/releases/download/$SPT_INSTALL_VERSION/$1" -o "$2"
  else
    curl -fsSL "https://github.com/$REPO/releases/latest/download/$1" -o "$2"
  fi
}

echo "spt-install: fetching $ASSET ..."
fetch "$ASSET" "$WORK/$ASSET"
fetch "SHA256SUMS" "$WORK/SHA256SUMS"

# ---- verify ----------------------------------------------------------------
WANT="$(awk -v a="$ASSET" '$2 == a { print $1 }' "$WORK/SHA256SUMS")"
[ -n "$WANT" ] || die "SHA256SUMS has no entry for $ASSET"
GOT="$(SHA256 "$WORK/$ASSET")"
[ "$GOT" = "$WANT" ] || die "sha256 mismatch for $ASSET (expected $WANT, got $GOT) — refusing to install"
echo "spt-install: sha256 verified."

# ---- place -----------------------------------------------------------------
# [impl->REQ-INSTALL-3] idempotent re-run: the binary is replaced atomically
# (rename over the old one); the PATH registration below is added at most once.
mkdir -p "$INSTALL_DIR"
chmod +x "$WORK/$ASSET"
mv -f "$WORK/$ASSET" "$INSTALL_DIR/spt"

# ---- user PATH registration -------------------------------------------------
PATH_NOTE=""
if [ "${SPT_INSTALL_NO_PATH:-0}" != "1" ]; then
  case ":$PATH:" in
    *":$INSTALL_DIR:"*) ;; # already reachable in this shell
    *)
      PROFILE="$HOME/.profile"
      MARK="# added by spt installer"
      if [ ! -f "$PROFILE" ] || ! grep -F "$MARK" "$PROFILE" >/dev/null 2>&1; then
        printf '\n%s\nexport PATH="%s:$PATH"\n' "$MARK" "$INSTALL_DIR" >> "$PROFILE"
        PATH_NOTE="Registered $INSTALL_DIR on your PATH via ~/.profile (takes effect in new login shells)."
      else
        PATH_NOTE="$INSTALL_DIR is registered in ~/.profile but not active in this shell."
      fi
      ;;
  esac
fi

# ---- report ----------------------------------------------------------------
# Print the absolute path: until PATH refreshes, invoke it absolutely.
echo "spt-install: installed $INSTALL_DIR/spt"
"$INSTALL_DIR/spt" --version
[ -z "$PATH_NOTE" ] || {
  echo "spt-install: $PATH_NOTE"
  echo "spt-install: in this shell, run it as $INSTALL_DIR/spt (or: export PATH=\"$INSTALL_DIR:\$PATH\")"
}

+# ---- sudo-reachable symlink (M8 decision 8, REQ-INSTALL-6) -------------------

+# [impl->REQ-INSTALL-6] a user-scope install is invisible to sudo's

+# secure_path, so bare `sudo spt` dies "command not found" (KNOWN-HAZARDS

+# 5.10). Symlink the binary into /usr/local/bin when we can write there

+# (an elevated install, or a permissive box); otherwise print the exact

+# one-liner instead of prompting (non-interactive by construction).

+if [ "${SPT_INSTALL_NO_SYMLINK:-0}" != "1" ]; then

+  if ln -sf "$INSTALL_DIR/spt" "$LINK_DIR/spt" 2>/dev/null; then

+    SYMLINK_NOTE="Linked $LINK_DIR/spt -> $INSTALL_DIR/spt (sudo spt now resolves)."

+    SYMLINK_NOTE="Could not write $LINK_DIR (unelevated install). To make \`sudo spt\` resolve, run:

+spt-install:   sudo ln -sf \"$INSTALL_DIR/spt\" $LINK_DIR/spt"

+# ---- systemd user service (M8 decision 17, REQ-INSTALL-8) ---------------------

+# [impl->REQ-INSTALL-8] reboot-survivable daemon: a systemd USER unit (the

+# daemon + its state stay in the user's universe — KNOWN-HAZARDS 5.7) plus

+# enable-linger so it starts at boot, before any login. Linger needs root

+# once; when this run is unelevated the exact command prints instead.

+# Soft everywhere: a box without systemd (or without a user session bus)

+if [ "${SPT_INSTALL_NO_SERVICE:-0}" != "1" ] && command -v systemctl >/dev/null 2>&1; then

+  cat > "$UNIT_DIR/spt-daemon.service" <<UNIT

+  if systemctl --user daemon-reload 2>/dev/null \

+     && systemctl --user enable --now spt-daemon.service 2>/dev/null; then

+    SERVICE_NOTE="Registered + started the spt-daemon systemd user service."

+    SERVICE_NOTE="Wrote $UNIT_DIR/spt-daemon.service; could not enable it from here (no user session bus?). Enable with:

+spt-install:   systemctl --user daemon-reload && systemctl --user enable --now spt-daemon"

+  # Linger = start at boot without a login. Root-only; hint otherwise.

+  LINGER_USER="${SUDO_USER:-$(id -un)}"

+  if [ "$(id -u)" = "0" ] && command -v loginctl >/dev/null 2>&1; then

+    if loginctl enable-linger "$LINGER_USER" 2>/dev/null; then

+spt-install: Enabled linger for $LINGER_USER (daemon starts at boot, pre-login)."

+spt-install: For a pre-login boot start, run once: sudo loginctl enable-linger $LINGER_USER"

+[ -z "$SYMLINK_NOTE" ] || echo "spt-install: $SYMLINK_NOTE"

+[ -z "$SERVICE_NOTE" ] || echo "spt-install: $SERVICE_NOTE"


Now install.ps1 firewall + scheduled-task legs:


# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir          (default: %LOCALAPPDATA%\spt-core\bin)
#   SPT_INSTALL_REPO       owner/repo           (default: SaberMage/spt-releases)
#   SPT_INSTALL_VERSION    release tag to pin   (default: latest)
#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
#                          directly (bypasses the release lookup; CI / air-gap)
#   SPT_INSTALL_NO_PATH    set to 1 to skip PATH registration

# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir          (default: %LOCALAPPDATA%\spt-core\bin)
#   SPT_INSTALL_REPO       owner/repo           (default: SaberMage/spt-releases)
#   SPT_INSTALL_VERSION    release tag to pin   (default: latest)
#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
#                          directly (bypasses the release lookup; CI / air-gap)
#   SPT_INSTALL_NO_PATH     set to 1 to skip PATH registration
#   SPT_INSTALL_NO_FIREWALL set to 1 to skip the inbound firewall rule leg
#   SPT_INSTALL_NO_SERVICE  set to 1 to skip the at-logon scheduled task leg
#                           (CI keeps the E2E hermetic with these two)


The file C:\Users\decid\Documents\projects\spt-core\installer\install.ps1 has been updated successfully. (file state is current in your context — no need to Read it back)

# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir          (default: %LOCALAPPDATA%\spt-core\bin)
#   SPT_INSTALL_REPO       owner/repo           (default: SaberMage/spt-releases)
#   SPT_INSTALL_VERSION    release tag to pin   (default: latest)
#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
#                          directly (bypasses the release lookup; CI / air-gap)
#   SPT_INSTALL_NO_PATH     set to 1 to skip PATH registration
#   SPT_INSTALL_NO_FIREWALL set to 1 to skip the inbound firewall rule leg
#   SPT_INSTALL_NO_SERVICE  set to 1 to skip the at-logon scheduled task leg
#                           (CI keeps the E2E hermetic with these two)

# spt installer (Windows) - served at the canonical Pages URL (ADR-0014):
#
#   irm https://sabermage.github.io/spt-releases/install.ps1 | iex
#
# [impl->REQ-INSTALL-5] Non-interactive BY CONSTRUCTION: this same script is
# every adapter's pack-in on-demand install (no second mechanism), so it must
# never prompt. It downloads the latest release binary, sha256-checks it
# against the release's SHA256SUMS (full ed25519 verification is `spt update`'s
# job after first install; first-fetch trust = HTTPS + GitHub), places it under
# the per-OS install root, and registers the *user* PATH.
#
# Compatible with stock Windows PowerShell 5.1 (the `irm | iex` default) and
# PowerShell 7+. ASCII only: PS 5.1 reads BOM-less scripts as ANSI.
#
# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir          (default: %LOCALAPPDATA%\spt-core\bin)
#   SPT_INSTALL_REPO       owner/repo           (default: SaberMage/spt-releases)
#   SPT_INSTALL_VERSION    release tag to pin   (default: latest)
#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
#                          directly (bypasses the release lookup; CI / air-gap)
#   SPT_INSTALL_NO_PATH    set to 1 to skip PATH registration

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
# PS 5.1 defaults to TLS 1.0; GitHub requires >= 1.2.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12

# throw, never `exit`: under `irm | iex` the script body runs in the user's
# session, where `exit` would close their terminal. A throw aborts the install
# either way, and `powershell -File` still maps it to a nonzero exit code.
function Fail([string]$msg) {
    throw "spt-install: error: $msg"
}

$repo = if ($env:SPT_INSTALL_REPO) { $env:SPT_INSTALL_REPO } else { 'SaberMage/spt-releases' }
# spt-core\bin, matching the state home (%LOCALAPPDATA%\spt-core) - NOT
# %LOCALAPPDATA%\spt, which belongs to the legacy SPT plugin (cohabiting its
# home was a v0.1.0 mistake; the migration block below cleans it up).
$usingDefaultDir = -not $env:SPT_INSTALL_DIR
$installDir = if ($env:SPT_INSTALL_DIR) { $env:SPT_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'spt-core\bin' }

# ---- platform / arch -> release asset name -------------------------------
$arch = $env:PROCESSOR_ARCHITECTURE
if ($arch -ne 'AMD64') {
    Fail "no published build for architecture '$arch' yet (x86_64 only today)"
}
$asset = 'spt-x86_64-windows.exe'

$work = Join-Path ([IO.Path]::GetTempPath()) ("spt-install-" + [IO.Path]::GetRandomFileName())
New-Item -ItemType Directory -Path $work -Force | Out-Null

try {
    # ---- fetch: binary + SHA256SUMS ----------------------------------------
    # [impl->REQ-INSTALL-1] the standalone one-line script half: latest-release
    # lookup -> platform asset fetch -> sha256 verify -> place -> user PATH.
    function Fetch([string]$name, [string]$dest) {
        $base = $env:SPT_INSTALL_ASSET_BASE
        if ($base) {
            if ($base -match '^https?://') {
                Invoke-WebRequest -Uri "$base/$name" -OutFile $dest -UseBasicParsing
            } else {
                Copy-Item -Path (Join-Path $base $name) -Destination $dest
            }
        } elseif ($env:SPT_INSTALL_VERSION) {
            Invoke-WebRequest -Uri "https://github.com/$repo/releases/download/$($env:SPT_INSTALL_VERSION)/$name" -OutFile $dest -UseBasicParsing
        } else {
            Invoke-WebRequest -Uri "https://github.com/$repo/releases/latest/download/$name" -OutFile $dest -UseBasicParsing
        }
    }

    Write-Output "spt-install: fetching $asset ..."
    $binPath = Join-Path $work $asset
    $sumsPath = Join-Path $work 'SHA256SUMS'
    Fetch $asset $binPath
    Fetch 'SHA256SUMS' $sumsPath

    # ---- verify -------------------------------------------------------------
    $want = $null
    foreach ($line in (Get-Content $sumsPath)) {
        if ($line -match '^([0-9a-fA-F]{64})\s+\*?(.+)$' -and $Matches[2].Trim() -eq $asset) {
            $want = $Matches[1].ToLowerInvariant()
        }
    }
    if (-not $want) { Fail "SHA256SUMS has no entry for $asset" }
    # .NET directly, not Get-FileHash: cmdlet autoload breaks when a PS7
    # parent's PSModulePath leaks into a 5.1 child (and on hardened boxes).
    $sha = [Security.Cryptography.SHA256]::Create()
    $stream = [IO.File]::OpenRead($binPath)
    try { $hashBytes = $sha.ComputeHash($stream) } finally { $stream.Dispose() }
    $got = ([BitConverter]::ToString($hashBytes) -replace '-', '').ToLowerInvariant()
    if ($got -ne $want) {
        Fail "sha256 mismatch for $asset (expected $want, got $got) - refusing to install"
    }
    Write-Output "spt-install: sha256 verified."

    # ---- place --------------------------------------------------------------
    # [impl->REQ-INSTALL-3] idempotent re-run: the binary is replaced in place;
    # the PATH registration below is added at most once.
    New-Item -ItemType Directory -Path $installDir -Force | Out-Null
    $target = Join-Path $installDir 'spt.exe'
    try {
        Move-Item -Path $binPath -Destination $target -Force
    } catch {
        Fail "could not replace $target (is spt running? stop it and re-run): $_"
    }

    # ---- migrate the v0.1.0 install dir ---------------------------------------
    # [impl->REQ-INSTALL-3] v0.1.0 installed into %LOCALAPPDATA%\spt\bin -
    # inside the LEGACY SPT plugin's state home. Re-running this installer
    # relocates cleanly: remove exactly the old binary + its bin dir (never
    # the parent - it holds the legacy plugin's live state) and drop the old
    # dir from the user PATH. Only fires on the default-dir path; an explicit
    # SPT_INSTALL_DIR opts out of any migration opinion.
    if ($usingDefaultDir) {
        $oldDir = Join-Path $env:LOCALAPPDATA 'spt\bin'
        $oldExe = Join-Path $oldDir 'spt.exe'
        if (Test-Path $oldExe) {
            try {
                Remove-Item -Path $oldExe -Force
                if (-not (Get-ChildItem -Path $oldDir -Force -ErrorAction SilentlyContinue)) {
                    Remove-Item -Path $oldDir -Force
                }
                Write-Output "spt-install: migrated: removed v0.1.0 binary at $oldExe"
            } catch {
                Write-Output "spt-install: warning: could not remove old install at $oldExe (is spt running?); remove it manually"
            }
        }
        $userPathNow = [Environment]::GetEnvironmentVariable('Path', 'User')
        if ($userPathNow) {
            $oldNorm = $oldDir.TrimEnd('\')
            $kept = $userPathNow -split ';' | Where-Object { $_ -and ($_.Trim().TrimEnd('\') -ne $oldNorm) }
            $cleaned = $kept -join ';'
            if ($cleaned -ne $userPathNow) {
                [Environment]::SetEnvironmentVariable('Path', $cleaned, 'User')
                Write-Output "spt-install: migrated: removed $oldDir from your user PATH"
            }
        }
    }

    # ---- user PATH registration ----------------------------------------------
    $pathNote = $null
    if ($env:SPT_INSTALL_NO_PATH -ne '1') {
        $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
        if (-not $userPath) { $userPath = '' }
        $entries = $userPath -split ';' | ForEach-Object { $_.Trim().TrimEnd('\') } | Where-Object { $_ }
        if ($entries -notcontains $installDir.TrimEnd('\')) {
            $newPath = if ($userPath -and -not $userPath.EndsWith(';')) { "$userPath;$installDir" } else { "$userPath$installDir" }
            [Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
            $pathNote = "Registered $installDir on your user PATH (takes effect in NEW terminals only)."
        }
    }

    # ---- report ---------------------------------------------------------------
    # Print the absolute path: the PATH change does not reach this terminal, so
    # the first invocation here must use it absolutely.
    Write-Output "spt-install: installed $target"
    & $target --version
    if ($pathNote) {
        Write-Output "spt-install: $pathNote"
        Write-Output "spt-install: in THIS terminal, invoke it as: $target"
    }
} finally {
    Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue
}

-#   SPT_INSTALL_NO_PATH    set to 1 to skip PATH registration

+#   SPT_INSTALL_NO_PATH     set to 1 to skip PATH registration

+#   SPT_INSTALL_NO_FIREWALL set to 1 to skip the inbound firewall rule leg

+#   SPT_INSTALL_NO_SERVICE  set to 1 to skip the at-logon scheduled task leg

+#                           (CI keeps the E2E hermetic with these two)


    # ---- report ---------------------------------------------------------------
    # Print the absolute path: the PATH change does not reach this terminal, so
    # the first invocation here must use it absolutely.
    Write-Output "spt-install: installed $target"
    & $target --version
    if ($pathNote) {
        Write-Output "spt-install: $pathNote"
        Write-Output "spt-install: in THIS terminal, invoke it as: $target"
    }

    # ---- inbound firewall rule (M8 root cause 3, REQ-INSTALL-7) ---------------
    # [impl->REQ-INSTALL-7] a detached daemon never triggers the first-bind
    # firewall prompt, so a fresh Windows install is silently unreachable
    # inbound (mDNS + QUIC meet/pairing UDP drop with no rendered cause -
    # the M7 NO_SEED_HOLDER). The elevated install leg registers the rule;
    # an unelevated run prints the exact command instead of prompting.
    $fwRuleName = 'spt-core inbound UDP'
    if ($env:SPT_INSTALL_NO_FIREWALL -ne '1') {
        $isElevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
                      ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
        if ($isElevated) {
            try {
                # Idempotent: replace any prior rule (the binary path may have
                # moved); netsh, not New-NetFirewallRule - works on PS 5.1
                # hardened boxes without the NetSecurity module.
                netsh advfirewall firewall delete rule name="$fwRuleName" | Out-Null
                netsh advfirewall firewall add rule name="$fwRuleName" dir=in action=allow protocol=UDP program="$target" | Out-Null
                Write-Output "spt-install: registered inbound UDP firewall rule for $target"
            } catch {
                Write-Output "spt-install: warning: firewall rule registration failed: $_"
            }
        } else {
            Write-Output "spt-install: NOT elevated - inbound UDP may be blocked (joins toward this node can fail)."
            Write-Output "spt-install: to fix, run elevated:  netsh advfirewall firewall add rule name=`"$fwRuleName`" dir=in action=allow protocol=UDP program=`"$target`""
        }
    }

    # ---- at-logon scheduled task (M8 decision 17, REQ-INSTALL-8) --------------
    # [impl->REQ-INSTALL-8] reboot-survivable daemon: an at-logon scheduled
    # task in the user's interactive session - no stored credentials, no
    # session-0 isolation (the daemon hosts terminals), registrable for the
    # OWN account without elevation. schtasks, not Register-ScheduledTask,
    # for the same PS 5.1 hardened-box reason as netsh above.
    if ($env:SPT_INSTALL_NO_SERVICE -ne '1') {
        $taskName = 'spt-core daemon'
        try {
            schtasks /Create /F /TN "$taskName" /SC ONLOGON /TR "`"$target`" daemon run" /RL LIMITED | Out-Null
            Write-Output "spt-install: registered the at-logon daemon task '$taskName' (starts at your next logon)."
        } catch {
            Write-Output "spt-install: warning: scheduled-task registration failed: $_"
            Write-Output "spt-install: register manually:  schtasks /Create /F /TN `"$taskName`" /SC ONLOGON /TR `"`"$target`" daemon run`" /RL LIMITED"
        }
    }

    # ---- report ---------------------------------------------------------------
    # Print the absolute path: the PATH change does not reach this terminal, so
    # the first invocation here must use it absolutely.
    Write-Output "spt-install: installed $target"
    & $target --version
    if ($pathNote) {
        Write-Output "spt-install: $pathNote"
        Write-Output "spt-install: in THIS terminal, invoke it as: $target"
    }


    # ---- inbound firewall rule (M8 root cause 3, REQ-INSTALL-7) ---------------
    # [impl->REQ-INSTALL-7] a detached daemon never triggers the first-bind
    # firewall prompt, so a fresh Windows install is silently unreachable
    # inbound (mDNS + QUIC meet/pairing UDP drop with no rendered cause -
    # the M7 NO_SEED_HOLDER). The elevated install leg registers the rule;
    # an unelevated run prints the exact command instead of prompting.
    $fwRuleName = 'spt-core inbound UDP'
    if ($env:SPT_INSTALL_NO_FIREWALL -ne '1') {
        $isElevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
                      ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
        if ($isElevated) {
            try {
                # Idempotent: replace any prior rule (the binary path may have
                # moved); netsh, not New-NetFirewallRule - works on PS 5.1
                # hardened boxes without the NetSecurity module.
                netsh advfirewall firewall delete rule name="$fwRuleName" | Out-Null
                netsh advfirewall firewall add rule name="$fwRuleName" dir=in action=allow protocol=UDP program="$target" | Out-Null
                Write-Output "spt-install: registered inbound UDP firewall rule for $target"
            } catch {
                Write-Output "spt-install: warning: firewall rule registration failed: $_"
            }
        } else {
            Write-Output "spt-install: NOT elevated - inbound UDP may be blocked (joins toward this node can fail)."
            Write-Output "spt-install: to fix, run elevated:  netsh advfirewall firewall add rule name=`"$fwRuleName`" dir=in action=allow protocol=UDP program=`"$target`""
        }
    }

    # ---- at-logon scheduled task (M8 decision 17, REQ-INSTALL-8) --------------
    # [impl->REQ-INSTALL-8] reboot-survivable daemon: an at-logon scheduled
    # task in the user's interactive session - no stored credentials, no
    # session-0 isolation (the daemon hosts terminals), registrable for the
    # OWN account without elevation. schtasks, not Register-ScheduledTask,
    # for the same PS 5.1 hardened-box reason as netsh above.
    if ($env:SPT_INSTALL_NO_SERVICE -ne '1') {
        $taskName = 'spt-core daemon'
        try {
            schtasks /Create /F /TN "$taskName" /SC ONLOGON /TR "`"$target`" daemon run" /RL LIMITED | Out-Null
            Write-Output "spt-install: registered the at-logon daemon task '$taskName' (starts at your next logon)."
        } catch {
            Write-Output "spt-install: warning: scheduled-task registration failed: $_"
            Write-Output "spt-install: register manually:  schtasks /Create /F /TN `"$taskName`" /SC ONLOGON /TR `"`"$target`" daemon run`" /RL LIMITED"
        }
    }

    # ---- report ---------------------------------------------------------------
    # Print the absolute path: the PATH change does not reach this terminal, so
    # the first invocation here must use it absolutely.
    Write-Output "spt-install: installed $target"
    & $target --version
    if ($pathNote) {
        Write-Output "spt-install: $pathNote"
        Write-Output "spt-install: in THIS terminal, invoke it as: $target"
    }

# spt installer (Windows) - served at the canonical Pages URL (ADR-0014):
#
#   irm https://sabermage.github.io/spt-releases/install.ps1 | iex
#
# [impl->REQ-INSTALL-5] Non-interactive BY CONSTRUCTION: this same script is
# every adapter's pack-in on-demand install (no second mechanism), so it must
# never prompt. It downloads the latest release binary, sha256-checks it
# against the release's SHA256SUMS (full ed25519 verification is `spt update`'s
# job after first install; first-fetch trust = HTTPS + GitHub), places it under
# the per-OS install root, and registers the *user* PATH.
#
# Compatible with stock Windows PowerShell 5.1 (the `irm | iex` default) and
# PowerShell 7+. ASCII only: PS 5.1 reads BOM-less scripts as ANSI.
#
# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir          (default: %LOCALAPPDATA%\spt-core\bin)
#   SPT_INSTALL_REPO       owner/repo           (default: SaberMage/spt-releases)
#   SPT_INSTALL_VERSION    release tag to pin   (default: latest)
#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
#                          directly (bypasses the release lookup; CI / air-gap)
#   SPT_INSTALL_NO_PATH     set to 1 to skip PATH registration
#   SPT_INSTALL_NO_FIREWALL set to 1 to skip the inbound firewall rule leg
#   SPT_INSTALL_NO_SERVICE  set to 1 to skip the at-logon scheduled task leg
#                           (CI keeps the E2E hermetic with these two)

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
# PS 5.1 defaults to TLS 1.0; GitHub requires >= 1.2.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12

# throw, never `exit`: under `irm | iex` the script body runs in the user's
# session, where `exit` would close their terminal. A throw aborts the install
# either way, and `powershell -File` still maps it to a nonzero exit code.
function Fail([string]$msg) {
    throw "spt-install: error: $msg"
}

$repo = if ($env:SPT_INSTALL_REPO) { $env:SPT_INSTALL_REPO } else { 'SaberMage/spt-releases' }
# spt-core\bin, matching the state home (%LOCALAPPDATA%\spt-core) - NOT
# %LOCALAPPDATA%\spt, which belongs to the legacy SPT plugin (cohabiting its
# home was a v0.1.0 mistake; the migration block below cleans it up).
$usingDefaultDir = -not $env:SPT_INSTALL_DIR
$installDir = if ($env:SPT_INSTALL_DIR) { $env:SPT_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'spt-core\bin' }

# ---- platform / arch -> release asset name -------------------------------
$arch = $env:PROCESSOR_ARCHITECTURE
if ($arch -ne 'AMD64') {
    Fail "no published build for architecture '$arch' yet (x86_64 only today)"
}
$asset = 'spt-x86_64-windows.exe'

$work = Join-Path ([IO.Path]::GetTempPath()) ("spt-install-" + [IO.Path]::GetRandomFileName())
New-Item -ItemType Directory -Path $work -Force | Out-Null

try {
    # ---- fetch: binary + SHA256SUMS ----------------------------------------
    # [impl->REQ-INSTALL-1] the standalone one-line script half: latest-release
    # lookup -> platform asset fetch -> sha256 verify -> place -> user PATH.
    function Fetch([string]$name, [string]$dest) {
        $base = $env:SPT_INSTALL_ASSET_BASE
        if ($base) {
            if ($base -match '^https?://') {
                Invoke-WebRequest -Uri "$base/$name" -OutFile $dest -UseBasicParsing
            } else {
                Copy-Item -Path (Join-Path $base $name) -Destination $dest
            }
        } elseif ($env:SPT_INSTALL_VERSION) {
            Invoke-WebRequest -Uri "https://github.com/$repo/releases/download/$($env:SPT_INSTALL_VERSION)/$name" -OutFile $dest -UseBasicParsing
        } else {
            Invoke-WebRequest -Uri "https://github.com/$repo/releases/latest/download/$name" -OutFile $dest -UseBasicParsing
        }
    }

    Write-Output "spt-install: fetching $asset ..."
    $binPath = Join-Path $work $asset
    $sumsPath = Join-Path $work 'SHA256SUMS'
    Fetch $asset $binPath
    Fetch 'SHA256SUMS' $sumsPath

    # ---- verify -------------------------------------------------------------
    $want = $null
    foreach ($line in (Get-Content $sumsPath)) {
        if ($line -match '^([0-9a-fA-F]{64})\s+\*?(.+)$' -and $Matches[2].Trim() -eq $asset) {
            $want = $Matches[1].ToLowerInvariant()
        }
    }
    if (-not $want) { Fail "SHA256SUMS has no entry for $asset" }
    # .NET directly, not Get-FileHash: cmdlet autoload breaks when a PS7
    # parent's PSModulePath leaks into a 5.1 child (and on hardened boxes).
    $sha = [Security.Cryptography.SHA256]::Create()
    $stream = [IO.File]::OpenRead($binPath)
    try { $hashBytes = $sha.ComputeHash($stream) } finally { $stream.Dispose() }
    $got = ([BitConverter]::ToString($hashBytes) -replace '-', '').ToLowerInvariant()
    if ($got -ne $want) {
        Fail "sha256 mismatch for $asset (expected $want, got $got) - refusing to install"
    }
    Write-Output "spt-install: sha256 verified."

    # ---- place --------------------------------------------------------------
    # [impl->REQ-INSTALL-3] idempotent re-run: the binary is replaced in place;
    # the PATH registration below is added at most once.
    New-Item -ItemType Directory -Path $installDir -Force | Out-Null
    $target = Join-Path $installDir 'spt.exe'
    try {
        Move-Item -Path $binPath -Destination $target -Force
    } catch {
        Fail "could not replace $target (is spt running? stop it and re-run): $_"
    }

    # ---- migrate the v0.1.0 install dir ---------------------------------------
    # [impl->REQ-INSTALL-3] v0.1.0 installed into %LOCALAPPDATA%\spt\bin -
    # inside the LEGACY SPT plugin's state home. Re-running this installer
    # relocates cleanly: remove exactly the old binary + its bin dir (never
    # the parent - it holds the legacy plugin's live state) and drop the old
    # dir from the user PATH. Only fires on the default-dir path; an explicit
    # SPT_INSTALL_DIR opts out of any migration opinion.
    if ($usingDefaultDir) {
        $oldDir = Join-Path $env:LOCALAPPDATA 'spt\bin'
        $oldExe = Join-Path $oldDir 'spt.exe'
        if (Test-Path $oldExe) {
            try {
                Remove-Item -Path $oldExe -Force
                if (-not (Get-ChildItem -Path $oldDir -Force -ErrorAction SilentlyContinue)) {
                    Remove-Item -Path $oldDir -Force
                }
                Write-Output "spt-install: migrated: removed v0.1.0 binary at $oldExe"
            } catch {
                Write-Output "spt-install: warning: could not remove old install at $oldExe (is spt running?); remove it manually"
            }
        }
        $userPathNow = [Environment]::GetEnvironmentVariable('Path', 'User')
        if ($userPathNow) {
            $oldNorm = $oldDir.TrimEnd('\')
            $kept = $userPathNow -split ';' | Where-Object { $_ -and ($_.Trim().TrimEnd('\') -ne $oldNorm) }
            $cleaned = $kept -join ';'
            if ($cleaned -ne $userPathNow) {
                [Environment]::SetEnvironmentVariable('Path', $cleaned, 'User')
                Write-Output "spt-install: migrated: removed $oldDir from your user PATH"
            }
        }
    }

    # ---- user PATH registration ----------------------------------------------
    $pathNote = $null
    if ($env:SPT_INSTALL_NO_PATH -ne '1') {
        $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
        if (-not $userPath) { $userPath = '' }
        $entries = $userPath -split ';' | ForEach-Object { $_.Trim().TrimEnd('\') } | Where-Object { $_ }
        if ($entries -notcontains $installDir.TrimEnd('\')) {
            $newPath = if ($userPath -and -not $userPath.EndsWith(';')) { "$userPath;$installDir" } else { "$userPath$installDir" }
            [Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
            $pathNote = "Registered $installDir on your user PATH (takes effect in NEW terminals only)."
        }
    }

    # ---- report ---------------------------------------------------------------
    # Print the absolute path: the PATH change does not reach this terminal, so
    # the first invocation here must use it absolutely.
    Write-Output "spt-install: installed $target"
    & $target --version
    if ($pathNote) {
        Write-Output "spt-install: $pathNote"
        Write-Output "spt-install: in THIS terminal, invoke it as: $target"
    }
} finally {
    Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue
}

+    # ---- inbound firewall rule (M8 root cause 3, REQ-INSTALL-7) ---------------

+    # [impl->REQ-INSTALL-7] a detached daemon never triggers the first-bind

+    # firewall prompt, so a fresh Windows install is silently unreachable

+    # inbound (mDNS + QUIC meet/pairing UDP drop with no rendered cause -

+    # the M7 NO_SEED_HOLDER). The elevated install leg registers the rule;

+    # an unelevated run prints the exact command instead of prompting.

+    $fwRuleName = 'spt-core inbound UDP'

+    if ($env:SPT_INSTALL_NO_FIREWALL -ne '1') {

+        $isElevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()

+                      ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)

+                # Idempotent: replace any prior rule (the binary path may have

+                # moved); netsh, not New-NetFirewallRule - works on PS 5.1

+                # hardened boxes without the NetSecurity module.

+                netsh advfirewall firewall delete rule name="$fwRuleName" | Out-Null

+                netsh advfirewall firewall add rule name="$fwRuleName" dir=in action=allow protocol=UDP program="$target" | Out-Null

+                Write-Output "spt-install: registered inbound UDP firewall rule for $target"

+                Write-Output "spt-install: warning: firewall rule registration failed: $_"

+            Write-Output "spt-install: NOT elevated - inbound UDP may be blocked (joins toward this node can fail)."

+            Write-Output "spt-install: to fix, run elevated:  netsh advfirewall firewall add rule name=`"$fwRuleName`" dir=in action=allow protocol=UDP program=`"$target`""

+    # ---- at-logon scheduled task (M8 decision 17, REQ-INSTALL-8) --------------

+    # [impl->REQ-INSTALL-8] reboot-survivable daemon: an at-logon scheduled

+    # task in the user's interactive session - no stored credentials, no

+    # session-0 isolation (the daemon hosts terminals), registrable for the

+    # OWN account without elevation. schtasks, not Register-ScheduledTask,

+    # for the same PS 5.1 hardened-box reason as netsh above.

+    if ($env:SPT_INSTALL_NO_SERVICE -ne '1') {

+            schtasks /Create /F /TN "$taskName" /SC ONLOGON /TR "`"$target`" daemon run" /RL LIMITED | Out-Null

+            Write-Output "spt-install: registered the at-logon daemon task '$taskName' (starts at your next logon)."

+            Write-Output "spt-install: warning: scheduled-task registration failed: $_"

+            Write-Output "spt-install: register manually:  schtasks /Create /F /TN `"$taskName`" /SC ONLOGON /TR `"`"$target`" daemon run`" /RL LIMITED"