#!/bin/sh # +Ai Node installer — macOS and Linux. # # curl -fsSL https://node.add.ai/install | sh # curl -fsSL https://node.add.ai/install | sh -s -- --code ABCDE --autostart # # Everything lands under ~/.ainode. The node gets its OWN Node runtime and its # OWN npm prefix, so whatever Node the machine already has — or doesn't — is # left exactly as it was, and no step needs root. That is not tidiness for its # own sake: a daemon started by launchd at login gets a minimal PATH and never # sources a shell rc file, so a runtime that can only be found through nvm or a # .zshrc is a runtime the supervised node cannot start. # # Re-running this is the upgrade path. Every step is idempotent. # # POSIX sh, not bash: `curl | sh` on Ubuntu runs dash. No arrays, no [[ ]], no # `set -o pipefail`. The whole body is functions with `main "$@"` on the last # line, so a download truncated mid-flight defines some functions and then # reaches EOF without ever calling one. set -eu # ── configuration ─────────────────────────────────────────────────────────── BASE_URL="${AINODE_INSTALL_BASE:-https://node.add.ai}" AINODE_HOME="${AINODE_HOME:-$HOME/.ainode}" NODE_DIR="$AINODE_HOME/node" TOOLS_DIR="$AINODE_HOME/tools" LAUNCHER="$AINODE_HOME/bin/ainode" LOG_FILE="$AINODE_HOME/install.log" DAEMON_LOG="$AINODE_HOME/daemon.log" JSON=0 PAIR_CODE="" AUTOSTART=0 DO_START=1 PKG_PIN="" OS="" ARCH="" NODE_VERSION="" NODE_URL="" NODE_ARCHIVE="" NODE_SHA256="" PKG_VERSION="latest" NODE_BIN="" CLI_JS="" DAEMON_PID="" SUPERVISED=0 STEP="install" PCT=0 usage() { cat <<'USAGE' +Ai Node installer curl -fsSL https://node.add.ai/install | sh curl -fsSL https://node.add.ai/install | sh -s -- [options] Options --code CODE connect to +Ai with a code from the connect page --autostart start this node whenever you log in --no-start install only; do not launch anything --json emit NDJSON progress on stdout instead of prose --pkg-version SPEC install a specific @addai/node version (default: latest) -h, --help this Everything is installed under ~/.ainode and nothing needs root. USAGE } # ── reporting ─────────────────────────────────────────────────────────────── # One report stream on stdout, whose FORMAT the caller chooses: NDJSON with # --json, prose without. Everything else — curl's noise, tar's complaints, npm's # output, our own diagnostics — goes to stderr or the log, so a caller parsing # stdout never has to filter anything out of it. json_escape() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' } emit() { # step state pct message STEP="$1" PCT="$3" if [ "$JSON" -eq 1 ]; then printf '{"step":"%s","state":"%s","pct":%s,"msg":"%s"}\n' \ "$1" "$2" "$3" "$(json_escape "$4")" else case "$2" in error) printf '[!!] %s\n' "$4" ;; skip) printf '[--] %s\n' "$4" ;; done) printf '[ok] %s\n' "$4" ;; *) printf '[..] %s\n' "$4" ;; esac fi } die() { emit "$STEP" error "$PCT" "$1" printf '%s\n' "$1" >&2 exit 1 } have() { command -v "$1" >/dev/null 2>&1; } # ── network + hashing ─────────────────────────────────────────────────────── fetch_text() { # url if have curl; then curl -fsSL --retry 3 --retry-delay 1 "$1" elif have wget; then wget -qO- "$1" else return 127 fi } # Fetch a body EVEN IF the status is an error. `curl -f` throws the body away, # and for our own endpoints the body is where the reason lives — "nodejs.org # publishes no armv7l build for this release" is a far better failure than # "could not reach", which is what -f alone leaves us able to say. fetch_reason() { # url if have curl; then curl -sSL "$1" 2>/dev/null | head -3 elif have wget; then wget -q --content-on-error -O- "$1" 2>/dev/null | head -3 fi } download() { # url dest if have curl; then curl -fsSL --retry 3 --retry-delay 1 -o "$2" "$1" >&2 elif have wget; then wget -q -O "$2" "$1" >&2 else return 127 fi } sha256_of() { # file if have sha256sum; then sha256sum "$1" | cut -d' ' -f1 elif have shasum; then shasum -a 256 "$1" | cut -d' ' -f1 elif have openssl; then openssl dgst -sha256 "$1" | sed 's/.*= *//' else printf '' fi } # ── steps ─────────────────────────────────────────────────────────────────── parse_args() { while [ $# -gt 0 ]; do case "$1" in --json) JSON=1 ;; --autostart) AUTOSTART=1 ;; --no-start) DO_START=0 ;; --code) shift; PAIR_CODE="${1:-}" ;; --code=*) PAIR_CODE="${1#--code=}" ;; --pkg-version) shift; PKG_PIN="${1:-}" ;; --pkg-version=*) PKG_PIN="${1#--pkg-version=}" ;; -h|--help) usage; exit 0 ;; *) die "unknown option: $1 (try --help)" ;; esac shift done # Validated, not trusted. The version rule is the one self-update.ts already # enforces on a remote roll, and for the same reason: npm accepts a great # deal more than a version after the @ — a tarball URL, a git ref, a file # path — each of which would install code of someone else's choosing. if [ -n "$PKG_PIN" ]; then if ! printf '%s' "$PKG_PIN" | grep -Eq '^([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?|[a-z][a-z0-9-]{0,31})$'; then die "--pkg-version must be a version or a dist-tag, not '$PKG_PIN'" fi fi if [ -n "$PAIR_CODE" ]; then if ! printf '%s' "$PAIR_CODE" | grep -Eq '^[A-Za-z0-9]{4,12}$'; then die "--code should be the short code from the connect page, not '$PAIR_CODE'" fi fi } # Node publishes no musl builds. A glibc tarball unpacks perfectly happily on # Alpine and then dies at the first exec, so refusing here — with the command # that does work — beats a broken install that looks like a successful one. is_glibc() { if [ -f /etc/alpine-release ]; then return 1; fi if have ldd; then if ldd --version 2>&1 | grep -qi musl; then return 1; fi fi return 0 } detect_platform() { emit detect start 2 'Checking this machine' sys="$(uname -s)" cpu="$(uname -m)" case "$sys" in Darwin) OS=darwin ;; Linux) OS=linux ;; *) die "unsupported operating system: $sys" ;; esac case "$cpu" in x86_64|amd64) ARCH=x64 ;; arm64|aarch64) ARCH=arm64 ;; armv7l|armv7) ARCH=armv7l ;; *) die "unsupported CPU architecture: $cpu" ;; esac if [ "$OS" = linux ] && ! is_glibc; then die "this looks like a musl system (Alpine). nodejs.org publishes no musl builds — install Node from your distro instead, then: npm i -g @addai/node" fi have tar || die "tar is required and was not found" emit detect done 5 "$OS/$ARCH" } read_manifest() { emit manifest start 6 'Asking node.add.ai which Node to install' url="$BASE_URL/manifest.sh?os=$OS&arch=$ARCH" if ! body="$(fetch_text "$url")"; then reason="$(fetch_reason "$url")" if [ -n "$reason" ]; then die "$reason"; fi die "could not reach $url (no curl or wget?)" fi # Read against an allowlist rather than eval'ing. The body comes off the # network, and a KEY=value reader gives it no code path to take even if the # host serving it were ever not ours. while IFS='=' read -r key value; do case "$key" in AINODE_NODE_VERSION) NODE_VERSION="$value" ;; AINODE_NODE_URL) NODE_URL="$value" ;; AINODE_NODE_ARCHIVE) NODE_ARCHIVE="$value" ;; AINODE_NODE_SHA256) NODE_SHA256="$value" ;; AINODE_PKG_VERSION) PKG_VERSION="$value" ;; *) ;; esac done </dev/null || true)" fi if [ "$current" = "$NODE_VERSION" ]; then emit node skip 55 "Node ${NODE_VERSION#v} already installed" return 0 fi emit node start 10 "Installing Node ${NODE_VERSION#v}" tmp="$AINODE_HOME/.install.$$" rm -rf "$tmp" mkdir -p "$tmp" archive="$tmp/$NODE_ARCHIVE" download "$NODE_URL" "$archive" || die "could not download $NODE_URL" emit node progress 35 'Verifying download' got="$(sha256_of "$archive")" [ -n "$got" ] || die "no SHA-256 tool found — install coreutils (sha256sum) or openssl" if [ "$got" != "$NODE_SHA256" ]; then rm -rf "$tmp" die "checksum mismatch for $NODE_ARCHIVE — refusing to install" fi emit node progress 45 'Unpacking' tar -xzf "$archive" -C "$tmp" || die "could not unpack $NODE_ARCHIVE" # Positional args are free by now: parse_args ran first. set -- "$tmp"/node-v*/ inner="${1%/}" [ -d "$inner" ] || die "unexpected archive layout in $NODE_ARCHIVE" # Swap rather than overwrite, so an interrupted unpack can never leave a # half-written runtime in place of a working one. rm -rf "$NODE_DIR.old" if [ -d "$NODE_DIR" ]; then mv "$NODE_DIR" "$NODE_DIR.old"; fi mv "$inner" "$NODE_DIR" rm -rf "$NODE_DIR.old" "$tmp" [ -x "$NODE_BIN" ] || die "Node did not land at $NODE_BIN" emit node done 55 "Node ${NODE_VERSION#v} ready" } # npm's BUILTIN config — the lowest-precedence file, and the only one every # later `npm i -g` is guaranteed to read, including the ones the daemon runs # itself for self-update and for installing harness CLIs. Without it those # would land inside NODE_DIR and be thrown away by the next Node upgrade. configure_npm() { npmrc="$NODE_DIR/lib/node_modules/npm/npmrc" mkdir -p "$(dirname "$npmrc")" printf 'prefix=%s\n' "$TOOLS_DIR" > "$npmrc" } ensure_package() { emit package start 58 "Installing @addai/node@$PKG_VERSION" npm_cli="$NODE_DIR/lib/node_modules/npm/bin/npm-cli.js" [ -f "$npm_cli" ] || die "the Node we installed has no npm at $npm_cli" mkdir -p "$TOOLS_DIR" # Run npm as `node npm-cli.js`, never through the npm shim: the shim starts # `#!/usr/bin/env node`, and there is deliberately no node on PATH here. if ! "$NODE_BIN" "$npm_cli" install -g --prefix "$TOOLS_DIR" \ "@addai/node@$PKG_VERSION" >>"$LOG_FILE" 2>&1; then die "npm could not install @addai/node — see $LOG_FILE" fi CLI_JS="$TOOLS_DIR/lib/node_modules/@addai/node/dist/cli.js" [ -f "$CLI_JS" ] || die "@addai/node installed but $CLI_JS is missing" version="$(node_run version 2>/dev/null || true)" emit package done 82 "+Ai Node ${version:-$PKG_VERSION} installed" } # Every invocation of the node goes through here, so the PATH it inherits is # decided in exactly one place. TOOLS_DIR/bin is on it because that is where # the daemon installs harness CLIs, and its discovery probes shell out to # `command -v`; NODE_DIR/bin because the daemon shells out to `npm`. node_run() { PATH="$NODE_DIR/bin:$TOOLS_DIR/bin:$PATH" "$NODE_BIN" "$CLI_JS" "$@" } start_detached() { mkdir -p "$AINODE_HOME" # stdin comes from /dev/null on purpose. Under `curl | sh` this script's # stdin IS the download, and a daemon that outlives us must not be holding # the other end of it. PATH="$NODE_DIR/bin:$TOOLS_DIR/bin:$PATH" AINODE_NO_TUI=1 \ nohup "$NODE_BIN" "$CLI_JS" "$@" >"$DAEMON_LOG" 2>&1 & DAEMON_PID=$! } write_launcher() { mkdir -p "$AINODE_HOME/bin" cat > "$LAUNCHER" <` pairs and then BECOMES the running node, so it is # started detached and watched for its state file rather than waited on. start_detached entities "$PAIR_CODE" i=0 while [ "$i" -lt 90 ]; do if [ -f "$AINODE_HOME/state.json" ]; then emit pair done 90 'Connected' return 0 fi if ! kill -0 "$DAEMON_PID" 2>/dev/null; then DAEMON_PID="" die "the node stopped before it finished connecting — see $DAEMON_LOG" fi sleep 1 i=$((i + 1)) done die "connecting timed out — the code may have expired. See $DAEMON_LOG" } maybe_autostart() { if [ "$AUTOSTART" -ne 1 ]; then emit autostart skip 96 'Not starting at login — pass --autostart to change that' return 0 fi if [ "$OS" = linux ]; then # Said plainly rather than attempted and silently dropped: autostart.ts # dispatches to launchd and Task Scheduler only, and returns # supported:false for everything else. emit autostart skip 96 'Starting at login is not supported on Linux yet — systemd support is coming' return 0 fi emit autostart start 92 'Registering this node to start when you log in' # launchd supervises with KeepAlive, so it has to be the process holding the # daemon. Stand ours down first or the two race for the lockfile and launchd # flaps retrying a job that keeps refusing to start. if [ -n "$DAEMON_PID" ] && kill -0 "$DAEMON_PID" 2>/dev/null; then kill "$DAEMON_PID" 2>/dev/null || true wait "$DAEMON_PID" 2>/dev/null || true DAEMON_PID="" fi if node_run startup enable >>"$LOG_FILE" 2>&1; then SUPERVISED=1 emit autostart done 96 'This node now starts when you log in' else # Never leave the machine with no node running because an optional extra # failed. Put the one we stood down back up. emit autostart error 96 "could not register the login item — see $LOG_FILE" if [ "$DO_START" -eq 1 ] && [ -f "$AINODE_HOME/state.json" ]; then start_detached run fi fi } maybe_start() { if [ "$DO_START" -ne 1 ] || [ "$SUPERVISED" -eq 1 ]; then return 0; fi if [ -n "$DAEMON_PID" ] && kill -0 "$DAEMON_PID" 2>/dev/null; then return 0; fi if [ ! -f "$AINODE_HOME/state.json" ]; then return 0; fi start_detached run } finish() { emit done done 100 'Installed' if [ "$JSON" -eq 1 ]; then return 0; fi printf '\n' printf ' node runtime %s\n' "$NODE_DIR" printf ' +Ai Node %s\n' "$TOOLS_DIR" printf ' command %s\n' "$LAUNCHER" printf ' logs %s\n' "$DAEMON_LOG" printf '\n' if [ ! -f "$AINODE_HOME/state.json" ]; then printf ' Connect this machine to +Ai:\n\n' printf ' %s\n\n' "$LAUNCHER" fi printf ' Add it to your PATH with:\n\n' printf ' export PATH="%s:$PATH"\n\n' "$AINODE_HOME/bin" } main() { parse_args "$@" mkdir -p "$AINODE_HOME" detect_platform read_manifest ensure_node configure_npm ensure_package write_launcher maybe_pair maybe_autostart maybe_start finish } main "$@"