#!/bin/sh # Install the avengents edge from a packed tarball. # # curl -fsSL https:///install.sh | sh # # The tarball is what makes this work. `npm install -g git+ssh://…` does NOT: # npm 10 links the global package at its temporary clone inside the cache and # then deletes that directory, leaving a dangling symlink and EXIT 0, and npm 12 # refuses git dependencies outright (EALLOWGIT). Both measured in clean images. # A tarball has no such path — npm unpacks it and installs its dependencies from # the registry the normal way. # # Override the source with AVENGENTS_TARBALL_URL, or pass a local .tgz as $1. set -eu # The published tarball. Baked in so `curl … | sh` works with nothing else set — # which is the entire point of a curl installer, and which this script did NOT # do: served verbatim it answered "no tarball to install", so the one-liner the # UI hands out could never have worked. Found by running it on the real host # rather than reading it. # # THIS LINE IS THE CHANNEL'S IDENTITY AND THE PACKER STAMPS IT. The dev/prod # separation (doc/DEV-PROD-SEPARATION.md, 2026-08-20) publishes one install.sh # per channel; the packer rewrites exactly this one line to the channel it is # publishing (app-dev… for dev), and the state-file write below derives the # update_url from whatever it says. So: keep it ONE line, this exact shape — # `PUBLISHED_URL="/avengents-server.tgz"` — and nothing else on it. # # The value here is the PROD public channel. It was the legacy tailnet :9444 # host until 2026-08-21, which was already refusing connections — a bare # `curl https://app.avengents.ai/install.sh | sh` on a machine outside the # tailnet died at the download (avg-frontend measured it; the App's own one- # liner passes the origin as $1 and was never affected). PUBLISHED_URL="https://app.avengents.ai/avengents-server.tgz" # The other two halves of the channel's identity — the router this channel's # machines talk to, and the key they pin for it. Same contract as the line # above: ONE line each, this exact shape, stamped per channel by the packer # (dev: router-dev.avengents.ai and the dev router's key). Prod values here. CHANNEL_ROUTER_URL="https://router.avengents.ai" CHANNEL_ROUTER_PUBKEY="_hHwK03ghenWdt3mjrFjspKUuav49ZzHZk4LrrUnTdg" DEFAULT_URL="${AVENGENTS_TARBALL_URL:-$PUBLISHED_URL}" SRC="${1:-$DEFAULT_URL}" die() { echo "avengents install: $*" >&2; exit 1; } # A MACHINE CHANGES CHANNEL ON PURPOSE, NEVER BY HABIT. # # Installing from channel X makes the whole machine X's (update channel, app, # router, key — see the record block at the end). That is right when it is # meant, and a silent disaster when it is not: `curl | sh` on # a machine someone deliberately pointed at dev would quietly put it back on # prod, and the reverse would take a production machine — its agents, its # router — onto dev with no error and no trace (avg-devenv measured the first # half on the dev box, 2026-08-21; avg-frontend named the fix: refuse out loud # instead of converting quietly, the same shape as a refused `send`). # # So, BEFORE anything is downloaded or installed: if this machine already # follows a channel and it is not the one being installed from, stop, name both, # and leave the machine exactly as it was. Converting stays one deliberate step # away — the flag, or removing the two record files first. channel_guard() { case "$SRC" in http://*|https://*) ;; *) return 0 ;; esac # a local .tgz names no channel want="${AVENGENTS_UPDATE_URL:-${SRC%/*}/avengents-update.json}" state_home="${AVENGENTS_HOME:-$HOME/.avengents}" have="$(cat "$state_home/update_url" 2>/dev/null | tr -d '[:space:]' || true)" if [ -z "$have" ] && [ -f "$state_home/cloud.json" ]; then have="$(sed -n 's/.*"channel":"\([^"]*\)".*/\1/p' "$state_home/cloud.json" 2>/dev/null | head -1)" fi [ -n "$have" ] || return 0 # never chose a channel: proceed [ "$have" = "$want" ] && return 0 # same channel: reinstall/upgrade, silently [ "${AVENGENTS_CONVERT:-}" = "1" ] && { echo "avengents install: converting this machine from $have to $want (AVENGENTS_CONVERT=1)"; return 0; } # THE CONVERT ENV GOES ON `sh`, NOT `curl`. In `VAR=1 curl … | sh` the shell # assigns VAR only to curl; the `sh` running this script never sees it, so the # guard refuses again — a suggestion that leads to the same dead end (owner hit # exactly this on tbc-m1, 2026-08-21; avg-devenv measured the semantics). The # variable must sit on the RIGHT of the pipe: `curl … | VAR=1 sh`. die "this machine currently follows $have you are installing from $want converting is fine — but do it deliberately, one of: curl -fsSL ${SRC%/*}/install.sh | AVENGENTS_CONVERT=1 sh rm -f $state_home/cloud.json $state_home/update_url && curl -fsSL ${SRC%/*}/install.sh | sh nothing was installed or changed." } # Append `export PATH=…/bin` to the profile the user's shell actually reads, once # (a marker guards against stacking duplicates on a re-run). Used only when the # install is redirected to a per-user prefix whose bin is not yet on PATH: the # script cannot change its parent shell, so persisting here is what lets the NEXT # shell find `avengents`, and the caller also prints the export for the shell in # hand. PATH_MARKER="# added by avengents install (per-user npm prefix)" persist_path() { case "${SHELL:-}" in */zsh) prof="$HOME/.zshrc" ;; */bash) prof="$HOME/.bashrc" ;; *) prof="$HOME/.profile" ;; esac # Deduped on the EXPORT LINE, not on the marker. There are now two different # directories this can be asked to persist — a per-user npm prefix, and the # private Node below — and a machine can legitimately need both. Keying on the # marker (which is what this did) meant the second call found the first call's # comment and returned having added nothing, so a machine that already had # ~/.npm-global on PATH would install Node 22 and never be able to find it. # The marker text is left EXACTLY as it was on purpose: changing it would make # every already-installed machine append a duplicate on its next reinstall. if [ -f "$prof" ] && grep -qF "export PATH=\"$1:\$PATH\"" "$prof" 2>/dev/null; then return 0 fi if printf '\n%s\nexport PATH="%s:$PATH"\n' "$PATH_MARKER" "$1" >> "$prof" 2>/dev/null; then echo "avengents install: added $1 to PATH in $prof — open a new shell, or run the export printed above now" else echo "avengents install: could not update $prof — add this yourself: export PATH=\"$1:\$PATH\"" fi } [ -n "$SRC" ] || die "no tarball to install. Pass one: sh install.sh ./avengents-server-0.1.0.tgz Or set a URL: AVENGENTS_TARBALL_URL=https:///avengents-server.tgz sh install.sh" # BEFORE the Node section, not after it — and that order is the whole point. # channel_guard's refusal ends with "nothing was installed or changed", and the # Node section below can now install a runtime. Asking a person to approve a # Node install and then refusing the install they wanted it for would make that # sentence a lie and leave the machine holding a download it has no use for. channel_guard # ── node ──────────────────────────────────────────────────────────────────── # --- offer_node begin --- # Checked BEFORE downloading anything. Node 18 and 20 install this package # cleanly and then die at module load — `sshHosts.ts` imports globSync from # node:fs at the top level — and npm only WARNS on an engines mismatch, so # nothing on screen connects the crash to the cause. Refusing here is the whole # difference between "wrong Node" and "it is broken". # # REFUSING USED TO BE ALL THIS DID, and that is the friction the owner named # (2026-09-15): a person who came here to install our edge is sent away to # install someone else's runtime first, in whichever dialect their machine # speaks — nvm, brew, a NodeSource apt repo, a dnf module — and most of them do # not come back. So when the floor is not met, this now ASKS, and on a yes it # installs Node itself. # # WHY A PRIVATE COPY RATHER THAN THE SYSTEM PACKAGE MANAGER, which is the # obvious first idea: # · every package-manager route needs root, and two of them (NodeSource for # apt, the dnf module stream) want a third-party repository and its signing # key added to the machine PERMANENTLY — an enormous, hard-to-reverse change # for a `curl … | sh` to make on someone's behalf; # · it does not even answer the question. Checked against the distributions' # own package indexes on 2026-09-15: Debian 13 (trixie, CURRENT stable) has # nodejs 20.19.2, Debian 12 has 18.20.4, Ubuntu 22.04 has 12.22.9 — every one # of them below our floor, so `apt install nodejs` ends at the same refusal # the person was trying to get past, and the newest stable release does not # fix it either; # · the official tarball needs no root at all, is the same three steps on # every distro and on macOS, and `rm -rf` undoes it completely. # It is also the same call this script already makes one section down, where a # root-owned npm prefix is answered with a per-user one instead of asking for a # password. # # WHAT IT DOES NOT BREAK. A node that was already on this machine is left # exactly where it is; the only change outside $NODE_HOME is one PATH line. And # a private Node does NOT recreate the nvm trap documented at the npm_path # record below, where a runtime the login shell finds and a systemd unit cannot # left two machines unable to ever self-update: the unit's ExecStart is rendered # from an absolute process.execPath (supervise.ts), and the updater prefers the # `node` sitting BESIDE the npm this installer records (selfUpdate.ts). Both # resolve inside $NODE_HOME with no PATH involved. NODE_HOME="${AVENGENTS_HOME:-$HOME/.avengents}/node" NODE_DIST="${AVENGENTS_NODE_DIST:-https://nodejs.org/dist/latest-v22.x}" # Alpine and anything else on musl. The official Linux builds are linked against # glibc and do not run here — they install perfectly and then fail on first exec, # which is precisely the "installed fine, then broken" shape the Node floor above # exists to prevent, so it is caught before the download rather than after. # Two signals because either alone can be absent: the loader is the file that # would actually have to run node, and `ldd --version` names the libc on systems # where that glob lives somewhere else. node_is_musl() { for _f in /lib/ld-musl-*.so.1; do if [ -e "$_f" ]; then return 0; fi done ldd --version 2>&1 | grep -qi musl } # WHAT THIS MACHINE IS, in nodejs.org's own vocabulary — the owner's "detect the # operating system" step. It decides which build to fetch, and when there is no # build it decides what to say instead, which is the half that would otherwise # become a shrug. NODE_TARGET="" # e.g. linux-x64, darwin-arm64 NODE_NO_BUILD="" # why there is none for this machine, when there is none node_detect() { _os="$(uname -s 2>/dev/null || echo unknown)" _arch="$(uname -m 2>/dev/null || echo unknown)" case "$_os" in Linux) _os=linux ;; Darwin) _os=darwin ;; *) NODE_NO_BUILD="these builds cover Linux and macOS; this is $_os"; return 0 ;; esac # The names nodejs.org publishes, read off the actual v22 file list rather than # guessed: x64, arm64, armv7l, ppc64le, s390x. armv6l is NOT among them (nor in # latest-v12 or latest-v14 — checked, since it is the one people assume is # there), so a Pi Zero lands in the honest-refusal branch instead of # downloading something that cannot run. case "$_arch" in x86_64|amd64) _arch=x64 ;; aarch64|arm64) _arch=arm64 ;; armv7l) _arch=armv7l ;; ppc64le) _arch=ppc64le ;; s390x) _arch=s390x ;; *) NODE_NO_BUILD="there is no official Node build for $_arch"; return 0 ;; esac if [ "$_os" = linux ] && node_is_musl; then # The advice belongs HERE, where the system is actually identified, and not # in the refusal — the refusal would have to re-detect to know what to say. NODE_NO_BUILD="the official Linux builds need glibc and this system uses musl (Alpine and kin) On Alpine: apk add nodejs npm (Alpine 3.20 or newer carries Node 22)" return 0 fi NODE_TARGET="$_os-$_arch" } # Its own fetcher, deliberately not shared with the tarball download below: this # whole block is lifted out verbatim and run under dash by # tests/installNodeOffer.test.ts, and a helper reaching upward out of the block # is a thing the test could only fake. # # A third argument asks for a progress bar, which is not decoration: the Node # tarball is ~50 MB where everything else this script fetches is small, and on a # slow link a silent `-s` download looks exactly like a hang. node_get() { if command -v curl >/dev/null 2>&1; then if [ -n "${3:-}" ]; then curl -fL --connect-timeout 20 --progress-bar "$1" -o "$2" else curl -fsSL --connect-timeout 20 "$1" -o "$2" fi elif command -v wget >/dev/null 2>&1; then wget --connect-timeout=20 -qO "$2" "$1" else return 1 fi } # Download, verify, unpack, and PROVE IT RUNS. Returns non-zero with a reason on # stderr; the caller turns that into the refusal. node_install() { _nt="$(mktemp -d)" || { echo "avengents install: could not create a temporary directory" >&2; return 1; } trap 'rm -rf "$_nt"' EXIT INT TERM # SHASUMS256.txt is fetched for TWO things at once, which is why this script # pins no version number that would go stale: it NAMES the current 22.x build # (so "latest v22" is resolved by the publisher, not by a constant in here), # and it carries the checksum of the exact file about to be unpacked and run. if ! node_get "$NODE_DIST/SHASUMS256.txt" "$_nt/sums"; then echo "avengents install: could not reach $NODE_DIST (no curl or wget, or no network)" >&2 rm -rf "$_nt"; trap - EXIT INT TERM; return 1 fi _file="$(sed -n "s/^[0-9a-f]\{64\} \(node-v22\.[0-9][0-9.]*-$NODE_TARGET\.tar\.gz\)\$/\1/p" "$_nt/sums" | head -1)" _want="$(sed -n "s/^\([0-9a-f]\{64\}\) node-v22\.[0-9][0-9.]*-$NODE_TARGET\.tar\.gz\$/\1/p" "$_nt/sums" | head -1)" if [ -z "$_file" ]; then echo "avengents install: $NODE_DIST lists no $NODE_TARGET build" >&2 rm -rf "$_nt"; trap - EXIT INT TERM; return 1 fi echo "avengents install: downloading ${_file%.tar.gz} from $NODE_DIST" if ! node_get "$NODE_DIST/$_file" "$_nt/$_file" progress; then echo "avengents install: download failed: $NODE_DIST/$_file" >&2 rm -rf "$_nt"; trap - EXIT INT TERM; return 1 fi # Checked when the machine has a tool for it, and SAID when it does not — # never skipped quietly. HTTPS already authenticates nodejs.org, so a missing # sha256sum is not a reason to refuse a person their runtime; it is a reason # to tell them which of the two checks they got. _got="" if command -v sha256sum >/dev/null 2>&1; then _got="$(sha256sum "$_nt/$_file" 2>/dev/null | cut -d' ' -f1)" elif command -v shasum >/dev/null 2>&1; then _got="$(shasum -a 256 "$_nt/$_file" 2>/dev/null | cut -d' ' -f1)" fi if [ -z "$_got" ]; then echo "avengents install: note — no sha256sum/shasum here, so the download was not checksum-verified (it did come over HTTPS)" elif [ "$_got" != "$_want" ]; then echo "avengents install: the download does not match its published checksum — refusing it expected $_want got $_got" >&2 rm -rf "$_nt"; trap - EXIT INT TERM; return 1 fi # Unpacked BESIDE the live directory and moved into place only once it is # whole. An interrupted `tar` straight into $NODE_HOME would leave half a # runtime that looks installed — and on a reinstall, half a runtime where a # working one used to be. rm -rf "$NODE_HOME.new" "$NODE_HOME.old" if ! mkdir -p "$NODE_HOME.new" || ! tar -xzf "$_nt/$_file" -C "$NODE_HOME.new" --strip-components=1; then echo "avengents install: could not unpack $_file into $NODE_HOME.new" >&2 rm -rf "$NODE_HOME.new" "$_nt"; trap - EXIT INT TERM; return 1 fi rm -rf "$_nt"; trap - EXIT INT TERM # PROVING IT RUNS, not that files landed — the same check this script makes of # its own install further down, and it earns its keep here: a glibc too old for # the build fails exactly at this point, with a message naming the version it # wanted, instead of thirty seconds later inside npm where nothing says Node. if ! _v="$("$NODE_HOME.new/bin/node" -v 2>&1)"; then echo "avengents install: Node unpacked but will not run on this machine: $_v" >&2 rm -rf "$NODE_HOME.new"; return 1 fi if [ -d "$NODE_HOME" ]; then mv "$NODE_HOME" "$NODE_HOME.old"; fi mv "$NODE_HOME.new" "$NODE_HOME" rm -rf "$NODE_HOME.old" # FIRST on PATH, and that is a real consequence stated out loud in the prompt: # everything downstream depends on it. npm's global prefix comes from this # node, `avengents` is a symlink to a script whose shebang is `env node`, and # the npm recorded for the auto-updater is whichever one this PATH finds. export PATH="$NODE_HOME/bin:$PATH" persist_path "$NODE_HOME/bin" echo "avengents install: Node $_v is installed in $NODE_HOME" } # ASKED ON THE TERMINAL, never on stdin — under `curl … | sh` stdin IS this # script, and a `read` there eats the installer's own next lines. Same contract # and same reasoning as offer_pairing at the end of this file, including the one # that matters most: no terminal to ask on (a CI job, a Dockerfile, `ssh host # 'curl … | sh'`) means nobody to ask, so it says what to do and stops. An # installer must never block a pipeline on a question. # # AVENGENTS_INSTALL_NODE=yes install Node without asking # AVENGENTS_INSTALL_NODE=no never ask, just refuse as this always did offer_node() { _have="" if command -v node >/dev/null 2>&1; then _have="$(node -v 2>/dev/null || true)" _major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)" if [ "$_major" -ge 22 ] 2>/dev/null; then return 0; fi _floor="Node $_have found, but 22 or newer is required. The package installs fine on 18 and 20 and then fails at startup, so this stops here instead." else _floor="node is not installed. Node 22 or newer is required." fi node_detect [ -n "$NODE_TARGET" ] || die "$_floor This installer can usually put Node 22 in place for you, but not on this machine: $NODE_NO_BUILD Install Node 22 yourself — https://nodejs.org/en/download — and run this again." case "${AVENGENTS_INSTALL_NODE:-ask}" in no) die "$_floor (AVENGENTS_INSTALL_NODE=no, so this did not offer to install it.)" ;; yes) echo "avengents install: $_floor"; _ans=y ;; *) # The offer and the question go to the terminal as ONE write, and that # write is the probe: it fails exactly when there is no terminal, and then # there is nobody to wait for. Built first and sent once so a machine with # nobody at it is never told what it "can" do — an offer printed into a CI # log immediately above "there is no terminal here to ask on" reads as a # promise the installer then breaks. # # Wrapped in a brace group so the 2>/dev/null covers the SHELL's own # diagnostic and not merely printf's: the redirection fails before printf # ever runs, and dash reports that itself — `cannot create /dev/tty: No # such device or address`, landing in the middle of an otherwise clean # refusal. Measured under both dash and bash; the branch taken is # identical either way, only the noise differs. if [ -n "$_have" ]; then _q="avengents install: I can install Node 22 for avengents into $NODE_HOME — no root needed. avengents install: Your $_have stays exactly where it is; new shells will find 22 first." else _q="avengents install: I can install Node 22 into $NODE_HOME — no root needed, nothing else on this machine changes." fi if { printf 'avengents install: %s\n%s\navengents install: install Node 22 now? [Y/n] ' \ "$_floor" "$_q" > /dev/tty; } 2>/dev/null; then read -r _ans < /dev/tty || _ans=n else die "$_floor There is no terminal here to ask on, so nothing was installed. Either install Node 22 yourself — https://nodejs.org/en/download — or re-run this and let it do it: curl -fsSL /install.sh | AVENGENTS_INSTALL_NODE=yes sh" fi ;; esac case "$_ans" in ""|[Yy]|[Yy][Ee][Ss]) ;; *) die "$_floor Not installing it, as asked. When you have Node 22, run this again." ;; esac # The floor is deliberately NOT restated here. Every way node_install fails # prints the specific reason first — a wrong checksum, a libc too old, a dist # that lists no build — and repeating "Node 22 is required" underneath it buries # the one line that actually says what went wrong. node_install || die "could not install Node 22 on this machine — the reason is above this line. Install Node 22 yourself and run this again: https://nodejs.org/en/download" } offer_node # --- offer_node end --- command -v npm >/dev/null 2>&1 || die "npm is not installed." # ── fetch ─────────────────────────────────────────────────────────────────── case "$SRC" in http://*|https://*) TMP=$(mktemp -d) # Cleared on ANY exit, including the failure paths below — a half-downloaded # tarball left behind is the kind of thing a retry silently reuses. trap 'rm -rf "$TMP"' EXIT INT TERM TGZ="$TMP/avengents.tgz" echo "avengents install: downloading $SRC" if command -v curl >/dev/null 2>&1; then curl -fsSL "$SRC" -o "$TGZ" || die "download failed: $SRC" elif command -v wget >/dev/null 2>&1; then wget -qO "$TGZ" "$SRC" || die "download failed: $SRC" else die "neither curl nor wget is available." fi # An HTML error page is still a 200 from some hosts, and npm's complaint # about it names a tar error rather than the URL. Check the magic bytes. gzip -t "$TGZ" 2>/dev/null || die "what came back from $SRC is not a gzip tarball. (A login page or a 404 body would look exactly like this to npm.)" ;; *) [ -f "$SRC" ] || die "no such file: $SRC" TGZ="$SRC" ;; esac # ── install ───────────────────────────────────────────────────────────────── # What owned the name BEFORE, so a takeover can be reported as well as a # shadowing. The guard below is one-directional on its own: it catches "this # install is invisible" and says nothing about "this install just replaced # something else", which deserves a line just as much. avengents-server-mbp hit # the second case — npm's bin came first on their PATH, so the TS build silently # won the name from the Python edge and the install reported plain success. PRIOR="$(command -v avengents 2>/dev/null || true)" # Where `npm install -g` will write. On a SYSTEM Node this is a root-owned prefix # (/usr, /usr/local) and a normal user's install dies with EACCES — the whole # reason a locked-down VM cannot `curl | sh`. Probe it by actually WRITING, not by # `id -u`: an nvm user is non-root yet OWNS their prefix, and handing them a second # one would make two installs and trip the shadowing guard below. If it is not # writable, redirect to a per-user prefix. # # Persisted with `npm config set prefix` (writes ~/.npmrc), never a one-shot # `--prefix` or a profile env var: `avengents update` later shells out to a BARE # `npm install -g` (selfUpdate.ts), and ~/.npmrc is the ONLY carrier read by all # three of an interactive shell, a systemd/launchd unit, and that spawned npm — # without it the first self-update would replay this same EACCES. NPM_CONFIG_PREFIX # overrides the default `~/.npm-global` for anyone who wants a different location. GLOBAL_MODULES="$(npm prefix -g 2>/dev/null)/lib/node_modules" PROBE="$GLOBAL_MODULES/.avengents-writetest.$$" USER_PREFIX="" # `touch`, NOT `: > "$PROBE"`: `:` is a POSIX SPECIAL built-in, and a redirection # failure on a special built-in makes a non-interactive shell EXIT (dash does) — # even inside this `if`. So a global prefix that EXISTS but is not writable (the # ordinary system-Node case: /usr/lib/node_modules is present and root-owned) # killed the script AT the probe instead of falling through to the per-user path. # `touch` is a normal command: a write it cannot do just fails, and the `if` takes # the else branch. (The write must actually be ATTEMPTED — `[ -w ]` is not enough, # an existing-but-unwritable dir is exactly this bug.) if mkdir -p "$GLOBAL_MODULES" 2>/dev/null && touch "$PROBE" 2>/dev/null; then rm -f "$PROBE" 2>/dev/null || true echo "avengents install: installing globally" else USER_PREFIX="${NPM_CONFIG_PREFIX:-$HOME/.npm-global}" echo "avengents install: $GLOBAL_MODULES needs root — installing to $USER_PREFIX instead (no root)." echo "avengents install: note — this sets your npm prefix in ~/.npmrc, so future 'npm install -g' and 'avengents update' also use $USER_PREFIX." mkdir -p "$USER_PREFIX/lib/node_modules" || die "could not create $USER_PREFIX" npm config set prefix "$USER_PREFIX" >/dev/null 2>&1 || die "could not set npm prefix to $USER_PREFIX" fi # --ignore-scripts: a poisoned tarball postinstall would be code exec as this user, # run by the AUTO-updater unattended; the package ships prebuilt dist/ with no # install-time script of its own, so ignoring lifecycle scripts costs nothing. npm install -g --ignore-scripts "$TGZ" || die "npm install failed." NPM_BIN="$(npm prefix -g 2>/dev/null)/bin" # npm's global bin goes FIRST on PATH whenever it is not already there — for this # shell (so the check below actually runs, and so it wins over any ~/.local # Python-edge shim) and persisted for the next one. # # The condition here used to be "this run redirected to a per-user prefix", which # is narrower than the thing that matters, and it left a real machine dying on the # very last line: a prefix pinned in ~/.npmrc by an EARLIER install is writable, so # nothing is redirected and USER_PREFIX stays empty — and when the installing shell # does not already carry that bin on PATH (a fresh non-interactive shell, `ssh host # 'curl … | sh'`), the package installs perfectly and the script then reports # "installed, but 'avengents' is not on PATH". Measured on HEAD as well, so it is # not new; it is simply reachable a new way now that the Node section above can put # a runtime in place a moment earlier. case ":$PATH:" in *":$NPM_BIN:"*) ;; *) export PATH="$NPM_BIN:$PATH"; persist_path "$NPM_BIN" ;; esac command -v avengents >/dev/null 2>&1 || die "installed, but 'avengents' is not on PATH. npm's global bin directory is probably not in \$PATH: export PATH=\"$NPM_BIN:\$PATH\"" # THE npm THAT JUST WORKED, written down for the process that cannot find it. # # Same reasoning as update_url a hundred lines below, and the same bill: the # auto-updater runs as a systemd/launchd unit whose PATH is not this shell's. # On an nvm machine that is fatal — npm lives only under ~/.nvm, which reaches # the unit's PATH from nowhere, and not from a non-interactive login shell # either (nvm initialises in .bashrc, which returns on its first line when the # shell is not interactive). tbc-m1 and spark-244c never once self-updated for # exactly this, and the UI's update button could not rescue them because it runs # the same code. # # Deriving npm from the running node fixes the common case and is what the # daemon does when this file is missing. This is better than a derivation: it is # a RECORD, written by the one context that just watched this npm install this # package successfully. Owner's idea, 2026-08-23. # # Best effort. A machine that cannot write it still updates by derivation, so a # failure here is a note, not a death. NPM_USED="$(command -v npm 2>/dev/null || true)" if [ -n "$NPM_USED" ]; then _state_home="${AVENGENTS_HOME:-$HOME/.avengents}" if mkdir -p "$_state_home" 2>/dev/null && printf '%s\n' "$NPM_USED" > "$_state_home/npm_path" 2>/dev/null; then chmod 600 "$_state_home/npm_path" 2>/dev/null || true # decides which npm installs updates echo "avengents install: recorded npm at $NPM_USED for the auto-updater" else echo "avengents install: note — could not record npm's path; auto-update will derive it from the running node instead" fi fi # AUTO-UPDATE IS A POLICY, RECORDED — never a flag that evaporates. # # The unit is RENDERED (supervise.unitText), so a choice living only in the # unit is erased by the next re-render — measured 2026-08-21, twice: a # hand-removed --auto-update came back on reinstall. A choice needs a # persistent home the renderer reads; this marker is that home. Set and # cleared HERE because installing is when the person states it # (`… | AVENGENTS_AUTO_UPDATE=0 sh` — on the sh side of the pipe, same trap # as AVENGENTS_CONVERT above); read by the unit renderer on every render. # `avengents update` by hand keeps working either way. `touch`, not `: >`, # for the dash special-built-in reason documented at the write-probe above. _state_home="${AVENGENTS_HOME:-$HOME/.avengents}" if [ "${AVENGENTS_AUTO_UPDATE:-1}" = "0" ]; then if mkdir -p "$_state_home" 2>/dev/null && touch "$_state_home/auto_update_off" 2>/dev/null; then echo "avengents install: auto-update OFF for this machine (rerun the installer without AVENGENTS_AUTO_UPDATE=0 to turn it back on)" else echo "avengents install: note — could not record the auto-update opt-out; the edge will keep auto-updating" fi else rm -f "$_state_home/auto_update_off" 2>/dev/null || true fi # SHADOWING. A machine that already ran the Python edge has a shim at # ~/.local/bin/avengents, and ~/.local/bin usually comes FIRST on PATH — so npm # installs correctly, `avengents` still resolves to the old one, and the version # printed below would be the Python build's. The install succeeds and changes # nothing anyone can see. Measured on aowl, where exactly this is true. FOUND="$(command -v avengents)" case "$FOUND" in "$NPM_BIN"/*) ;; *) die "installed to $NPM_BIN, but 'avengents' on this PATH resolves to: $FOUND That one wins and this install is invisible. It is probably the Python edge's shim. Remove it, or put npm's bin first: export PATH=\"$NPM_BIN:\$PATH\"" ;; esac if [ -n "$PRIOR" ] && [ "$PRIOR" != "$FOUND" ]; then echo "avengents install: note — 'avengents' previously resolved to $PRIOR" echo "avengents install: it now resolves to $FOUND" fi # Proving it RUNS, not merely that a file landed. The failure this catches is # real: a global install from a directory (rather than a tarball) links back to # that directory, puts avengents on PATH, exits 0, and then dies on first use # with "Cannot find package 'ws'". echo "avengents install: $(avengents version 2>&1 | head -1)" # WHICH CHANNEL THIS MACHINE BELONGS TO — recorded here, in a file. # # Installing from a channel IS choosing it, so this overwrites: a re-install # pointed somewhere else is a conversion, not an accident to protect against # (avg-devenv's ruling, dev/prod separation 2026-08-20). The tarball itself stays # channel-NEUTRAL so the same bytes can be promoted between channels; identity # lives here and in the manifest. # # A FILE and not an exported variable, for the same reason the npm prefix above # is persisted with `npm config set`: `avengents update` runs from an interactive # shell, the auto-updater runs from a systemd/launchd unit that never reads a # login profile, and either may spawn a bare npm. An env-only channel would make # a machine update to one channel BY HAND and another BY DAEMON — and the hand # test, the one a person actually runs, would pass. case "$SRC" in http://*|https://*) CHANNEL_URL="${AVENGENTS_UPDATE_URL:-${SRC%/*}/avengents-update.json}" STATE_HOME="${AVENGENTS_HOME:-$HOME/.avengents}" # WHO THIS MACHINE TALKS TO, from the same three stamped lines: the app base # is the channel's own origin (the tarball is served by the app host), the # router URL and key are the two lines above. The installing shell's # AVENGENTS_BASE_URL / WT_ROUTER_* are deliberately NOT consulted here — # measured on the first dry run: the shell this was tested from still # carried a WT_ROUTER_URL from the tailnet era, and the "override" quietly # gave a dev install a prod router. Installing from channel X is choosing X, # for all of it. A runtime override stays possible where it is visible: an # exported variable when the edge RUNS still beats this file (env > state). CLOUD_BASE="${SRC%/*}" CLOUD_ROUTER="$CHANNEL_ROUTER_URL" CLOUD_PUBKEY="$CHANNEL_ROUTER_PUBKEY" PREV_CHANNEL="$(cat "$STATE_HOME/update_url" 2>/dev/null || true)" PREV_CLOUD="$(cat "$STATE_HOME/cloud.json" 2>/dev/null || true)" NEW_CLOUD="$(printf '{"base_url":"%s","router_url":"%s","router_pubkey":"%s","channel":"%s","source":"install.sh"}' \ "$CLOUD_BASE" "$CLOUD_ROUTER" "$CLOUD_PUBKEY" "$CHANNEL_URL")" if mkdir -p "$STATE_HOME" 2>/dev/null && printf '%s\n' "$CHANNEL_URL" > "$STATE_HOME/update_url" 2>/dev/null && printf '%s\n' "$NEW_CLOUD" > "$STATE_HOME/cloud.json" 2>/dev/null; then # 0600: these redirect WHICH channel the auto-updater pulls from. Written # through a printf redirect above, they inherit the umask (0664 = group- # writable, measured 2026-08-26) so a same-group user could repoint updates. # The edge also self-heals this at every serve start (hardenSensitiveState). chmod 600 "$STATE_HOME/update_url" "$STATE_HOME/cloud.json" 2>/dev/null || true echo "avengents install: channel — $CHANNEL_URL" echo "avengents install: cloud — base $CLOUD_BASE, router $CLOUD_ROUTER, pubkey ${CLOUD_PUBKEY%"${CLOUD_PUBKEY#????????}"}…" # A CONVERSION — this machine followed another channel or cloud a moment # ago. Writing the files is not enough: the supervised edge, if one is # running, still carries the old unit's pinned coordinates until it is # re-rendered, and an installer that says "done" while the machine keeps # talking to the old cloud is the failure this whole step exists for. # `pair --ensure-running` re-renders the unit from the new state (the # renderer no longer pins state-owned keys) and restarts it; it never # waits for a person, and on an unpaired machine it only says so. if { [ -n "$PREV_CHANNEL" ] && [ "$PREV_CHANNEL" != "$CHANNEL_URL" ]; } || { [ -n "$PREV_CLOUD" ] && [ "$PREV_CLOUD" != "$NEW_CLOUD" ]; }; then echo "avengents install: this machine CHANGED channel/cloud — restarting the supervised edge onto it" avengents pair --ensure-running 2>&1 | sed 's/^/avengents install: /' || true fi else echo "avengents install: could not record the channel in $STATE_HOME — updates will use the built-in one" fi ;; *) # A local .tgz names no channel. Leaving any existing record alone is the # honest answer: this install did not choose a channel, so it must not # silently move the machine off the one it is already following. echo "avengents install: installed from a local file — channel unchanged" ;; esac echo "avengents install: done — try 'avengents help'" # --- offer_pairing begin --- # PAIR RIGHT AFTER INSTALLING — owner, 2026-09-13: installing should lead straight # into pairing. Ask "pair now?"; yes enters the pairing flow, no prints how to pair # later. (This file is downloaded and read by the people who run it, so it is kept # in English throughout — owner, 2026-09-13, the same day: the CLI must not mix in # any language but English. tests/englishOnly.test.ts holds that.) # # The question is asked on the TERMINAL (/dev/tty), never on stdin: under # `curl … | sh` stdin IS this script, and a `read` there would swallow the # installer's own next lines. No terminal to write the question to — a CI job, a # Dockerfile, `ssh host 'curl … | sh'` without -t — means nobody to ask: say how # to pair and finish. An installer must never block a pipeline on a question. # # AVENGENTS_PAIR=yes pair without asking AVENGENTS_PAIR=no never ask # # Already paired (identity.json present): not asked — the machine belongs to an # account, and `avengents pair` on it would only re-check and restart the service. # `avengents pair` needs no --base: the CLI seeds its cloud from the state files # written above (seedCloudFromState), so a dev install pairs against dev. # Between the markers on purpose: tests/installPairPrompt.test.ts lifts exactly # this block out and runs it under dash against a stub `avengents`. offer_pairing() { _state="${AVENGENTS_HOME:-$HOME/.avengents}" if [ -s "$_state/identity.json" ]; then echo "avengents install: this machine is already paired — nothing to do (avengents pair re-checks it)" return 0 fi case "${AVENGENTS_PAIR:-ask}" in no) echo "avengents install: pairing skipped (AVENGENTS_PAIR=no). When you are ready, run: avengents pair" return 0 ;; yes) _ans=y ;; *) # The prompt itself is the probe: writing it fails exactly when there is # no terminal, and then there is nobody to wait for. if printf 'avengents install: pair this machine with your account now? [Y/n] ' > /dev/tty 2>/dev/null; then read -r _ans < /dev/tty || _ans=n else echo "avengents install: no terminal to ask on. When you are ready, run: avengents pair" return 0 fi ;; esac case "$_ans" in ""|[Yy]|[Yy][Ee][Ss]) echo "avengents install: starting pairing — in the app: + → Add a new machine → scan the square, or type the code" exec avengents pair ;; *) echo "avengents install: not now. When you are ready, run: avengents pair" echo "avengents install: then, in the app: + → Add a new machine → scan the square it prints, or type the code" ;; esac } offer_pairing # --- offer_pairing end ---