#!/usr/bin/env bash # SPDX-License-Identifier: MIT # Copyright (c) 2026 Svayam Infoware Pvt. Ltd. # # gov bootstrap installer — macOS and Linux. # # curl -fsSL -o install.sh && bash install.sh # the URL is one constant, defined below # # Fetch, then run. `curl -fsSL | bash` hides a failed download: curl writes nothing, bash # runs an empty script and exits 0, and the pipeline reports success (docs/installing.md). # # WHY THIS EXISTS. `gov` runs on Node 24, so it cannot install Node 24 — the whole # class of first-run failure happens before `gov` exists to help. Three of them, # all reported by real adopters on their first command (#186): # # 1. `npm WARN EBADENGINE` on Node 16 — a warning, not a gate. npm installs # anyway and `gov` fails later, far from the cause. # 2. `EACCES … mkdir '/usr/local/lib/node_modules'` — the global npm prefix is # a system directory the user cannot write. # 3. On RHEL 9, `dnf install nodejs` refuses: the distro's Node 16 is a module # stream that `npm` depends on, so the two cannot coexist. # # All three vanish under one decision: DO NOT TOUCH THE SYSTEM. Node is fetched # from nodejs.org as a tarball and unpacked under the user's home directory. No # package manager, no sudo, no conflict with whatever the distro shipped. # # DEPENDENCIES ARE DELIBERATELY curl + tar. A version manager (fnm, nvm) would be # the idiomatic choice, but fnm's own installer needs `unzip`, which minimal RHEL # and Debian images do not have — reintroducing exactly the "install this first" # problem this script exists to remove. `curl` and `tar` are present everywhere # this script can plausibly run. set -euo pipefail NODE_MAJOR=24 # Overridable so a pre-release build can be tested through the SAME path an adopter # takes, rather than through a different one that proves less: GOV_PKG=/path/to.tgz # or GOV_PKG='@svayam-opensource/gov@next'. GOV_PKG="${GOV_PKG:-@svayam-opensource/gov@1.2.3}" GOV_HOME="${GOV_INSTALL_DIR:-$HOME/.local/share/gov}" NODE_DIR="$GOV_HOME/node" # A NODE ARCHIVE ALREADY ON DISK, for a machine that cannot reach nodejs.org. # # Air-gapped and proxied networks are the real case: today this script dies with "check your # network or proxy" and there is nothing the adopter can do about it except give up. Point this # at a node-v24.*-.tar.gz they brought with them and the install completes offline. # # DELIBERATELY A FILE PATH, NEVER A URL (#201). An env var that could redirect where a runtime # is fetched FROM is a supply-chain surface; one that can only name a file already on this # machine is not — the archive is something the person already has and chose. It is checked for # existence, announced on screen so it is never a silent substitution, and the unpacked result # still has to run `node -v` before anything is claimed. GOV_NODE_TARBALL="${GOV_NODE_TARBALL:-}" # WHERE THIS SCRIPT IS SERVED FROM — one constant, because it is printed back to the adopter # and a URL living in two places drifts. # # NOT FINAL. Two things are wrong with it and only one is cosmetic: # · it pins `main`, so an adopter gets whatever is on main at that instant, including a # half-merged change. A tag or a release branch belongs here (#201 discipline applies to # our own artefact, not only to vendors'). # · raw.githubusercontent.com is not an address anyone can say out loud or type from memory. # A short vanity host redirecting to a TAGGED raw URL fixes both without new infrastructure. GOV_INSTALL_URL="${GOV_INSTALL_URL:-https://gov.svayamtech.com/install.sh}" # ── output ──────────────────────────────────────────────────────────────────── # TERM=dumb is the terminal saying what NO_COLOR says on the person's behalf (#204). Both are # obeyed; neither is a preference to override. if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != "dumb" ]; then B=$'\033[1m'; DIM=$'\033[2m'; GRN=$'\033[32m'; YEL=$'\033[33m'; RED=$'\033[31m'; CYA=$'\033[36m'; RST=$'\033[0m' else B=""; DIM=""; GRN=""; YEL=""; RED=""; CYA=""; RST="" fi # Display a path with $HOME shortened to ~. Written as a function because # "${p/#$HOME/\~}" keeps the backslash in bash and prints a literal \~. tilde() { case "$1" in "$HOME"/*) printf '~%s' "${1#"$HOME"}" ;; *) printf '%s' "$1" ;; esac; } say() { printf '%s\n' "$*"; } # A NAMED PHASE, SET OFF BY BLANK LINES (#204). It was `==> title` inline, in output dense # enough that the five-minute browser sign-in arrived with nothing before it — which is how # the PATH reminder went unread in #186. The blank lines belong to the phase, so no caller # has to remember them. step() { printf '\n%s%s%s\n\n' "$B" "$*" "$RST"; } # TWO MARKS, TWO MEANINGS. The arrow is under way or informational; the tick is a fact that is # now true. One mark doing both jobs gave the eye nothing to sort by. info() { printf ' %s→%s %s\n' "$CYA" "$RST" "$*"; } ok() { printf ' %s✓%s %s\n' "$GRN" "$RST" "$*"; } skip() { printf ' %s·%s %s %s(already present)%s\n' "$DIM" "$RST" "$*" "$DIM" "$RST"; } warn() { printf ' %s!%s %s\n' "$YEL" "$RST" "$*"; } die() { printf '\n%serror:%s %s\n' "$RED" "$RST" "$*" >&2; exit 1; } # SHA-256 OF A FILE, on whatever this machine happens to have. # # Three spellings because there is no one command: coreutils gives `sha256sum` (every Linux image # gov supports), macOS gives `shasum` and no sha256sum, and `openssl` covers the rest. Returns # non-zero if none exist, and the caller treats that as a hard failure rather than as "unverified" — # a check that quietly does not run is worse than no check, because the output still looks clean. sha256_of() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}' else return 1 fi } # Run something slow with a spinner, so silence never looks like a hang. # # A tester watched `npm install -g` for half a minute with nothing on screen and # wondered whether to press Ctrl-C. Silence is indistinguishable from a stall, and # a person who cannot tell the difference will eventually guess wrong — the one # outcome an installer must not invite. Output is captured and shown only on # failure, so the spinner is not fighting a wall of npm text. spin() { local msg="$1"; shift local log; log="$(mktemp)" local rc=0 if [ ! -t 1 ]; then # no terminal: no animation, just say it printf ' %s→%s %s… ' "$CYA" "$RST" "$msg" # `|| rc=$?` matters under `set -e`: a bare failing command would end the whole # script HERE, silently, with the log still unread — the reader sees the prompt # come back and nothing else. "$@" >"$log" 2>&1 || rc=$? if [ $rc -eq 0 ]; then printf 'done\n'; rm -f "$log"; return 0; fi printf 'failed\n'; cat "$log" >&2; rm -f "$log"; return $rc fi "$@" >"$log" 2>&1 & local pid=$! i=0 t0 secs t0=$(date +%s) local frames='|/-\' while kill -0 "$pid" 2>/dev/null; do secs=$(( $(date +%s) - t0 )) # The elapsed seconds are the point, not the spinner. A spinner says "a program # is running"; a rising count says "it has been running for 12 seconds, and this # is normal" — which is what a person weighing Ctrl-C actually needs to know. printf '\r %s %s (%ss) ' "${frames:i++%4:1}" "$msg" "$secs" sleep 0.2 done wait "$pid" || rc=$? secs=$(( $(date +%s) - t0 )) if [ $rc -eq 0 ]; then printf '\r %s✓%s %s (%ss)%s\n' "$GRN" "$RST" "$msg" "$secs" " "; rm -f "$log"; return 0 fi printf '\r %s✗%s %s%s\n' "$RED" "$RST" "$msg" " "; cat "$log" >&2; rm -f "$log"; return $rc } # Is there a terminal we can ASK on? Testing `-e /dev/tty` is not enough: inside a # container without a controlling terminal the path exists and opening it still # fails with "No such device or address". Open it and see. have_tty() { (exec 3/dev/null; } # Ask a yes/no question on the controlling terminal, defaulting to yes. # # Reads /dev/tty, not stdin: under `curl … | bash` this script IS stdin, so a # `read` there would swallow the rest of the script rather than the answer. # With no terminal (CI, a provisioning run) it proceeds — someone who invoked an # installer non-interactively has already answered — but it says so. confirm() { local q="$1" ans if [ "${GOV_YES:-}" = "1" ]; then return 0; fi if ! have_tty; then say " ${DIM}(no terminal to ask on — continuing)${RST}" return 0 fi printf ' %s [Y/n] : ' "$q" > /dev/tty read -r ans < /dev/tty || ans="" case "$ans" in [nN]|[nN][oO]) return 1 ;; *) return 0 ;; esac } # Is there a terminal we can ASK on? Testing `-e /dev/tty` is not enough: inside a # container without a controlling terminal the path exists and opening it still # fails with "No such device or address". Open it and see. # ── platform ────────────────────────────────────────────────────────────────── detect_platform() { local os arch case "$(uname -s)" in Linux) os=linux ;; Darwin) os=darwin ;; *) die "unsupported operating system: $(uname -s). On Windows, use install.ps1 in PowerShell." ;; esac case "$(uname -m)" in x86_64|amd64) arch=x64 ;; arm64|aarch64) arch=arm64 ;; *) die "unsupported CPU architecture: $(uname -m). Node 24 is published for x64 and arm64 only." ;; esac printf '%s-%s' "$os" "$arch" } need() { command -v "$1" >/dev/null 2>&1; } # Node's own major version, or 0 when absent/unreadable. node_major() { need node || { echo 0; return; } node -e 'process.stdout.write(String(process.versions.node.split(".")[0]))' 2>/dev/null || echo 0 } # ── the shell profile we append PATH to ─────────────────────────────────────── profile_file() { case "$(basename "${SHELL:-/bin/bash}")" in zsh) printf '%s/.zshrc' "$HOME" ;; bash) if [ "$(uname -s)" = "Darwin" ]; then printf '%s/.bash_profile' "$HOME"; else printf '%s/.bashrc' "$HOME"; fi ;; *) printf '%s/.profile' "$HOME" ;; esac } MARKER="# added by the gov installer" # Is this directory ALREADY on the running shell's PATH? on_path() { case ":$PATH:" in *":$1:"*) return 0 ;; *) return 1 ;; esac; } # A child process cannot change its parent shell's PATH — that is an operating # system rule, not an oversight, and it is why every installer ends by telling you # to open a new terminal. # # But it can put the command somewhere the parent shell is ALREADY looking. # ~/.local/bin is on PATH by default on Fedora, RHEL, Rocky and most Debian # derivatives. When it is, a symlink there means `gov` works in the shell you are # standing in, with nothing to source and nothing to reopen. # Append the PATH line to the user's shell profile, once. add_to_path() { local dir="$1" prof; prof="$(profile_file)" touch "$prof" if grep -Fq "$dir" "$prof" 2>/dev/null; then skip "PATH entry in $(tilde "$prof")" else { printf '\n%s\n' "$MARKER"; printf 'export PATH="%s:$PATH"\n' "$dir"; } >> "$prof" ok "added to PATH in $(tilde "$prof")" fi PROFILE_TOUCHED="$prof" } link_into_path() { local target="$1" dir="$HOME/.local/bin" on_path "$dir" || return 1 mkdir -p "$dir" || return 1 # A SYMLINK IS NOT ENOUGH, and claiming otherwise is worse than saying nothing. # The `gov` npm ships is a script whose shebang is `#!/usr/bin/env node`. Link it # somewhere on PATH and the shell finds `gov` — then fails with # `env: 'node': No such file or directory`, because Node lives in the directory we # just added to a profile the running shell has not read. Found but unrunnable is # a worse answer than not found. # # So: a two-line wrapper that puts Node on PATH for its own invocation and hands # over. Self-contained, no sourcing, and it keeps working after the profile is # read because prepending an already-present directory changes nothing. { printf '#!/bin/sh\n' printf '%s\n' "$MARKER" printf 'PATH="%s:$PATH"; export PATH\n' "$NODE_DIR/bin" printf 'exec "%s" "$@"\n' "$target" } > "$dir/gov" || return 1 chmod +x "$dir/gov" || return 1 # Prove it, rather than announce it. If the wrapper cannot run, the old # open-a-new-terminal message is the honest ending. if ! "$dir/gov" --version >/dev/null 2>&1; then rm -f "$dir/gov" return 1 fi IMMEDIATELY_USABLE=1 ok "linked into $(tilde "$dir"), which is already on your PATH" return 0 } # ── steps ───────────────────────────────────────────────────────────────────── install_node() { local plat="$1" have; have="$(node_major)" if [ "$have" -ge "$NODE_MAJOR" ] 2>/dev/null; then skip "Node $(node -v)" return fi if [ -x "$NODE_DIR/bin/node" ]; then local mine; mine="$("$NODE_DIR/bin/node" -v 2>/dev/null || echo "")" if [ -n "$mine" ]; then skip "Node $mine (installed here previously)" export PATH="$NODE_DIR/bin:$PATH" return fi fi if [ "$have" -gt 0 ]; then warn "Node v$have is installed and too old — leaving it alone and installing Node $NODE_MAJOR alongside it" fi need curl || die "curl is required to download Node. Install curl, then re-run this script." need tar || die "tar is required to unpack Node. Install tar, then re-run this script." # ASK, even though nothing outside this user's home is touched. # # It is a 50 MB download and a new directory in someone's home folder. That it is # reversible with one `rm -rf` is a reason the answer is usually yes, not a reason # to skip the question — and a person who has just piped a script from the # internet into their shell is owed the chance to see what it intends before it # does it. say "" if [ "$have" -gt 0 ]; then say " ${B}Step 1 — Node $NODE_MAJOR or newer is required${RST}, and this machine has v$have." say " gov will install Node $NODE_MAJOR into $(tilde "$NODE_DIR") and leave your" say " existing Node exactly where it is. About 50 MB. No sudo, nothing system-wide." else say " ${B}Step 1 — Node $NODE_MAJOR is required and is not installed.${RST}" say " gov will install it into $(tilde "$NODE_DIR") — about 50 MB." say " Nothing outside your home folder is touched, and sudo is never used." fi say "" confirm "Install Node $NODE_MAJOR?" || die "Stopped at your request. Nothing was installed. If you would rather install Node $NODE_MAJOR yourself, do that and re-run this script — it will skip this step." step "Installing Node $NODE_MAJOR for $plat" local listing file url tmp tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' RETURN if [ -n "$GOV_NODE_TARBALL" ]; then # Named a file that is not there? Say so and stop. Falling back to the network would be a # silent substitution of the thing the adopter explicitly asked for, and on an air-gapped # machine it would fail a second later with a misleading message about the network. [ -f "$GOV_NODE_TARBALL" ] \ || die "GOV_NODE_TARBALL is set but there is no file there: $GOV_NODE_TARBALL Point it at a node-v${NODE_MAJOR}.*-${plat}.tar.gz, or unset it to download from nodejs.org." info "using the Node archive you provided, not downloading: $GOV_NODE_TARBALL" cp "$GOV_NODE_TARBALL" "$tmp/node.tar.gz" \ || die "could not read $GOV_NODE_TARBALL — check the path and its permissions" # AN ARCHIVE YOU SUPPLIED IS NOT CHECKED AGAINST THE NETWORK, and the output says which. # # The reason GOV_NODE_TARBALL exists is a machine that cannot reach nodejs.org, so fetching # SHASUMS256.txt to verify it would defeat the point and fail on exactly the machines that need # it. Set GOV_NODE_SHA256 to have it checked; leave it unset and the hash is PRINTED rather than # silently skipped, so it can be compared by hand and so the output never implies a check that # did not happen. supplied_sha="$(sha256_of "$tmp/node.tar.gz")" \ || die "no sha256 tool on this machine (looked for sha256sum, shasum, openssl)." if [ -n "${GOV_NODE_SHA256:-}" ]; then [ "$supplied_sha" = "$GOV_NODE_SHA256" ] || die "CHECKSUM MISMATCH on the archive you supplied. expected $GOV_NODE_SHA256 actual $supplied_sha Nothing was unpacked." ok "checksum verified against GOV_NODE_SHA256: ${supplied_sha}" else warn "not verified — no GOV_NODE_SHA256 given. sha256 is ${supplied_sha}" fi else # RETRY BEFORE BLAMING THE NETWORK. # # Both of these used to fail on the first blip and say "check your network or proxy" — bad # advice for a transient 429 or a dropped TLS handshake, and it aborts an install that would # have worked a second later. nodejs.org rate-limits repeated fetches, which is ordinary on a # shared or NAT'd connection and reliable in CI: the OS tier hit it four times in one run. # # `--retry-all-errors` is the part that matters — plain `--retry` ignores connection failures # and 4xx, which is most of what actually happens here. spin "asking nodejs.org which version is current" \ bash -c "curl -fsSL --retry 4 --retry-delay 2 --retry-all-errors 'https://nodejs.org/dist/latest-v${NODE_MAJOR}.x/' -o '$tmp/listing.html'" \ || die "could not reach nodejs.org after several tries — check your network or proxy" listing="$(cat "$tmp/listing.html")" # .tar.gz, not .tar.xz: minimal RHEL and Debian images ship tar without the xz # helper binary, and the failure is an opaque "xz: Cannot exec". gzip is built # into every tar that can run here. The extra few megabytes are worth it. file="$(printf '%s' "$listing" | grep -o "node-v${NODE_MAJOR}\.[0-9.]*-${plat}\.tar\.gz" | head -1)" [ -n "$file" ] || die "no Node $NODE_MAJOR build published for $plat" url="https://nodejs.org/dist/latest-v${NODE_MAJOR}.x/$file" info "downloading ${file} (about 50 MB)" curl -fSL --progress-bar --retry 4 --retry-delay 2 --retry-all-errors "$url" -o "$tmp/node.tar.gz" \ || die "download failed after several tries: $url" # VERIFY WHAT IS ABOUT TO BE UNPACKED AND RUN (#205). # # This script downloads a 50 MB archive over the network and then executes what is inside it. # nodejs.org publishes SHASUMS256.txt beside every release for exactly this, and until now it # was not read. Fetched from the same directory as the archive, so it pins the file we actually # took rather than some other build of the same version. # # NOT a supply-chain proof on its own: whoever could substitute the archive could substitute the # checksum file with it. It does catch the case that actually happens — a truncated or corrupted # download, a proxy that served something else, a mirror that is stale — and it is the # precondition for the signature check that comes next (SHASUMS256.txt.asc, which needs the Node # release keyring; deliberately a separate step). spin "verifying the download against nodejs.org's checksums" \ bash -c "curl -fsSL --retry 4 --retry-delay 2 --retry-all-errors 'https://nodejs.org/dist/latest-v${NODE_MAJOR}.x/SHASUMS256.txt' -o '$tmp/SHASUMS256.txt'" \ || die "could not fetch nodejs.org's checksums after several tries. The Node archive downloaded but cannot be verified, so it will not be unpacked." expected="$(awk -v f="$file" '$2 == f { print $1; exit }' "$tmp/SHASUMS256.txt")" [ -n "$expected" ] || die "nodejs.org's SHASUMS256.txt does not list ${file}. Refusing to unpack an archive that cannot be checked." actual="$(sha256_of "$tmp/node.tar.gz")" \ || die "no sha256 tool on this machine (looked for sha256sum, shasum, openssl). Install one of those and run this again — the download will not be unpacked unverified." if [ "$actual" != "$expected" ]; then rm -f "$tmp/node.tar.gz" die "CHECKSUM MISMATCH on ${file} — the download has been deleted, nothing was unpacked. expected $expected actual $actual A corrupted or truncated download is the usual cause; run this again. If it repeats, something between you and nodejs.org is altering the file and that is worth investigating before retrying." fi ok "checksum verified: ${actual}" fi rm -rf "$NODE_DIR"; mkdir -p "$NODE_DIR" spin "unpacking into $(tilde "$NODE_DIR")" \ tar -xzf "$tmp/node.tar.gz" -C "$NODE_DIR" --strip-components=1 \ || die "could not unpack the Node archive — see the error above" # PROVE IT RUNS BEFORE CLAIMING IT — and before touching the profile. # # Newly reachable. Until GOV_NODE_TARBALL existed the archive always came from nodejs.org for # the platform this script had just detected, so "unpacked but will not run" was not a real # case. An adopter can now hand over an archive built for another architecture, and the old # line — `ok "Node $(node -v)"` — failed inside a command substitution and said nothing about # why. The tree is removed rather than left half-installed, and no PATH entry is added for a # Node that does not work: the same rule the agent wrapper follows. local ver if ! ver="$("$NODE_DIR/bin/node" -v 2>/dev/null)" || [ -z "$ver" ]; then rm -rf "$NODE_DIR" die "the archive unpacked, but the node inside it does not run on this machine. This machine is ${plat}. If you supplied the archive yourself it is most likely built for a different platform — use a node-v${NODE_MAJOR}.*-${plat}.tar.gz, or unset GOV_NODE_TARBALL to let this script download the right one." fi export PATH="$NODE_DIR/bin:$PATH" add_to_path "$NODE_DIR/bin" ok "Node $ver" say "===> 1. [✓] Install Node version 24" } install_gov() { say ""; say "$RULE" step "Installing the governance client — $GOV_PKG" need npm || die "npm did not come with Node — the install is incomplete. Remove $(tilde "$NODE_DIR") and re-run." # If we are using a Node we did NOT install, its global prefix may be a system # directory — the EACCES failure. Redirect the prefix to a user-owned folder # rather than escalating with sudo (npm's own advice: a root-owned global tree # causes worse problems later). if [ ! -x "$NODE_DIR/bin/node" ]; then local prefix; prefix="$(npm config get prefix 2>/dev/null || echo "")" if [ -n "$prefix" ] && [ ! -w "$prefix" ]; then warn "npm's global folder ($prefix) is not writable by you — switching to ~/.npm-global" mkdir -p "$HOME/.npm-global" npm config set prefix "$HOME/.npm-global" export PATH="$HOME/.npm-global/bin:$PATH" add_to_path "$HOME/.npm-global/bin" fi fi spin "downloading and installing gov (this takes a moment)" \ npm install -g --silent "$GOV_PKG" \ || die "npm could not install $GOV_PKG — the output above says why" ok "$(gov --version 2>/dev/null | head -1 || echo "gov installed")" say "===> 2. [✓] Install the governance client — gov" # Prefer the shell the person is actually in over a shell they have to go and open. local gov_bin; gov_bin="$(command -v gov 2>/dev/null || true)" [ -n "$gov_bin" ] && link_into_path "$gov_bin" || true } # ── run ─────────────────────────────────────────────────────────────────────── PROFILE_TOUCHED="" IMMEDIATELY_USABLE=0 PLATFORM="$(detect_platform)" RULE="========================================================================================" say "" say "$RULE" say "${B} Thank you for your interest in Svayam's governance framework.${RST}" say "$RULE" say "${DIM} Installing for $PLATFORM. A minute or two on a fresh machine.${RST}" say "" say " What to expect:" say " · Nothing is installed or changed without being shown to you first." say " · Each step reports as it completes, so you always know how far along you are." say " · Some steps only you can do — a browser sign-in, an administrator password," say " a name for your organization. gov will stop and ask." say "" # THE PLAN, BEFORE THE FIRST QUESTION. # # gov renders this list too, ticked off from what it can actually see — but gov # needs Node, and Node is step 1. So the plan is printed here, where nothing exists # yet, and gov takes over the ticking the moment it can run. This copy is the # ITINERARY; gov's is the PROGRESS, and gov's is the one that is derived and # therefore cannot be wrong. say " These steps will set governance up for your organization, on GitHub and here:" say "" say " 1. [ ] Install Node version 24" say " 2. [ ] Install the governance client — gov" say " 3. [ ] Install dependency — git" say " 4. [ ] Install dependency — gh, the GitHub CLI" say " 5. [ ] Authorize gov for GitHub" say " 6. [ ] Configure git" say " 7. [ ] Create the governance workspace folder" say " 8. [ ] Set your organization up ${DIM}(adopters)${RST} — or bring in your org's ${DIM}(joiners)${RST}" say " ${DIM}including which AI agents your organization allows${RST}" say " 9. [ ] Finish setting up this machine" say "" say "${DIM} Steps 3 onward are gov's own; it shows this list again, ticked off, at the end.${RST}" say "" # A GATE SO THE PLAN CAN BE READ BEFORE IT SCROLLS AWAY. # # Everything above this line is the only place an adopter is told what the next few minutes # will do to their machine — and installing Node alone produces enough output to push it off # screen. A plan nobody had a chance to read is not consent, it is a formality performed at # them, and this installer's own promise two paragraphs earlier is "nothing is installed or # changed without being shown to you first". # # `confirm` handles the two cases that must not block: GOV_YES=1 and no controlling terminal. # Someone who invoked an installer non-interactively has already answered, and the e2e tiers # run exactly that way — a gate that stops CI is a gate that gets removed. if ! confirm "Continue"; then say "" say " Nothing was installed. Run it again when you are ready:" say " ${B}curl -fsSL $GOV_INSTALL_URL -o install.sh && bash install.sh${RST}" exit 0 fi say "" say "$RULE" say "${B} Starting install${RST}" say "$RULE" step "Checking what you already have" install_node "$PLATFORM" install_gov say "" say "${GRN}${B}gov is installed.${RST}" say "" # THE LAST WORD, printed where the reader actually is. # # This used to be said just after the install and before `gov doctor --fix`. On a # machine that needed git, gh and a browser sign-in, that put it several screens # and a few minutes above the prompt the person was left staring at — and the # first thing they typed was `gov doctor`, which their shell had never heard of. # A reminder that has scrolled away is not a reminder. finish() { say "" if [ "$IMMEDIATELY_USABLE" = "1" ]; then say "${GRN}${B}gov is ready in this shell.${RST} Try: ${B}gov${RST}" say "" return fi if [ -n "$PROFILE_TOUCHED" ]; then say "${YEL}${B}One last thing.${RST} This shell was started before gov was installed," say "so it does not know about it yet. Run:" say "" say " ${B}source $(tilde "$PROFILE_TOUCHED")${RST}" say "" say "…or just open a new terminal. Then ${B}gov${RST} will work." else say "Run ${B}gov${RST} on its own to open the menu — start there if you are new." fi say "" } # HAND OVER, and do not stop at a report. # # The installer's job is "get this machine ready", and Node plus gov is only part # of that: git, the GitHub CLI and a signed-in token are the rest. Ending at a # report that says what is still wrong, and a command the reader must copy, puts # the last mile back on the person who ran a one-line installer precisely to avoid # it (#186). # # So it runs `gov doctor --fix`, which shows each command and waits for consent — # nothing is installed behind anyone's back. # # THE TERMINAL IS THE CATCH. Under `curl … | bash` this script's stdin IS the # pipe, so a prompt would read the rest of the script instead of the user. When a # real terminal exists we hand it to doctor explicitly with `< /dev/tty`; when it # does not — CI, a provisioning script — we report and name the command, because # consent cannot be given by something that is not there. if ! need gov; then exit 0 fi if [ "${GOV_NO_FIX:-}" = "1" ]; then step "gov doctor" gov doctor || true finish elif have_tty; then say "${B}One more step: the tools gov needs on this machine.${RST}" say "${DIM}You will be shown each command and asked before anything runs.${RST}" say "" gov doctor --fix < /dev/tty || true # AND KEEP GOING. The environment being ready is not what anyone came for — it is # the toll on the way to setting their organization up. Stopping here, with a # green report and a different command to discover, is the same "last mile handed # back" the report-only ending already was, one step further along. # # Any gov command triggers the first-run flow; `list` is the least surprising one # to be holding when it does. say "" say "${B}Next: your organization.${RST}" say "${DIM}gov will ask whether you are adopting the framework or joining an existing setup.${RST}" say "${DIM}There is an option for 'I am not sure' — it only explains, and changes nothing.${RST}" say "" printf ' Continue now? [Y/n] ' read -r go < /dev/tty || go="" case "$go" in [nN]|[nN][oO]) say "" say "Stopped here. When you are ready, run: ${B}gov${RST}" ;; *) say "" gov list < /dev/tty || true ;; esac finish exit 0 else step "gov doctor" gov doctor || true say "" say "No terminal here, so nothing was changed. To finish setting this machine up:" say " ${B}gov doctor --fix${RST}" finish fi