#!/bin/sh # LStack per-user installer — installs LStack under your home directory, without root. # Install: curl -fsSL https://lstack.ai/install.sh | bash set -eu usage() { cat <<'EOF' LStack installer (per-user, no root) Install LStack: curl -fsSL https://lstack.ai/install.sh | bash Options: --start start LStack once the installation completes --help, -h print this text and exit This installer takes no version or channel argument. LStack publishes one release document and installs the version it names. The beta downloads need no account and no sign-in. The release is fetched over ordinary anonymous HTTPS, and every artifact is checksum-verified before anything on this machine is changed. Environment: LSTACK_HOME relocate the entire installation (your own HOME is left alone) LSTACK_DATA_DIR override the private data root LSTACK_EXEC_DATA_DIR override the exec-data root LSTACK_RELEASE_BASE acquire from your OWN http(s):// or file:// origin instead of the LStack beta release EOF } fail() { if [ -n "${PREFLIGHT_TMP:-}" ]; then rm -rf "$PREFLIGHT_TMP" 2>/dev/null || true fi echo "install.sh: ERROR: $*" >&2 exit 1 } fault() { if [ "${LSTACK_INSTALL_HOLD:-}" = "$1" ] && [ -n "${LSTACK_INSTALL_HOLD_FILE:-}" ]; then echo "install.sh: holding at '$1' (test hook)" >&2 _hold_waited=0 while [ -e "$LSTACK_INSTALL_HOLD_FILE" ] && [ "$_hold_waited" -lt 120 ]; do sleep 1 _hold_waited=$((_hold_waited + 1)) done fi if [ "${LSTACK_INSTALL_FAULT:-}" = "$1" ]; then echo "install.sh: injected fault '$1' (test hook)" >&2 exit 97 fi } # Use the stable release channel. CHANNEL=stable # Read the version from the release document. VERSION="" START=0 while [ $# -gt 0 ]; do case "$1" in --start) START=1; shift ;; --help | -h) usage; exit 0 ;; # Reject unknown options before downloading or changing files. *) echo "install.sh: unknown argument '$1' (see --help)" >&2; exit 2 ;; esac done assert_strict_semver() { _sv_value=$1 _sv_origin=$2 case "$_sv_value" in "") fail "$_sv_origin is empty — nothing was installed" ;; *[!0-9.]* | .* | *. | *..*) fail "$_sv_origin is '$_sv_value', which is not a strict major.minor.patch semantic version (digits and dots only). Nothing was installed." ;; *.*.*.*) fail "$_sv_origin is '$_sv_value', which has more than three components; a strict major.minor.patch semantic version is required. Nothing was installed." ;; *.*.*) ;; *) fail "$_sv_origin is '$_sv_value', which is not a strict major.minor.patch semantic version. Nothing was installed." ;; esac } # Refuse to run as root — LStack installs per-user and needs no privilege. if [ "$(id -u 2>/dev/null || echo 1)" = "0" ]; then echo "install.sh: refusing to run as root." >&2 echo "LStack installs per-user under your own home directory and never needs privilege." >&2 echo "Run this installer as the account that will use LStack — without administrator privileges." >&2 echo "Nothing was installed." >&2 exit 1 fi OS_RAW=$(uname -s) ARCH_RAW=$(uname -m) # Normalize platform identifiers. case "$OS_RAW" in Darwin) OS=darwin ;; Linux) OS=linux ;; MINGW* | MSYS* | CYGWIN* | Windows_NT) OS=windows ;; *) OS=unknown ;; esac case "$ARCH_RAW" in arm64 | aarch64) ARCH=arm64 ;; x86_64 | amd64) ARCH=x64 ;; *) ARCH=unknown ;; esac SUPPORTED_LIST="Supported platforms: - macOS arm64 (Apple Silicon) [darwin/arm64] - Linux x86_64 (with real Docker Engine) [linux/x64] - Linux arm64 (with real Docker Engine) [linux/arm64]" reject_platform() { echo "install.sh: unsupported platform: $OS_RAW/$ARCH_RAW ($1)" >&2 echo "$SUPPORTED_LIST" >&2 echo "Nothing was installed." >&2 exit 1 } case "$OS/$ARCH" in darwin/arm64 | linux/x64 | linux/arm64) ;; windows/*) echo "install.sh: Windows is not supported in this release." >&2 echo "To run LStack on this machine today, use WSL2 with an Ubuntu x86_64 distribution and run this installer inside it." >&2 reject_platform "Windows — see the WSL2 note above" ;; darwin/x64) reject_platform "recognized but not currently supported" ;; *) reject_platform "unrecognized OS/architecture" ;; esac PRIVATE_RELEASE_REPO="lstack-ai/beta" # Locate the stable release document and GitHub metadata. PRIVATE_RELEASE_BRANCH="main" STABLE_DOCUMENT_PATH="stable.json" GH_RAW_ACCEPT_HEADER="Accept: application/vnd.github.raw" GH_JSON_ACCEPT_HEADER="Accept: application/vnd.github+json" # Use fixed HTTPS origins for official release downloads. RELEASE_API_ORIGIN="https://api.github.com" RELEASE_DOWNLOAD_ORIGIN="https://github.com" MAX_ARTIFACT_BYTES=629145600 MAX_METADATA_BYTES=4194304 FETCH_TIMEOUT_S=120 GH_TIMEOUT_S=120 GH_KILL_GRACE_S=2 # Follow at most three redirects, all over HTTPS. MAX_RELEASE_REDIRECTS=3 PREFLIGHT_TMP="" PREFLIGHT_ASSETS="" NET_WATCH_FILE="" NET_WATCH_MAX=0 RELEASE_HTTP_STATUS="" # Stop a timed-out or oversized download and wait for its process to exit. net_terminate() { _gt_pid=$1 kill -TERM "$_gt_pid" 2>/dev/null || true _gt_waited=0 while kill -0 "$_gt_pid" 2>/dev/null; do if [ "$_gt_waited" -ge "$GH_KILL_GRACE_S" ]; then kill -KILL "$_gt_pid" 2>/dev/null || true break fi sleep 1 _gt_waited=$((_gt_waited + 1)) done wait "$_gt_pid" 2>/dev/null || true } # Stop downloads on timeout or after the size limit is exceeded, including on older curl. net_run() { _nr_out=$1 _nr_err=$2 shift 2 : >"$_nr_out" : >"$_nr_err" curl "$@" >"$_nr_out" 2>"$_nr_err" & _nr_pid=$! _nr_waited=0 _nr_stopped="" while kill -0 "$_nr_pid" 2>/dev/null; do if [ "$_nr_waited" -ge "$GH_TIMEOUT_S" ]; then _nr_stopped=timeout break fi if [ -n "$NET_WATCH_FILE" ] && [ -f "$NET_WATCH_FILE" ]; then _nr_size=$(wc -c <"$NET_WATCH_FILE" 2>/dev/null | awk '{print $1+0}') [ -n "$_nr_size" ] || _nr_size=0 if [ "$_nr_size" -gt "$NET_WATCH_MAX" ]; then _nr_stopped=oversize break fi fi sleep 1 _nr_waited=$((_nr_waited + 1)) done _nr_partial="$NET_WATCH_FILE" _nr_limit="$NET_WATCH_MAX" NET_WATCH_FILE="" NET_WATCH_MAX=0 if [ -n "$_nr_stopped" ]; then net_terminate "$_nr_pid" if [ -n "$_nr_partial" ]; then rm -f "$_nr_partial" 2>/dev/null || true; fi if [ "$_nr_stopped" = oversize ]; then echo "lstack-install: download exceeded the ${_nr_limit}-byte limit and was stopped" >>"$_nr_err" else echo "the download timed out after ${GH_TIMEOUT_S}s" >>"$_nr_err" fi return 1 fi _nr_status=0 wait "$_nr_pid" || _nr_status=$? return "$_nr_status" } # Fetch release metadata, retaining HTTP status and headers for error handling. release_api_get() { _ag_url=$1 _ag_accept=$2 _ag_dest=$3 _ag_max=$4 RELEASE_HTTP_STATUS="" rm -f "$_ag_dest" "$PREFLIGHT_TMP/headers.txt" 2>/dev/null || true NET_WATCH_FILE="$_ag_dest" NET_WATCH_MAX="$_ag_max" net_run "$PREFLIGHT_TMP/status.txt" "$PREFLIGHT_TMP/net.err" \ -sS \ --proto '=https' --proto-redir '=https' \ --location --max-redirs "$MAX_RELEASE_REDIRECTS" \ --max-filesize "$_ag_max" --max-time "$FETCH_TIMEOUT_S" \ -H "$_ag_accept" \ -D "$PREFLIGHT_TMP/headers.txt" \ -o "$_ag_dest" \ -w '%{http_code}' \ "$_ag_url" || return 1 RELEASE_HTTP_STATUS=$(tr -dc '0-9' <"$PREFLIGHT_TMP/status.txt" 2>/dev/null) [ -n "$RELEASE_HTTP_STATUS" ] || return 1 return 0 } # Download an official asset; reject HTTP errors before accepting the file. release_asset_get() { _ad_url=$1 _ad_dest=$2 _ad_max=$3 rm -f "$_ad_dest" 2>/dev/null || true NET_WATCH_FILE="$_ad_dest" NET_WATCH_MAX="$_ad_max" net_run "$PREFLIGHT_TMP/net.out" "$PREFLIGHT_TMP/net.err" \ -fsS \ --proto '=https' --proto-redir '=https' \ --location --max-redirs "$MAX_RELEASE_REDIRECTS" \ --max-filesize "$_ad_max" --max-time "$FETCH_TIMEOUT_S" \ -o "$_ad_dest" \ "$_ad_url" || return 1 [ -f "$_ad_dest" ] || return 1 return 0 } # Classify missing releases and assets; report rate limits only when headers confirm them. http_classify() { _hc_status=$1 _hc_kind=$2 if LC_ALL=C grep -qi 'lstack-install: download exceeded' "$PREFLIGHT_TMP/net.err" 2>/dev/null; then echo asset-oversized; return 0 fi case "$_hc_status" in 403 | 429) _hc_remaining=$(LC_ALL=C awk 'BEGIN{IGNORECASE=0} tolower($1) == "x-ratelimit-remaining:" { gsub(/\r/, "", $2); print $2 }' "$PREFLIGHT_TMP/headers.txt" 2>/dev/null | tail -n 1) if [ "$_hc_remaining" = "0" ]; then echo rate-limited; return 0; fi echo unclassified; return 0 ;; 404) case "$_hc_kind" in document) echo stable-document-missing; return 0 ;; asset) echo asset-missing; return 0 ;; *) echo release-missing; return 0 ;; esac ;; esac echo unclassified } release_refuse() { case "$1" in asset-oversized) fail "The download from the release grew past the ${MAX_ARTIFACT_BYTES}-byte limit this installer accepts and was stopped. Nothing was installed, and the partial download was removed. Report this to the LStack owner." ;; rate-limited) fail "GitHub is rate-limiting anonymous downloads from your network address, so the release could not be read. This is temporary, and it is not a problem with your access — the download needs no account at all. Wait for the limit to reset, then rerun: $PUBLIC_INSTALL_COMMAND Nothing was installed." ;; release-missing) fail "The requested release does not exist in $PRIVATE_RELEASE_REPO. The LStack release document names a version that has no Release. This is a publication problem on the LStack side, not a problem on your machine. Nothing was installed. Report it to the LStack owner, naming the version above." ;; stable-document-missing) fail "The LStack release document '$STABLE_DOCUMENT_PATH' is not present on branch '$PRIVATE_RELEASE_BRANCH' of $PRIVATE_RELEASE_REPO. This is a publication problem on the LStack side, not a problem on your machine. Nothing was installed. Report it to the LStack owner, naming the repository, the branch and the file." ;; asset-missing) fail "The release publishes no build for this platform ($OS/$ARCH). Nothing was installed. Report this to the LStack owner with your OS and architecture." ;; asset-ambiguous) fail "The release advertises more than one asset with that exact name, so no single artifact can be chosen. Refusing rather than guessing; nothing was installed." ;; *) fail "The release could not be reached and the failure was not recognised. Nothing was installed. Rerun the install command; if it persists, report it to the LStack owner." ;; esac } PUBLIC_INSTALL_COMMAND=$(cat <<'CMDEOF' curl -fsSL https://lstack.ai/install.sh | bash CMDEOF ) # Fetch assets from the official source or the explicit LSTACK_RELEASE_BASE override. fetch() { _url=$1 _dest=$2 _max=${3:-$MAX_ARTIFACT_BYTES} case "$TRANSPORT" in gh) # Official downloads use anonymous HTTPS with redirect, time and size limits. [ -n "$PREFLIGHT_TMP" ] || return 1 release_asset_get "$_url" "$_dest" "$_max" || return 1 ;; *) case "$_url" in file://*) # Copy a local file only if its size is within the download limit. _src=$(printf '%s' "$_url" | sed 's,^file://,,') [ -f "$_src" ] || return 1 _srcsize=$(wc -c <"$_src" 2>/dev/null | awk '{print $1+0}') [ -n "$_srcsize" ] || _srcsize=-1 [ "$_srcsize" -ge 0 ] && [ "$_srcsize" -le "$_max" ] || return 1 cp "$_src" "$_dest" || return 1 ;; *) # Custom HTTP(S) sources must serve the file directly, without redirects. curl -fsSL --max-redirs 0 --max-filesize "$_max" --max-time "$FETCH_TIMEOUT_S" "$_url" -o "$_dest" || return 1 ;; esac ;; esac _got=$(wc -c <"$_dest" 2>/dev/null | awk '{print $1+0}') [ -n "$_got" ] || _got=0 if [ "$_got" -le 0 ] || [ "$_got" -gt "$_max" ]; then rm -f "$_dest" 2>/dev/null || true return 1 fi } # Network downloads require curl; local file sources do not. case "${LSTACK_RELEASE_BASE:-}" in file://*) ;; *) command -v curl >/dev/null 2>&1 || fail "curl is required" ;; esac # Check required tools before downloading or installing. command -v awk >/dev/null 2>&1 || fail "awk is required" command -v tar >/dev/null 2>&1 || fail "tar is required" command -v mktemp >/dev/null 2>&1 || fail "mktemp is required (this installer creates its scratch directories securely, never from a predictable name)" if command -v sha256sum >/dev/null 2>&1; then sha256_of() { sha256sum "$1" | awk '{print tolower($1)}'; } elif command -v shasum >/dev/null 2>&1; then sha256_of() { shasum -a 256 "$1" | awk '{print tolower($1)}'; } elif command -v openssl >/dev/null 2>&1; then sha256_of() { openssl dgst -sha256 -r "$1" | awk '{print tolower($1)}'; } else fail "no SHA-256 tool found (need sha256sum, shasum, or openssl)" fi # Verify artifact integrity before extraction. verify_archive_checksum() { _archive=$1 _sums=$2 [ -f "$_sums" ] || fail "checksum missing — no .sha256 was published for $(basename "$_archive"). Refusing to install; nothing was installed." [ -s "$_sums" ] || fail "checksum missing — the .sha256 for $(basename "$_archive") is empty. Refusing to install; nothing was installed." _lines=$(grep -c '[^[:space:]]' "$_sums" 2>/dev/null || echo 0) if [ "$_lines" -gt 1 ]; then fail "checksum duplicate — the .sha256 for $(basename "$_archive") carries $_lines digests; exactly one is required. Refusing rather than choosing one; nothing was installed." fi _expected=$(awk 'NR==1 {print tolower($1)}' "$_sums") case "$_expected" in *[!0-9a-f]* | "") fail "checksum file is malformed" ;; esac [ "${#_expected}" -eq 64 ] || fail "checksum file is malformed (not a SHA-256)" [ -f "$_archive" ] || fail "checksum unexpected — a checksum was published with no artifact beside it. Refusing to install; nothing was installed." _actual=$(sha256_of "$_archive") if [ "$_expected" != "$_actual" ]; then fail "SHA-256 mismatch for $(basename "$_archive") — expected $_expected, got $_actual. Refusing to install; nothing was installed." fi } # Reject unsafe archive members before any extraction. validate_archive_policy() { _ap_archive=$1 _ap_work=$2 _ap_list="$_ap_work/policy-listing" _ap_listv="$_ap_work/policy-listing-v" tar -tzf "$_ap_archive" >"$_ap_list" 2>/dev/null || fail "archive is unreadable or malformed — refused; nothing was installed" [ -s "$_ap_list" ] || fail "archive is empty — refused" if LC_ALL=C grep -q '[^[:print:]]' "$_ap_list"; then fail "archive member names contain control characters — refused" fi if LC_ALL=C grep -q '^/' "$_ap_list"; then fail "archive contains an absolute member path — refused; no file was written outside the install root" fi if LC_ALL=C grep -Eq '(^|/)\.\.(/|$)' "$_ap_list"; then fail "archive contains a '..' path segment — traversal refused; no file was written outside the install root" fi if LC_ALL=C grep -Eq '(^|/)\.(/|$)' "$_ap_list"; then fail "archive contains a '.' path segment — ambiguous path refused" fi if LC_ALL=C grep -q '//' "$_ap_list"; then fail "archive contains an empty path segment ('//') — refused" fi # shellcheck disable=SC1003 if LC_ALL=C grep -q '\\' "$_ap_list"; then fail "archive member names contain a backslash — refused" fi if LC_ALL=C awk '{ n=$0; sub(/\/$/,"",n); if (seen[n]++) exit 1 }' "$_ap_list"; then :; else fail "archive contains a duplicate member name — refused" fi tar -tzvf "$_ap_archive" >"$_ap_listv" 2>/dev/null || fail "archive is unreadable — refused" if ! LC_ALL=C awk 'NF { c = substr($1, 1, 1); if (c != "-" && c != "d") exit 1 }' "$_ap_listv"; then fail "archive contains a non-regular member (symlink/hardlink/device) — refused; release artifacts contain regular files and directories only" fi if LC_ALL=C grep -qE ' -> | link to ' "$_ap_listv"; then fail "archive contains a link member (symlink/hardlink) — refused; release artifacts contain regular files and directories only" fi } # Validate installation paths before making changes. normalize_path() { _np=$1 while :; do case "$_np" in *//*) _np_pre=${_np%%//*} _np_post=${_np#*//} while :; do case "$_np_post" in /*) _np_post=${_np_post#/} ;; *) break ;; esac done _np="$_np_pre/$_np_post" ;; *) break ;; esac done case "$_np" in /) ;; */) _np=${_np%/} ;; esac printf '%s' "$_np" } path_reject_reason() { _pr=$1 if [ -z "$_pr" ]; then printf '%s' "is empty"; return 0; fi case "$_pr" in /*) ;; *) printf '%s' "is not an absolute path"; return 0 ;; esac case "$_pr" in *[[:cntrl:]]*) printf '%s' "contains a control character"; return 0 ;; esac case "$_pr" in *//*) printf '%s' "contains an empty path segment ('//')"; return 0 ;; esac case "$_pr" in */../* | */.. | ../* | ..) printf '%s' "contains a '..' traversal segment"; return 0 ;; esac case "$_pr" in */./* | */. | ./* | .) printf '%s' "contains a '.' segment"; return 0 ;; esac if [ "$_pr" = "/" ]; then printf '%s' "is the filesystem root"; return 0; fi printf '%s' "" } path_within() { case "$2" in "$1") return 0 ;; "$1"/*) return 0 ;; *) return 1 ;; esac } canonical_path() { _cp_head=$1 _cp_tail="" _cp_guard=0 while [ "$_cp_head" != "/" ] && [ ! -d "$_cp_head" ]; do _cp_guard=$((_cp_guard + 1)) if [ "$_cp_guard" -gt 128 ]; then printf '%s' "$1"; return 0; fi _cp_base=${_cp_head##*/} _cp_head=${_cp_head%/*} [ -n "$_cp_head" ] || _cp_head=/ if [ -n "$_cp_tail" ]; then _cp_tail="$_cp_base/$_cp_tail"; else _cp_tail="$_cp_base"; fi done _cp_real=$(cd "$_cp_head" 2>/dev/null && pwd -P) || _cp_real=$_cp_head [ -n "$_cp_real" ] || _cp_real=$_cp_head if [ -n "$_cp_tail" ]; then case "$_cp_real" in /) printf '/%s' "$_cp_tail" ;; *) printf '%s/%s' "$_cp_real" "$_cp_tail" ;; esac else printf '%s' "$_cp_real" fi } TERMINAL_ROOTS="/ /bin /boot /dev /etc /home /lib /lib64 /opt /private /proc /root /run /sbin /srv /sys /tmp /usr /var /Applications /Library /System /Users /Volumes" assert_usable_root() { _ur_label=$1 _ur_path=$2 _ur_why=$(path_reject_reason "$_ur_path") if [ -n "$_ur_why" ]; then fail "the $_ur_label ('$_ur_path') $_ur_why. Refusing before anything was created; nothing was installed." fi for _ur_root in $TERMINAL_ROOTS; do if [ "$_ur_path" = "$_ur_root" ]; then fail "the $_ur_label resolves to '$_ur_root', which is a system root. This installer never creates, writes to, or removes one. Nothing was installed." fi done } assert_roots_disjoint() { _rd_a_label=$1 _rd_a=$2 _rd_b_label=$3 _rd_b=$4 if [ "$_rd_a" = "$_rd_b" ]; then fail "the $_rd_a_label and the $_rd_b_label are the same directory ('$_rd_a'). They are separate roots with separate lifecycles, and a rollback of one would remove the other. Nothing was installed." fi if path_within "$_rd_a" "$_rd_b"; then fail "the $_rd_b_label ('$_rd_b') lies inside the $_rd_a_label ('$_rd_a'). Overlapping roots are refused: a rollback of the outer one would recursively remove the inner one. Nothing was installed." fi if path_within "$_rd_b" "$_rd_a"; then fail "the $_rd_a_label ('$_rd_a') lies inside the $_rd_b_label ('$_rd_b'). Overlapping roots are refused: a rollback of the outer one would recursively remove the inner one. Nothing was installed." fi } # Resolve per-user installation paths. assert_not_set_but_empty() { if [ "$2" = set ] && [ -z "$3" ]; then fail "$1 is set but empty. A set-but-empty path override is an error, not an unset: unset it, or give it an absolute path. Nothing was installed." fi } assert_not_set_but_empty LSTACK_HOME "${LSTACK_HOME+set}" "${LSTACK_HOME-}" assert_not_set_but_empty LSTACK_DATA_DIR "${LSTACK_DATA_DIR+set}" "${LSTACK_DATA_DIR-}" assert_not_set_but_empty LSTACK_EXEC_DATA_DIR "${LSTACK_EXEC_DATA_DIR+set}" "${LSTACK_EXEC_DATA_DIR-}" assert_not_set_but_empty XDG_DATA_HOME "${XDG_DATA_HOME+set}" "${XDG_DATA_HOME-}" assert_not_set_but_empty XDG_CONFIG_HOME "${XDG_CONFIG_HOME+set}" "${XDG_CONFIG_HOME-}" LSTACK_EFFECTIVE_HOME="${LSTACK_HOME:-${HOME:-}}" [ -n "$LSTACK_EFFECTIVE_HOME" ] || fail "no home directory is set (neither LSTACK_HOME nor HOME) — there is nowhere to install" case "$LSTACK_EFFECTIVE_HOME" in *\"* | *' '*) fail "the home directory contains a quote or newline — refusing" ;; esac LSTACK_EFFECTIVE_HOME=$(normalize_path "$LSTACK_EFFECTIVE_HOME") assert_usable_root "effective home directory" "$LSTACK_EFFECTIVE_HOME" INSTALL_ROOT="$LSTACK_EFFECTIVE_HOME/.lstack" _APPDATA="" if [ "$OS" = darwin ]; then _APPDATA="$LSTACK_EFFECTIVE_HOME/Library/Application Support" DATA_DIR="${LSTACK_DATA_DIR:-$_APPDATA/LStack}" EXEC_DATA_DIR="${LSTACK_EXEC_DATA_DIR:-$_APPDATA/LStack-exec-data}" CONFIG_DIR="$_APPDATA/LStack" PROJECTS_ROOT="$LSTACK_EFFECTIVE_HOME/LStack-Projects" else XDG_DATA="${XDG_DATA_HOME:-$LSTACK_EFFECTIVE_HOME/.local/share}" XDG_CONFIG="${XDG_CONFIG_HOME:-$LSTACK_EFFECTIVE_HOME/.config}" assert_usable_root "XDG data home" "$(normalize_path "$XDG_DATA")" assert_usable_root "XDG config home" "$(normalize_path "$XDG_CONFIG")" DATA_DIR="${LSTACK_DATA_DIR:-$XDG_DATA/lstack}" EXEC_DATA_DIR="${LSTACK_EXEC_DATA_DIR:-$XDG_DATA/lstack-exec-data}" CONFIG_DIR="$XDG_CONFIG/lstack" PROJECTS_ROOT="$LSTACK_EFFECTIVE_HOME/LStack-Projects" fi INSTALL_ROOT=$(normalize_path "$INSTALL_ROOT") DATA_DIR=$(normalize_path "$DATA_DIR") EXEC_DATA_DIR=$(normalize_path "$EXEC_DATA_DIR") CONFIG_DIR=$(normalize_path "$CONFIG_DIR") PROJECTS_ROOT=$(normalize_path "$PROJECTS_ROOT") assert_usable_root "install root" "$INSTALL_ROOT" assert_usable_root "data root" "$DATA_DIR" assert_usable_root "exec-data root" "$EXEC_DATA_DIR" assert_usable_root "configuration root" "$CONFIG_DIR" assert_usable_root "projects root" "$PROJECTS_ROOT" CANON_HOME=$(canonical_path "$LSTACK_EFFECTIVE_HOME") CANON_INSTALL_ROOT=$(canonical_path "$INSTALL_ROOT") CANON_DATA_DIR=$(canonical_path "$DATA_DIR") CANON_EXEC_DATA_DIR=$(canonical_path "$EXEC_DATA_DIR") CANON_CONFIG_DIR=$(canonical_path "$CONFIG_DIR") CANON_PROJECTS_ROOT=$(canonical_path "$PROJECTS_ROOT") for _cr in "$CANON_HOME" "$CANON_INSTALL_ROOT" "$CANON_DATA_DIR" "$CANON_EXEC_DATA_DIR" "$CANON_CONFIG_DIR" "$CANON_PROJECTS_ROOT"; do for _tr in $TERMINAL_ROOTS; do if [ "$_cr" = "$_tr" ]; then fail "a root resolves to the system directory '$_tr' (through '$_cr'). This installer never creates, writes to, or removes one. Nothing was installed." fi done done for _ovr in "data root:$CANON_DATA_DIR" "exec-data root:$CANON_EXEC_DATA_DIR" "configuration root:$CANON_CONFIG_DIR"; do _ovr_label=${_ovr%%:*} _ovr_path=${_ovr#*:} if path_within "$_ovr_path" "$CANON_HOME"; then fail "the $_ovr_label ('$_ovr_path') is your home directory, or contains it. It must be a directory of its own that this installer can create and, on failure, remove. Nothing was installed." fi done CONFIG_ALIASES_DATA=0 if [ "$CANON_DATA_DIR" = "$CANON_CONFIG_DIR" ]; then if [ "$OS" = darwin ]; then CONFIG_ALIASES_DATA=1 else fail "the data root and the configuration root are the same directory ('$DATA_DIR'). On this platform they are separate roots, and a rollback of one would remove the other. Nothing was installed." fi fi assert_roots_disjoint "install root" "$CANON_INSTALL_ROOT" "data root" "$CANON_DATA_DIR" assert_roots_disjoint "install root" "$CANON_INSTALL_ROOT" "exec-data root" "$CANON_EXEC_DATA_DIR" assert_roots_disjoint "install root" "$CANON_INSTALL_ROOT" "configuration root" "$CANON_CONFIG_DIR" assert_roots_disjoint "install root" "$CANON_INSTALL_ROOT" "projects root" "$CANON_PROJECTS_ROOT" assert_roots_disjoint "data root" "$CANON_DATA_DIR" "exec-data root" "$CANON_EXEC_DATA_DIR" assert_roots_disjoint "data root" "$CANON_DATA_DIR" "projects root" "$CANON_PROJECTS_ROOT" assert_roots_disjoint "exec-data root" "$CANON_EXEC_DATA_DIR" "configuration root" "$CANON_CONFIG_DIR" assert_roots_disjoint "exec-data root" "$CANON_EXEC_DATA_DIR" "projects root" "$CANON_PROJECTS_ROOT" assert_roots_disjoint "configuration root" "$CANON_CONFIG_DIR" "projects root" "$CANON_PROJECTS_ROOT" if [ "$CONFIG_ALIASES_DATA" = 0 ]; then assert_roots_disjoint "data root" "$CANON_DATA_DIR" "configuration root" "$CANON_CONFIG_DIR" fi INSTALL_JSON="$CONFIG_DIR/install.json" SECRET_FILE="$DATA_DIR/secrets/runtime-secrets.key" MANIFEST_FILE="$INSTALL_ROOT/manifest.json" if [ -n "${LSTACK_RELEASE_BASE:-}" ]; then TRANSPORT=url RELEASE_BASE="$LSTACK_RELEASE_BASE" else TRANSPORT=gh RELEASE_BASE="gh:$PRIVATE_RELEASE_REPO" fi # Build the release-document URL for a custom source. channel_doc_url() { printf '%s/%s' "$RELEASE_BASE" "$STABLE_DOCUMENT_PATH" } artifact_asset_url() { if [ "$TRANSPORT" = gh ]; then printf '%s/%s/releases/download/v%s/%s' "$RELEASE_DOWNLOAD_ORIGIN" "$PRIVATE_RELEASE_REPO" "$VERSION" "$1" else printf '%s/%s/%s' "$RELEASE_BASE" "$VERSION" "$1" fi } PREFLIGHT_ASSET_SIZE="" # Validate the full release JSON before emitting asset names and sizes as tab-separated rows. release_assets_tsv() { LC_ALL=C awk ' function fail(msg) { printf("release_assets_tsv: refused: %s\n", msg) > "/dev/stderr"; exit 1 } function push(ty, v) { NTOK++; TT[NTOK] = ty; TV[NTOK] = v } function oddbs(s, k, n) { n = length(s); k = 0 while (n - k > 0 && substr(s, n - k, 1) == "\\") k++ return k % 2 } function push_string(raw) { if (raw ~ /[[:cntrl:]]/) fail("a raw control character inside a JSON string") push(3, raw) } function push_struct(t, j, n, toks) { # Tokenize JSON punctuation separately for compatibility across awk implementations. gsub(/[{]/, " & ", t) gsub(/[}]/, " & ", t) gsub(/[[]/, " & ", t) gsub(/[]]/, " & ", t) gsub(/:/, " & ", t) gsub(/,/, " & ", t) n = split(t, toks, /[ \t\n\r]+/) for (j = 1; j <= n; j++) { if (toks[j] == "") continue if (toks[j] == "{" || toks[j] == "}" || toks[j] == "[" || toks[j] == "]" || toks[j] == ":" || toks[j] == ",") push(1, toks[j]) else push(2, toks[j]) } } function tokenize( n, parts, i, inStr, cur) { n = split(BUF, parts, /"/) inStr = 0 cur = parts[1] for (i = 2; i <= n; i++) { if (inStr && oddbs(cur)) { cur = cur "\"" parts[i]; continue } if (inStr) { push_string(cur); cur = parts[i]; inStr = 0 } else { push_struct(cur); cur = parts[i]; inStr = 1 } } if (inStr) fail("an unterminated JSON string") push_struct(cur) } function printable_ascii(s) { if (s == "") return 0 if (index(s, "\\") > 0) return 0 return (s ~ /^[ -~]*$/) } function decode_name(raw, out, j, c, d, L) { out = ""; L = length(raw); j = 1 while (j <= L) { c = substr(raw, j, 1) if (c == "\\") { d = substr(raw, j + 1, 1) if (d == "\"") { out = out "\""; j += 2; continue } if (d == "/") { out = out "/"; j += 2; continue } if (d == "\\") { out = out "\\"; j += 2; continue } fail("an asset name uses an escape this installer does not decode") } out = out c j++ } if (!printable_ascii(out)) fail("an asset name is empty, not printable ASCII, or carries a backslash") return out } function valid_literal(t) { if (t == "true" || t == "false" || t == "null") return 1 return (t ~ /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][-+]?[0-9]+)?$/) } function parse_value(i, depth) { if (depth > 64) fail("the document nests deeper than this installer reads") if (i > NTOK) fail("a truncated document") if (TT[i] == 3) return i + 1 if (TT[i] == 2) { if (!valid_literal(TV[i])) fail("a value that is not valid JSON"); return i + 1 } if (TT[i] == 1 && TV[i] == "{") return parse_object(i, "", depth + 1) if (TT[i] == 1 && TV[i] == "[") return parse_array(i, depth + 1) fail("a token where a JSON value was required") } function parse_array(i, depth) { i++ if (TT[i] == 1 && TV[i] == "]") return i + 1 for (;;) { i = parse_value(i, depth) if (TT[i] == 1 && TV[i] == ",") { i++; continue } if (TT[i] == 1 && TV[i] == "]") return i + 1 fail("an array element not followed by a comma or a closing bracket") } } function parse_object(i, mode, depth, seen, key) { if (depth > 64) fail("the document nests deeper than this installer reads") i++ seen = "" if (TT[i] == 1 && TV[i] == "}") return i + 1 for (;;) { if (TT[i] != 3) fail("an object key that is not a string") key = TV[i] if (index(seen, SUBSEP key SUBSEP) > 0) fail("a duplicate object key") seen = seen SUBSEP key SUBSEP i++ if (!(TT[i] == 1 && TV[i] == ":")) fail("an object member with no colon") i++ i = parse_member(i, mode, key, depth) if (TT[i] == 1 && TV[i] == ",") { i++; continue } if (TT[i] == 1 && TV[i] == "}") return i + 1 fail("an object member not followed by a comma or a closing brace") } } function parse_member(i, mode, key, depth) { if (mode == "root" && key == "assets") { SAW_ASSETS = 1; return parse_assets(i, depth + 1) } if (mode == "asset" && key == "name") { if (TT[i] != 3) fail("an asset name that is not a string") CUR_NAME = decode_name(TV[i]); CUR_HAS_NAME = 1 return i + 1 } if (mode == "asset" && key == "size") { if (TT[i] != 2) fail("an asset size that is not a number") if (TV[i] !~ /^(0|[1-9][0-9]*)$/) fail("an asset size that is not a plain non-negative integer") CUR_SIZE = TV[i]; CUR_HAS_SIZE = 1 return i + 1 } return parse_value(i, depth) } function parse_assets(i, depth) { if (!(TT[i] == 1 && TV[i] == "[")) fail("an assets member that is not an array") i++ if (TT[i] == 1 && TV[i] == "]") return i + 1 for (;;) { if (!(TT[i] == 1 && TV[i] == "{")) fail("an assets element that is not an object") CUR_HAS_NAME = 0; CUR_HAS_SIZE = 0; CUR_NAME = ""; CUR_SIZE = "" i = parse_object(i, "asset", depth + 1) if (!CUR_HAS_NAME) fail("an asset with no name") if (!CUR_HAS_SIZE) fail("an asset with no size") OUT = OUT CUR_NAME "\t" CUR_SIZE "\n" if (TT[i] == 1 && TV[i] == ",") { i++; continue } if (TT[i] == 1 && TV[i] == "]") return i + 1 fail("an assets element not followed by a comma or a closing bracket") } } BEGIN { NTOK = 0; OUT = ""; SAW_ASSETS = 0 } { BUF = BUF $0 "\n" } END { tokenize() if (!(TT[1] == 1 && TV[1] == "{")) fail("a document that is not a JSON object") i = parse_object(1, "root", 1) if (i != NTOK + 1) fail("trailing content after the document") if (!SAW_ASSETS) fail("a document with no assets array") printf "%s", OUT exit 0 } ' } # Read and validate the release's advertised asset inventory. release_load_asset_inventory() { _ai_tag=$1 PREFLIGHT_RAW_INVENTORY="$PREFLIGHT_TMP/release.json" PREFLIGHT_ASSETS="$PREFLIGHT_TMP/assets.tsv" release_api_get "$RELEASE_API_ORIGIN/repos/$PRIVATE_RELEASE_REPO/releases/tags/$_ai_tag" \ "$GH_JSON_ACCEPT_HEADER" "$PREFLIGHT_RAW_INVENTORY" "$MAX_METADATA_BYTES" || release_refuse "$(http_classify "$RELEASE_HTTP_STATUS" release)" # Check HTTP status before parsing the response body. [ "$RELEASE_HTTP_STATUS" = "200" ] || release_refuse "$(http_classify "$RELEASE_HTTP_STATUS" release)" # Reject oversized metadata rather than parsing a truncated document. _ai_bytes=$(wc -c <"$PREFLIGHT_RAW_INVENTORY" 2>/dev/null | awk '{print $1+0}') [ -n "$_ai_bytes" ] || _ai_bytes=0 if [ "$_ai_bytes" -gt "$MAX_METADATA_BYTES" ]; then fail "the release's asset inventory is $_ai_bytes bytes, above the $MAX_METADATA_BYTES-byte limit this installer accepts. Refusing; nothing was installed." fi if ! release_assets_tsv <"$PREFLIGHT_RAW_INVENTORY" >"$PREFLIGHT_ASSETS" 2>"$PREFLIGHT_TMP/reducer.err"; then rm -f "$PREFLIGHT_ASSETS" 2>/dev/null || true fail "the release's asset inventory could not be read as name/size pairs — refusing rather than guessing which asset was meant; nothing was installed" fi # Require two columns: a non-empty asset name and a size written as digits. if ! LC_ALL=C awk -F'\t' 'NF == 0 { next } NF != 2 { exit 1 } $2 !~ /^[0-9]+$/ { exit 1 } $1 == "" { exit 1 }' "$PREFLIGHT_ASSETS"; then fail "the release's asset inventory could not be read as name/size pairs — refusing rather than guessing which asset was meant; nothing was installed" fi } release_require_asset() { _ra_want=$1 _ra_max=$2 _ra_n=$(LC_ALL=C awk -F'\t' -v want="$_ra_want" '$1 == want { c++ } END { print c+0 }' "$PREFLIGHT_ASSETS") if [ "$_ra_n" -eq 0 ]; then release_refuse asset-missing; fi if [ "$_ra_n" -gt 1 ]; then release_refuse asset-ambiguous; fi _ra_size=$(LC_ALL=C awk -F'\t' -v want="$_ra_want" '$1 == want { print $2; exit }' "$PREFLIGHT_ASSETS") case "$_ra_size" in '' | *[!0-9]*) fail "the release advertises no usable size for '$_ra_want'. Refusing to download an asset of unknown size; nothing was installed." ;; esac # Reject numbers too long for the limit before using shell arithmetic. if [ "${#_ra_size}" -gt "${#_ra_max}" ]; then fail "the release advertises '$_ra_want' as $_ra_size bytes, above the $_ra_max-byte limit this installer accepts. Refusing BEFORE downloading it; nothing was installed." fi if [ "$_ra_size" -le 0 ]; then fail "the release advertises '$_ra_want' as a zero-length asset. Refusing to install from an empty artifact; nothing was installed." fi if [ "$_ra_size" -gt "$_ra_max" ]; then fail "the release advertises '$_ra_want' as $_ra_size bytes, above the $_ra_max-byte limit this installer accepts. Refusing BEFORE downloading it; nothing was installed." fi PREFLIGHT_ASSET_SIZE="$_ra_size" } # Read a repository file anonymously, enforcing response and size checks. release_read_repo_file() { _rf_path=$1 _rf_dest=$2 _rf_max=$3 rm -f "$_rf_dest" 2>/dev/null || true release_api_get "$RELEASE_API_ORIGIN/repos/$PRIVATE_RELEASE_REPO/contents/$_rf_path?ref=$PRIVATE_RELEASE_BRANCH" \ "$GH_RAW_ACCEPT_HEADER" "$PREFLIGHT_TMP/repofile.out" "$_rf_max" || { rm -f "$PREFLIGHT_TMP/repofile.out" 2>/dev/null || true return 1 } if [ "$RELEASE_HTTP_STATUS" != "200" ]; then rm -f "$PREFLIGHT_TMP/repofile.out" 2>/dev/null || true return 1 fi _rf_bytes=$(wc -c <"$PREFLIGHT_TMP/repofile.out" 2>/dev/null | awk '{print $1+0}') [ -n "$_rf_bytes" ] || _rf_bytes=0 if [ "$_rf_bytes" -le 0 ]; then rm -f "$PREFLIGHT_TMP/repofile.out" 2>/dev/null || true fail "the LStack release document '$_rf_path' was served empty by $PRIVATE_RELEASE_REPO. Refusing; nothing was installed." fi if [ "$_rf_bytes" -gt "$_rf_max" ]; then rm -f "$PREFLIGHT_TMP/repofile.out" 2>/dev/null || true fail "the LStack release document '$_rf_path' is $_rf_bytes bytes, above the $_rf_max-byte limit this installer accepts. Refusing; nothing was installed." fi mv "$PREFLIGHT_TMP/repofile.out" "$_rf_dest" || return 1 } # Resolve the release document and required assets over anonymous HTTPS. PREFLIGHT_ARTIFACT="" PREFLIGHT_SUMS="" if [ "$TRANSPORT" = gh ]; then _tmpbase=${TMPDIR:-/tmp} _tmpbase=$(normalize_path "$_tmpbase") _tmp_why=$(path_reject_reason "$_tmpbase") [ -z "$_tmp_why" ] || fail "the temporary directory ('$_tmpbase') $_tmp_why. Set TMPDIR to a usable directory and rerun; nothing was installed." PREFLIGHT_TMP=$(umask 077 && mktemp -d "$_tmpbase/lstack-preflight.XXXXXXXX") || fail "could not create a temporary directory for the release preflight" # shellcheck disable=SC2329 preflight_cleanup() { if [ -n "${PREFLIGHT_TMP:-}" ]; then rm -rf "$PREFLIGHT_TMP" 2>/dev/null || true; fi } trap 'preflight_cleanup' EXIT trap 'trap - EXIT INT TERM; preflight_cleanup; echo "install.sh: interrupted — nothing was installed." >&2; exit 130' INT trap 'trap - EXIT INT TERM; preflight_cleanup; echo "install.sh: terminated — nothing was installed." >&2; exit 143' TERM # Resolve the version from the stable release document, or stop on failure. release_read_repo_file "$STABLE_DOCUMENT_PATH" "$PREFLIGHT_TMP/channel.json" "$MAX_METADATA_BYTES" || release_refuse "$(http_classify "$RELEASE_HTTP_STATUS" document)" # Extract and validate the version without executing the document. VERSION=$(sed -n 's/.*"latest"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PREFLIGHT_TMP/channel.json" | head -n 1) case "$VERSION" in "") fail "could not resolve a version from the LStack release document ($PRIVATE_RELEASE_REPO $PRIVATE_RELEASE_BRANCH:$STABLE_DOCUMENT_PATH) — nothing was installed" ;; *[!0-9A-Za-z.+-]*) fail "unusable version string (illegal characters) — nothing was installed" ;; [!0-9A-Za-z]*) fail "unusable version string (must start alphanumeric) — nothing was installed" ;; esac assert_strict_semver "$VERSION" "the LStack release document named a version that" PREFLIGHT_ARTIFACT_NAME="lstack-$VERSION-$OS-$ARCH.tar.gz" release_load_asset_inventory "v$VERSION" release_require_asset "$PREFLIGHT_ARTIFACT_NAME" "$MAX_ARTIFACT_BYTES" ARTIFACT_ADVERTISED_BYTES="$PREFLIGHT_ASSET_SIZE" release_require_asset "$PREFLIGHT_ARTIFACT_NAME.sha256" "$MAX_METADATA_BYTES" echo "install.sh: fetching $PREFLIGHT_ARTIFACT_NAME from the LStack beta release ($PRIVATE_RELEASE_REPO)" >&2 PREFLIGHT_ARTIFACT="$PREFLIGHT_TMP/$PREFLIGHT_ARTIFACT_NAME" PREFLIGHT_SUMS="$PREFLIGHT_ARTIFACT.sha256" fetch "$(artifact_asset_url "$PREFLIGHT_ARTIFACT_NAME")" "$PREFLIGHT_ARTIFACT" "$MAX_ARTIFACT_BYTES" || release_refuse "$(http_classify "" asset)" _art_got=$(wc -c <"$PREFLIGHT_ARTIFACT" 2>/dev/null | awk '{print $1+0}') [ -n "$_art_got" ] || _art_got=0 if [ "$_art_got" -ne "$ARTIFACT_ADVERTISED_BYTES" ]; then fail "the release advertised $PREFLIGHT_ARTIFACT_NAME as $ARTIFACT_ADVERTISED_BYTES bytes but delivered $_art_got. Refusing; nothing was installed." fi fetch "$(artifact_asset_url "$PREFLIGHT_ARTIFACT_NAME.sha256")" "$PREFLIGHT_SUMS" "$MAX_METADATA_BYTES" || release_refuse "$(http_classify "" asset)" verify_archive_checksum "$PREFLIGHT_ARTIFACT" "$PREFLIGHT_SUMS" echo "install.sh: SHA-256 verified" >&2 validate_archive_policy "$PREFLIGHT_ARTIFACT" "$PREFLIGHT_TMP" mkdir "$PREFLIGHT_TMP/boot" || fail "could not create the preflight inspection directory" (cd "$PREFLIGHT_TMP/boot" && tar -xzf "$PREFLIGHT_ARTIFACT" bin/node cli manifest.json) || fail "this artifact is not an LStack bundle (its bin/node, cli/ or manifest.json could not be read) — nothing was installed" _PNODE="$PREFLIGHT_TMP/boot/bin/node" [ -f "$_PNODE" ] && [ ! -h "$_PNODE" ] && [ -x "$_PNODE" ] || fail "this artifact is not an LStack bundle (bin/node is missing, a symlink, or not executable) — nothing was installed" _PGATE="$PREFLIGHT_TMP/boot/cli/lib/verify.js" [ -f "$_PGATE" ] && [ ! -h "$_PGATE" ] || fail "this artifact predates the release-maturity gate (cli/lib/verify.js is missing) and cannot prove it may be distributed — nothing was installed" _PCHANNEL="$PREFLIGHT_TMP/channel.json" [ -f "$_PCHANNEL" ] || _PCHANNEL="-" "$_PNODE" --no-warnings -e ' const { pathToFileURL } = require("node:url"); import(pathToFileURL(process.argv[1]).href) .then((m) => m.runInstallGateCli(process.argv.slice(2))) .then((code) => process.exit(code)) .catch((err) => { console.error(String((err && err.message) || err)); process.exit(1); }); ' "$_PGATE" gate "$PREFLIGHT_TMP/boot/manifest.json" "$_PCHANNEL" "$CHANNEL" "$VERSION" "$OS" "$ARCH" private-release || fail "this release was refused before anything was installed — see the reason above; nothing was installed" rm -rf "${PREFLIGHT_TMP:?}/boot" 2>/dev/null || true fi # Capture existing state for rollback. exists() { [ -e "$1" ] || [ -h "$1" ]; } b() { if exists "$1"; then echo 1; else echo 0; fi; } PRE_INSTALL_ROOT=$(b "$INSTALL_ROOT") PRE_DATA_DIR=$(b "$DATA_DIR") PRE_EXEC_DATA_DIR=$(b "$EXEC_DATA_DIR") PRE_CONFIG_DIR=$(b "$CONFIG_DIR") PRE_PROJECTS=$(b "$PROJECTS_ROOT") PRE_VERSIONS=$(b "$INSTALL_ROOT/versions") PRE_BIN=$(b "$INSTALL_ROOT/bin") PRE_CURRENT=$(b "$INSTALL_ROOT/current") PRE_CURRENT_TARGET="" [ "$PRE_CURRENT" = 1 ] && PRE_CURRENT_TARGET=$(readlink "$INSTALL_ROOT/current" 2>/dev/null || true) PRE_SHIM=$(b "$INSTALL_ROOT/bin/lstack") PRE_MANIFEST=$(b "$MANIFEST_FILE") PRE_INSTALL_JSON=$(b "$INSTALL_JSON") PRE_SECRET=$(b "$SECRET_FILE") COMMITTED=0 DID_PUBLISH=0 VERSION_BAK="" DID_SWITCH=0 DID_SHIM=0 SHIM_BAK="" DID_FINALIZE_ATTEMPTED=0 STAGING="" LOCK_DIR="" LOCK_HELD=0 # shellcheck disable=SC2329 rollback() { if [ "$DID_FINALIZE_ATTEMPTED" = 1 ]; then if [ "$PRE_MANIFEST" = 1 ] && [ -n "${MANIFEST_BAK:-}" ] && [ -f "${MANIFEST_BAK:-}" ]; then cp -p "$MANIFEST_BAK" "$MANIFEST_FILE" 2>/dev/null || true elif [ "$PRE_MANIFEST" = 0 ]; then rm -f "$MANIFEST_FILE" 2>/dev/null || true fi fi if [ "$DID_SHIM" = 1 ]; then if [ "$PRE_SHIM" = 1 ] && [ -n "${SHIM_BAK:-}" ] && [ -f "${SHIM_BAK:-}" ]; then cp -p "$SHIM_BAK" "$INSTALL_ROOT/bin/lstack" 2>/dev/null || true elif [ "$PRE_SHIM" = 0 ]; then rm -f "$INSTALL_ROOT/bin/lstack" 2>/dev/null || true fi fi if [ "$DID_SWITCH" = 1 ]; then rm -f "$INSTALL_ROOT/current" 2>/dev/null || true if [ "$PRE_CURRENT" = 1 ] && [ -n "$PRE_CURRENT_TARGET" ]; then ln -s "$PRE_CURRENT_TARGET" "$INSTALL_ROOT/current" 2>/dev/null || true fi fi if [ "$DID_PUBLISH" = 1 ]; then rm -rf "$DEST" 2>/dev/null || true if [ -n "$VERSION_BAK" ] && exists "$VERSION_BAK"; then mv "$VERSION_BAK" "$DEST" 2>/dev/null || true fi fi [ "$PRE_INSTALL_JSON" = 0 ] && rm -f "$INSTALL_JSON" 2>/dev/null || true [ "$PRE_SECRET" = 0 ] && rm -f "$SECRET_FILE" 2>/dev/null || true [ "$PRE_CONFIG_DIR" = 0 ] && rm -rf "$CONFIG_DIR" 2>/dev/null || true [ "$PRE_PROJECTS" = 0 ] && rm -rf "$PROJECTS_ROOT" 2>/dev/null || true [ "$PRE_EXEC_DATA_DIR" = 0 ] && rm -rf "$EXEC_DATA_DIR" 2>/dev/null || true [ "$PRE_DATA_DIR" = 0 ] && rm -rf "$DATA_DIR" 2>/dev/null || true [ "$PRE_BIN" = 0 ] && rmdir "$INSTALL_ROOT/bin" 2>/dev/null || true [ "$PRE_VERSIONS" = 0 ] && rmdir "$INSTALL_ROOT/versions" 2>/dev/null || true [ -n "$STAGING" ] && rm -rf "$STAGING" 2>/dev/null || true } discard_version_backup() { if [ -n "$VERSION_BAK" ] && exists "$VERSION_BAK"; then rm -rf "$VERSION_BAK" 2>/dev/null || true fi } # shellcheck disable=SC2329 release_lock() { if [ "$LOCK_HELD" = 1 ] && [ -n "$LOCK_DIR" ]; then rm -f "$LOCK_DIR/pid" "$LOCK_DIR/owner" 2>/dev/null || true rmdir "$LOCK_DIR" 2>/dev/null || true LOCK_HELD=0 fi } # shellcheck disable=SC2329 run_cleanup() { if [ "$COMMITTED" = 1 ]; then [ -n "$STAGING" ] && rm -rf "$STAGING" 2>/dev/null || true discard_version_backup else rollback fi if [ -n "${PREFLIGHT_TMP:-}" ]; then rm -rf "$PREFLIGHT_TMP" 2>/dev/null || true; fi release_lock if [ "$COMMITTED" != 1 ] && [ "$PRE_INSTALL_ROOT" = 0 ]; then rmdir "$INSTALL_ROOT" 2>/dev/null || true fi } # shellcheck disable=SC2329 on_exit() { _exit_status=$? trap - EXIT INT TERM run_cleanup if [ "$COMMITTED" != 1 ] && [ "$_exit_status" -eq 0 ]; then echo "install.sh: ERROR: the installer ended before the install committed — nothing was installed." >&2 _exit_status=1 fi exit "$_exit_status" } # shellcheck disable=SC2329 on_signal() { trap - EXIT INT TERM echo "install.sh: $1 — rolling back; nothing was left half-installed." >&2 run_cleanup exit "$2" } acquire_lock() { if mkdir "$LOCK_DIR" 2>/dev/null; then LOCK_HELD=1 printf '%s\n' "$$" >"$LOCK_DIR/pid" 2>/dev/null || true printf '%s\n' "lstack-install.sh" >"$LOCK_DIR/owner" 2>/dev/null || true return 0 fi _lk_owner="" _lk_pid="" if [ -f "$LOCK_DIR/owner" ]; then _lk_owner=$(head -n 1 "$LOCK_DIR/owner" 2>/dev/null || true); fi if [ -f "$LOCK_DIR/pid" ]; then _lk_pid=$(head -n 1 "$LOCK_DIR/pid" 2>/dev/null || true); fi case "$_lk_pid" in '' | *[!0-9]*) _lk_pid="" ;; esac if [ "$_lk_owner" = "lstack-install.sh" ] && [ -n "$_lk_pid" ] && ! kill -0 "$_lk_pid" 2>/dev/null; then rm -f "$LOCK_DIR/pid" "$LOCK_DIR/owner" 2>/dev/null || true rmdir "$LOCK_DIR" 2>/dev/null || true if mkdir "$LOCK_DIR" 2>/dev/null; then LOCK_HELD=1 printf '%s\n' "$$" >"$LOCK_DIR/pid" 2>/dev/null || true printf '%s\n' "lstack-install.sh" >"$LOCK_DIR/owner" 2>/dev/null || true return 0 fi fi return 1 } lock_refusal() { trap - EXIT INT TERM if [ -n "${PREFLIGHT_TMP:-}" ]; then rm -rf "$PREFLIGHT_TMP" 2>/dev/null || true; fi [ "$PRE_INSTALL_ROOT" = 0 ] && rmdir "$INSTALL_ROOT" 2>/dev/null || true echo "install.sh: ERROR: another LStack installer is already running for $INSTALL_ROOT" >&2 echo " Wait for it to finish and run this installer again." >&2 echo " Nothing was installed, and nothing belonging to the other run was changed." >&2 echo " If you are certain no installer is running, remove $LOCK_DIR and retry." >&2 exit 1 } # Take the per-install-root lock and begin the transactional installation. umask 077 trap 'on_exit' EXIT trap 'on_signal interrupted 130' INT trap 'on_signal terminated 143' TERM mkdir -p "$INSTALL_ROOT" || fail "could not create the install root at $INSTALL_ROOT" LOCK_DIR="$INSTALL_ROOT/.install-lock" acquire_lock || lock_refusal mkdir -p "$DATA_DIR/db" "$DATA_DIR/secrets" "$EXEC_DATA_DIR" || fail "could not create install directories" STAGING=$(umask 077 && mktemp -d "$INSTALL_ROOT/.staging-install.XXXXXXXX") || fail "could not create staging directory" RUN_TOKEN=${STAGING##*/.staging-install.} for _stale in "$INSTALL_ROOT"/.staging-install.*; do [ -e "$_stale" ] && [ "$_stale" != "$STAGING" ] && rm -rf "$_stale" 2>/dev/null || true done if [ -d "$INSTALL_ROOT/versions" ]; then for _stalebak in "$INSTALL_ROOT"/versions/.bak-*; do [ -e "$_stalebak" ] || continue _sb_name=${_stalebak##*/} _sb_ver=${_sb_name#.bak-} _sb_ver=${_sb_ver%.*} [ -n "$_sb_ver" ] || continue if exists "$INSTALL_ROOT/versions/$_sb_ver"; then rm -rf "$_stalebak" 2>/dev/null || true else mv "$_stalebak" "$INSTALL_ROOT/versions/$_sb_ver" 2>/dev/null || true fi done fi fault after-dirs if [ -n "$PREFLIGHT_ARTIFACT" ]; then ARTIFACT="lstack-$VERSION-$OS-$ARCH.tar.gz" mv "$PREFLIGHT_ARTIFACT" "$STAGING/$ARTIFACT" || fail "could not stage the verified artifact" mv "$PREFLIGHT_SUMS" "$STAGING/$ARTIFACT.sha256" || fail "could not stage the verified checksum" if [ -f "$PREFLIGHT_TMP/channel.json" ]; then mv "$PREFLIGHT_TMP/channel.json" "$STAGING/channel.json" || true fi rm -rf "$PREFLIGHT_TMP" 2>/dev/null || true PREFLIGHT_TMP="" PREFLIGHT_ARTIFACT="" PREFLIGHT_SUMS="" fault after-download verify_archive_checksum "$STAGING/$ARTIFACT" "$STAGING/$ARTIFACT.sha256" fault after-verify else # Resolve the version for a custom release source. if [ -z "$VERSION" ]; then fetch "$(channel_doc_url)" "$STAGING/channel.json" "$MAX_METADATA_BYTES" || fail "could not download the release document from $(channel_doc_url) — nothing was installed" VERSION=$(sed -n 's/.*"latest"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$STAGING/channel.json" | head -n 1) fi case "$VERSION" in "") fail "could not resolve a version from the release document at $(channel_doc_url)" ;; *[!0-9A-Za-z.+-]*) fail "unusable version string (illegal characters)" ;; [!0-9A-Za-z]*) fail "unusable version string (must start alphanumeric)" ;; esac # Apply the same version validation used for official releases. assert_strict_semver "$VERSION" "the release document named a version that" ARTIFACT="lstack-$VERSION-$OS-$ARCH.tar.gz" ARTIFACT_URL="$(artifact_asset_url "$ARTIFACT")" echo "install.sh: downloading $ARTIFACT_URL" >&2 fetch "$ARTIFACT_URL" "$STAGING/$ARTIFACT" "$MAX_ARTIFACT_BYTES" || fail "download failed for $ARTIFACT_URL — nothing was installed" fetch "$ARTIFACT_URL.sha256" "$STAGING/$ARTIFACT.sha256" "$MAX_METADATA_BYTES" || fail "download failed for the checksum $ARTIFACT_URL.sha256 — nothing was installed" fault after-download verify_archive_checksum "$STAGING/$ARTIFACT" "$STAGING/$ARTIFACT.sha256" echo "install.sh: SHA-256 verified" >&2 fault after-verify fi validate_archive_policy "$STAGING/$ARTIFACT" "$STAGING" mkdir "$STAGING/bootstrap" (cd "$STAGING/bootstrap" && tar -xzf "$STAGING/$ARTIFACT" bin/node cli) || fail "bootstrap extraction failed — refused; nothing was installed" NODE="$STAGING/bootstrap/bin/node" [ -f "$NODE" ] || fail "archive is not an LStack bundle (missing bin/node)" [ ! -h "$NODE" ] || fail "bin/node is a symlink — refused" [ -x "$NODE" ] || fail "bin/node is not executable — refused" ARCHIVE_JS="$STAGING/bootstrap/cli/lib/archive.js" [ -f "$ARCHIVE_JS" ] || fail "archive is not an LStack bundle (missing cli/lib/archive.js)" [ ! -h "$ARCHIVE_JS" ] || fail "cli/lib/archive.js is a symlink — refused" fault before-extract "$NODE" --no-warnings -e ' const { pathToFileURL } = require("node:url"); import(pathToFileURL(process.argv[1]).href) .then((m) => m.runArchiveCli(["extract", process.argv[2], process.argv[3]])) .then((code) => process.exit(code)) .catch((err) => { console.error(String((err && err.message) || err)); process.exit(1); }); ' "$ARCHIVE_JS" "$STAGING/$ARTIFACT" "$STAGING/tree" || fail "safe extraction refused the archive — nothing was installed" fault after-extract # Publish the version and switch the active install atomically. mkdir -p "$INSTALL_ROOT/versions" "$INSTALL_ROOT/bin" DEST="$INSTALL_ROOT/versions/$VERSION" fault before-publish if exists "$DEST"; then VERSION_BAK="$INSTALL_ROOT/versions/.bak-$VERSION.$RUN_TOKEN" mv "$DEST" "$VERSION_BAK" || fail "could not stage the previous version for replacement" fi mv "$STAGING/tree" "$DEST" || fail "could not publish the new version" DID_PUBLISH=1 fault after-publish ln -s "versions/$VERSION" "$STAGING/current.new" fault before-switch "$NODE" -e 'require("node:fs").renameSync(process.argv[1], process.argv[2])' \ "$STAGING/current.new" "$INSTALL_ROOT/current" || fail "could not switch 'current'" DID_SWITCH=1 # Install the lstack command shim. fault before-shim NODE="$INSTALL_ROOT/current/bin/node" if [ "$PRE_SHIM" = 1 ]; then SHIM_BAK="$STAGING/lstack.shim.bak" cp -p "$INSTALL_ROOT/bin/lstack" "$SHIM_BAK" 2>/dev/null || true fi cat >"$STAGING/lstack.shim" </dev/null || true fi DID_FINALIZE_ATTEMPTED=1 HOME="$LSTACK_EFFECTIVE_HOME" "$NODE" --no-warnings -e ' const { pathToFileURL } = require("node:url"); import(pathToFileURL(process.argv[1]).href) .then((m) => m.runInstallerFinalize(process.argv.slice(2))) .then((code) => process.exit(code)) .catch((err) => { console.error(String((err && err.message) || err)); process.exit(1); }); ' "$FINALIZE_JS" \ --install-root "$INSTALL_ROOT" --version "$VERSION" \ --shim-path "$INSTALL_ROOT/bin/lstack" --profile-line "$PROFILE_LINE" \ >"$STAGING/finalize.out" || fail "install finalize failed (config/secrets/manifest) — rolled back" SUMMARY=$(sed -n 's/^lstack-finalize: //p' "$STAGING/finalize.out" | head -n 1) PORT=$(printf '%s' "$SUMMARY" | sed -n 's/.*"port":[[:space:]]*\([0-9][0-9]*\).*/\1/p') [ -n "$PORT" ] || PORT=4700 DASHBOARD_URL="http://127.0.0.1:$PORT" rm -rf "$STAGING" COMMITTED=1 discard_version_backup # Optionally start LStack before reporting the result. STARTED=0 START_FAILED=0 maybe_start() { if HOME="$LSTACK_EFFECTIVE_HOME" "$INSTALL_ROOT/bin/lstack" start; then STARTED=1 else START_FAILED=1 fi } if [ "$START" = 1 ]; then maybe_start elif [ -t 0 ]; then printf 'Start LStack now? [y/N] ' >&2 read -r REPLY || REPLY="" case "$REPLY" in y | Y | yes | YES) maybe_start ;; *) ;; esac fi DASHBOARD_KIND=configured CORE_STATE_FILE="$DATA_DIR/run/core.json" if [ "$STARTED" = 1 ] && [ -f "$CORE_STATE_FILE" ]; then EFFECTIVE_URL=$(sed -n 's/.*"url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$CORE_STATE_FILE" | head -n 1) case "$EFFECTIVE_URL" in http://127.0.0.1:[0-9] | http://127.0.0.1:[0-9][0-9] | http://127.0.0.1:[0-9][0-9][0-9] | \ http://127.0.0.1:[0-9][0-9][0-9][0-9] | http://127.0.0.1:[0-9][0-9][0-9][0-9][0-9]) DASHBOARD_URL="$EFFECTIVE_URL" DASHBOARD_KIND=live ;; *) ;; esac fi # Report Docker and worker readiness without treating a failed check as an installation failure. READINESS=$(HOME="$LSTACK_EFFECTIVE_HOME" "$INSTALL_ROOT/bin/lstack" setup --installer-summary 2>/dev/null || true) # Read the Docker state, worker readiness and reason from the summary. DOCKER_STATE_KEY=$(printf '%s\n' "$READINESS" | sed -n 's/^LSTACK_DOCKER=//p' | head -n 1) WORKER_STATE=$(printf '%s\n' "$READINESS" | sed -n 's/^LSTACK_WORKERS=//p' | head -n 1) WORKER_REASON=$(printf '%s\n' "$READINESS" | sed -n 's/^LSTACK_WORKERS_REASON=//p' | head -n 1) [ -n "$DOCKER_STATE_KEY" ] || DOCKER_STATE_KEY=unknown [ -n "$WORKER_STATE" ] || WORKER_STATE=unknown case "$DOCKER_STATE_KEY" in running) DOCKER_STATE="installed and running" ;; stopped) DOCKER_STATE="installed but not running (start Docker to use workers and project runtimes)" ;; not-installed) DOCKER_STATE="not installed (workers and project runtimes need Docker; the dashboard runs without it)" ;; *) DOCKER_STATE="unable to check" ;; esac case "$WORKER_STATE" in ready) WORKER_LABEL="ready" ;; blocked) WORKER_LABEL="not ready" ;; *) WORKER_LABEL="unable to check" ;; esac echo "" echo "LStack $VERSION installed." echo "" echo " Install root : $INSTALL_ROOT" echo " Data root : $DATA_DIR" echo " Exec data : $EXEC_DATA_DIR" echo " Projects root: $PROJECTS_ROOT" echo " Docker : $DOCKER_STATE" echo " Workers : $WORKER_LABEL" if [ "$WORKER_STATE" != "ready" ] && [ -n "$WORKER_REASON" ]; then echo " Reason : $WORKER_REASON" fi echo "" if [ "$STARTED" = 1 ] && [ "$DASHBOARD_KIND" = live ]; then echo " Dashboard : $DASHBOARD_URL" elif [ "$STARTED" = 1 ]; then echo " Dashboard : $DASHBOARD_URL (configured address; the running core did not record one)" elif [ "$START_FAILED" = 1 ]; then echo " NOTE : installation SUCCEEDED, but 'lstack start' failed." echo " Your install is intact — retry with: $INSTALL_ROOT/bin/lstack start" echo " Dashboard : $DASHBOARD_URL (configured address, not live yet; loopback only)" else echo " Start it : $INSTALL_ROOT/bin/lstack start" echo " Dashboard : $DASHBOARD_URL (configured address, not live yet; loopback only)" fi echo " Check it : $INSTALL_ROOT/bin/lstack setup" case ":${PATH}:" in *":$INSTALL_ROOT/bin:"*) ;; *) # Identify the shell startup file for PATH guidance without changing it. STARTUP_FILE="" case "${SHELL:-}" in */zsh | zsh) STARTUP_FILE='${ZDOTDIR:-$HOME}/.zshrc' ;; */bash | bash) if [ "$OS" = linux ]; then STARTUP_FILE='$HOME/.bashrc' elif [ -n "${HOME:-}" ]; then # Login bash uses the first existing file in this order. if [ -f "${HOME:-}/.bash_profile" ]; then STARTUP_FILE='$HOME/.bash_profile' elif [ -f "${HOME:-}/.bash_login" ]; then STARTUP_FILE='$HOME/.bash_login' elif [ -f "${HOME:-}/.profile" ]; then STARTUP_FILE='$HOME/.profile' else STARTUP_FILE='$HOME/.bash_profile' fi fi ;; *) ;; esac echo "" echo " ACTION REQUIRED — finish adding lstack to your PATH" echo "" if [ -n "$STARTUP_FILE" ]; then # Suggest a command to add PATH once; only the user running it changes the startup file. PERSIST_CMD="grep -qxF $(shell_sq "$PROFILE_LINE") \"$STARTUP_FILE\" 2>/dev/null || printf '%s\\n' $(shell_sq "$PROFILE_LINE") >> \"$STARTUP_FILE\"" echo " For future terminals, run this once:" echo "" printf ' %s\n' "$PERSIST_CMD" echo "" echo " For this terminal, run:" echo "" printf ' %s\n' "$PROFILE_LINE" echo "" echo " The first affects future terminals; the second affects only this one." else echo " LStack could not identify a supported shell setup, so it names no file." echo " Add this directory to the PATH your shell sets when it starts:" echo "" printf ' %s/bin\n' "$INSTALL_ROOT" echo "" echo " Until then, run LStack by full path:" echo "" printf ' %s/bin/lstack\n' "$INSTALL_ROOT" fi ;; esac if [ "$(uname -s)" = "Linux" ]; then echo "" echo " Worker setup : one more step is needed before workers can run assignments:" echo " $INSTALL_ROOT/bin/lstack setup-confinement" echo " It prints the exact privileged commands it will run and does" echo " nothing without your confirmation. This installer itself ran" echo " no privileged command. Until it is run, the dashboard works" echo " normally but workers accept no assignments and every status" echo " surface says so." fi echo "" echo " Uninstall : $INSTALL_ROOT/bin/lstack uninstall (your data and projects are preserved)" echo "" [ "$START_FAILED" = 1 ] && exit 20 exit 0