#!/usr/bin/env bash set -euo pipefail # AdaL CLI Installer # Usage: curl -fsSL https://adal.sylph.ai/install.sh | bash # latest stable # curl -fsSL https://adal.sylph.ai/install.sh | bash -s -- --version beta # latest beta # curl -fsSL https://adal.sylph.ai/install.sh | bash -s -- --version 1.0.2-beta.1 # specific version # # Installs to ~/.adal/versions// with symlink at ~/.adal/bin/adal # Supports: macOS (arm64/x64), Linux (x64/arm64), Windows (x64 via Git Bash) APP="adal" RELEASES_URL="https://d35qg8ac0yw4p7.cloudfront.net/cli" TRACK_URL="${ADAL_TRACK_URL:-https://adal.sylph.ai/api/installs/track}" # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' MUTED='\033[0;2m' NC='\033[0m' # ─── Parse arguments ────────────────────────────────────────────────────────── usage() { cat < Install a specific version (e.g., 0.6.0-beta.1) --local-tarball Install from a local native tarball (skip download) --no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.) -h, --help Show this help message EOF } requested_version="" no_modify_path=false local_tarball="" while [[ $# -gt 0 ]]; do case "$1" in -h|--help) usage; exit 0 ;; -v|--version) if [[ -n "${2:-}" ]]; then requested_version="$2"; shift 2 else echo -e "${RED}Error: --version requires a version argument${NC}" >&2; exit 1 fi ;; --no-modify-path) no_modify_path=true; shift ;; --local-tarball) if [[ -n "${2:-}" ]]; then local_tarball="$2"; shift 2 else echo -e "${RED}Error: --local-tarball requires a path argument${NC}" >&2; exit 1 fi ;; *) echo -e "${YELLOW}Warning: Unknown option '$1'${NC}" >&2; shift ;; esac done # ─── Platform detection ─────────────────────────────────────────────────────── detect_platform() { local raw_os raw_os=$(uname -s) case "$raw_os" in Darwin*) os="darwin" ;; Linux*) os="linux" ;; MINGW*|MSYS*|CYGWIN*) os="win32" ;; *) echo -e "${RED}Unsupported operating system: $raw_os${NC}" >&2 echo -e "${MUTED}AdaL supports macOS, Linux, and Windows (via Git Bash).${NC}" >&2 exit 1 ;; esac local raw_arch raw_arch=$(uname -m) case "$raw_arch" in x86_64|amd64) arch="x64" ;; arm64|aarch64) arch="arm64" ;; *) echo -e "${RED}Unsupported architecture: $raw_arch${NC}" >&2 exit 1 ;; esac # Detect Rosetta 2: if running x64 under emulation on ARM Mac, use arm64 if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then if [ "$(sysctl -n sysctl.proc_translated 2>/dev/null)" = "1" ]; then arch="arm64" echo -e "${MUTED}Detected Rosetta 2 — using native arm64 binary${NC}" fi fi # Detect musl (Alpine Linux) is_musl=false if [ "$os" = "linux" ]; then if [ -f /etc/alpine-release ]; then is_musl=true elif command -v ldd >/dev/null 2>&1; then if ldd --version 2>&1 | grep -qi musl; then is_musl=true fi fi fi PLATFORM="${os}-${arch}" # On musl-libc Linux (Alpine and similar), pick the musl-native tarball # instead of the glibc one — glibc binaries (including the bundled Bun # runtime) cannot load when /lib64/ld-linux-x86-64.so.2 is absent. if [ "$is_musl" = true ] && [ "$os" = "linux" ]; then PLATFORM="${PLATFORM}-musl" echo -e "${MUTED}Detected musl libc (Alpine and similar) — using ${PLATFORM} tarball${NC}" fi # Validate platform is supported. # NOTE: linux-arm64-musl is intentionally NOT in this list — no CI matrix # entry builds that tarball yet, so accepting it here would yield a # confusing 404 at download time. Re-add once the release pipeline # publishes it. case "$PLATFORM" in darwin-arm64|darwin-x64|linux-x64|linux-x64-musl|linux-arm64|win32-x64) ;; *) echo -e "${RED}Unsupported platform: $PLATFORM${NC}" >&2 echo -e "${MUTED}Supported: macOS (arm64/x64), Linux (x64 glibc/musl, arm64 glibc), Windows (x64)${NC}" >&2 exit 1 ;; esac } # ─── musl runtime deps ──────────────────────────────────────────────────────── # Bun in the musl tarball is dynamically linked against libstdc++ and libgcc_s # (Bun's C++ runtime + GCC's unwinder). Slim Alpine base images (including # python:3.11-alpine, alpine:3.x, and most SWE-bench Alpine variants) don't # ship these by default — exec'ing adal then prints a flood of "Error # loading shared library libstdc++.so.6 / libgcc_s.so.1" messages. Install # them via apk before extracting the tarball. ensure_musl_runtime_deps() { [ "$is_musl" = true ] || return 0 [ "$os" = "linux" ] || return 0 # Skip if both libs are already present. if ldconfig -p 2>/dev/null | grep -q libstdc++.so.6 && \ ldconfig -p 2>/dev/null | grep -q libgcc_s.so.1; then return 0 fi if [ -f /usr/lib/libstdc++.so.6 ] && [ -f /usr/lib/libgcc_s.so.1 ]; then return 0 fi if ! command -v apk >/dev/null 2>&1; then echo -e "${YELLOW}musl detected but apk is not available; cannot auto-install libstdc++/libgcc.${NC}" >&2 echo -e "${MUTED}Please install them manually before running adal.${NC}" >&2 return 0 fi echo -e "${MUTED}Installing libstdc++ and libgcc (required by bundled Bun runtime)...${NC}" if [ "$(id -u 2>/dev/null)" = "0" ]; then apk add --no-cache libstdc++ libgcc >/dev/null 2>&1 || \ echo -e "${YELLOW}apk add libstdc++ libgcc failed; adal may not start.${NC}" >&2 elif command -v sudo >/dev/null 2>&1; then sudo apk add --no-cache libstdc++ libgcc >/dev/null 2>&1 || \ echo -e "${YELLOW}sudo apk add libstdc++ libgcc failed; adal may not start.${NC}" >&2 else echo -e "${YELLOW}Not running as root and sudo not available — please run: apk add libstdc++ libgcc${NC}" >&2 fi } # ─── Download helpers ───────────────────────────────────────────────────────── DOWNLOADER="" detect_downloader() { if command -v curl >/dev/null 2>&1; then DOWNLOADER="curl" elif command -v wget >/dev/null 2>&1; then DOWNLOADER="wget" else echo -e "${RED}Either curl or wget is required but neither is installed${NC}" >&2 exit 1 fi } download_file() { local url="$1" local output="${2:-}" if [ "$DOWNLOADER" = "curl" ]; then if [ -n "$output" ]; then curl -fsSL -o "$output" "$url" else curl -fsSL "$url" fi elif [ "$DOWNLOADER" = "wget" ]; then if [ -n "$output" ]; then wget -q -O "$output" "$url" else wget -q -O - "$url" fi fi } download_with_progress() { local url="$1" local output="$2" local expected_size="$3" # optional, in bytes # Start download in background if [ "$DOWNLOADER" = "curl" ]; then curl -fsSL -o "$output" "$url" & elif [ "$DOWNLOADER" = "wget" ]; then wget -q -O "$output" "$url" & fi local dl_pid=$! # Show progress if we know the expected size if [ -n "$expected_size" ] && [ "$expected_size" -gt 0 ] 2>/dev/null; then local last_pct=-1 while kill -0 "$dl_pid" 2>/dev/null; do if [ -f "$output" ]; then local current_size if [[ "$(uname -s)" == "Darwin" ]]; then current_size=$(stat -f%z "$output" 2>/dev/null || echo 0) else current_size=$(stat -c%s "$output" 2>/dev/null || echo 0) fi local pct=$((current_size * 100 / expected_size)) [ "$pct" -gt 100 ] && pct=100 if [ "$pct" -ne "$last_pct" ]; then printf "\r${MUTED}🌸 Installing ${NC}${APP} ${MUTED}v${VERSION} (%d%%)${NC}" "$pct" >&2 last_pct=$pct fi fi sleep 0.5 done printf "\r${MUTED}🌸 Installing ${NC}${APP} ${MUTED}v${VERSION} (100%%)${NC}\n" >&2 fi wait "$dl_pid" return $? } # ─── Checksum verification ──────────────────────────────────────────────────── verify_checksum() { local file="$1" local expected="$2" local actual if command -v shasum >/dev/null 2>&1; then actual=$(shasum -a 256 "$file" | cut -d' ' -f1) elif command -v sha256sum >/dev/null 2>&1; then actual=$(sha256sum "$file" | cut -d' ' -f1) else echo -e "${YELLOW}Warning: Cannot verify checksum (no shasum or sha256sum)${NC}" >&2 return 0 fi if [ "$actual" != "$expected" ]; then echo -e "${RED}Checksum verification failed!${NC}" >&2 echo -e "${MUTED} Expected: $expected${NC}" >&2 echo -e "${MUTED} Actual: $actual${NC}" >&2 return 1 fi } # ─── JSON parsing (no jq dependency) ────────────────────────────────────────── # Extract a string value from JSON: json_get '{"key":"val"}' "key" → val json_get() { local json="$1" local key="$2" echo "$json" | sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -1 } # Extract a number value from JSON json_get_num() { local json="$1" local key="$2" echo "$json" | sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p" | head -1 } # Extract a platform block from manifest JSON json_get_platform() { local json="$1" local platform="$2" # Extract everything between "platform": { ... } echo "$json" | tr -d '\n' | sed -n "s/.*\"$platform\"[[:space:]]*:[[:space:]]*{\([^}]*\)}.*/\1/p" } # ─── Version resolution ────────────────────────────────────────────────────── resolve_version() { if [ -n "$requested_version" ]; then # Strip leading 'v' if present requested_version="${requested_version#v}" VERSION="$requested_version" # Support channel names (e.g., "beta") — resolve to actual version number if echo "$VERSION" | grep -qE '^[a-z]+$'; then echo -e "${MUTED}Resolving channel '$VERSION'...${NC}" local resolved resolved=$(download_file "$RELEASES_URL/$VERSION" 2>/dev/null | tr -d '[:space:]') if [ -z "$resolved" ]; then echo -e "${RED}Error: Channel '${VERSION}' not found${NC}" >&2 exit 1 fi VERSION="$resolved" else # Verify the version exists on S3 by checking the manifest local http_status if [ "$DOWNLOADER" = "curl" ]; then http_status=$(curl -sI -o /dev/null -w "%{http_code}" "$RELEASES_URL/manifests/manifest-${VERSION}.json") else http_status=$(wget --spider -S "$RELEASES_URL/manifests/manifest-${VERSION}.json" 2>&1 | grep "HTTP/" | tail -1 | awk '{print $2}') fi if [ "$http_status" = "404" ] || [ "$http_status" = "403" ]; then echo -e "${RED}Error: Version ${VERSION} not found${NC}" >&2 exit 1 fi fi else # Fetch latest version from S3 channel pointer file echo -e "${MUTED}Checking for latest version...${NC}" VERSION=$(download_file "$RELEASES_URL/latest" 2>/dev/null | tr -d '[:space:]') if [ -z "$VERSION" ]; then echo -e "${RED}Failed to determine latest version${NC}" >&2 exit 1 fi fi } # ─── Check existing installation ───────────────────────────────────────────── INSTALL_BASE="$HOME/.adal" VERSIONS_DIR="$INSTALL_BASE/versions" BIN_DIR="$INSTALL_BASE/bin" check_existing() { if [ -d "$VERSIONS_DIR/$VERSION" ]; then echo -e "${MUTED}Version $VERSION is already installed${NC}" # Still update symlinks and PATH in case they're broken or missing. setup_symlinks cleanup_old_versions setup_path setup_github_actions_path detect_npm_install print_success exit 0 fi } # ─── Local tarball install ───────────────────────────────────────────────────── install_from_local_tarball() { local tarball="$1" if [ ! -f "$tarball" ]; then echo -e "${RED}Error: Local tarball not found: $tarball${NC}" >&2 exit 1 fi # Extract version from tarball filename: adal--.tar.gz local basename_tar basename_tar=$(basename "$tarball") # Strip extension (.tar.gz or .zip) local stem="${basename_tar%.tar.gz}" stem="${stem%.zip}" # Extract version: adal-- → strip "adal-" prefix, then strip "-" suffix # Platform is the last 2 or 3 dash-separated segments (e.g., linux-x64 or linux-x64-musl) # # -E (extended regex), not basic regex with \(a\|b\): \| alternation in # BRE is a GNU sed extension. macOS ships BSD sed, where \| in a basic # regex is either unsupported or matches literally depending on the # BSD variant, not "or" — the pattern silently failed to strip the # platform suffix on darwin runners, leaving e.g. # "0.0.0-warmup-test.3-darwin-arm64" as VERSION instead of # "0.0.0-warmup-test.3". -E is POSIX and behaves identically on GNU and # BSD sed. Verified locally on this machine's BSD sed: the old pattern # left the platform suffix attached; -E strips it correctly. VERSION=$(echo "$stem" | sed -E 's/^adal-//; s/-(darwin|linux|win32)-.*$//') if [ -z "$VERSION" ]; then echo -e "${RED}Error: Cannot determine version from tarball filename: $basename_tar${NC}" >&2 echo -e "${MUTED}Expected format: adal--.tar.gz${NC}" >&2 exit 1 fi printf "\n${MUTED}🌸 Installing ${NC}${APP} ${MUTED}v${VERSION} (from local tarball)${NC}\n" # Extract mkdir -p "$VERSIONS_DIR" tar -xzf "$tarball" -C "$VERSIONS_DIR" # The tarball extracts to adal-/ — rename to just / if [ -d "$VERSIONS_DIR/adal-$VERSION" ]; then rm -rf "$VERSIONS_DIR/$VERSION" mv "$VERSIONS_DIR/adal-$VERSION" "$VERSIONS_DIR/$VERSION" fi # Verify extraction if [ ! -f "$VERSIONS_DIR/$VERSION/adal-cli.js" ]; then echo -e "${RED}Error: Extraction failed — adal-cli.js not found${NC}" >&2 exit 1 fi } # ─── Main install logic ────────────────────────────────────────────────────── install_version() { printf "\n${MUTED}🌸 Installing ${NC}${APP} ${MUTED}v${VERSION}${NC}" local archive_ext="tar.gz" if [ "$os" = "win32" ]; then archive_ext="zip" fi local filename="adal-${VERSION}-${PLATFORM}.${archive_ext}" local download_url="$RELEASES_URL/${VERSION}/${filename}" # Download manifest for checksum # Fetch manifest silently local manifest_url="$RELEASES_URL/manifests/manifest-${VERSION}.json" local manifest_json manifest_json=$(download_file "$manifest_url" 2>/dev/null || echo "") local expected_checksum="" local expected_size="" if [ -n "$manifest_json" ]; then local platform_block platform_block=$(json_get_platform "$manifest_json" "$PLATFORM") if [ -n "$platform_block" ]; then expected_checksum=$(json_get "$platform_block" "checksum" 2>/dev/null || echo "") expected_size=$(json_get_num "$platform_block" "size" 2>/dev/null || echo "") fi fi # Download tarball local tmp_dir tmp_dir=$(mktemp -d) local archive_path="$tmp_dir/$filename" # Download with single-line progress percentage if ! download_with_progress "$download_url" "$archive_path" "$expected_size"; then echo -e "${RED}Download failed${NC}" >&2 rm -rf "$tmp_dir" exit 1 fi # Verify checksum if [ -n "$expected_checksum" ]; then # Verify checksum silently if ! verify_checksum "$archive_path" "$expected_checksum"; then rm -rf "$tmp_dir" exit 1 fi # Checksum verified silently else echo -e " ${YELLOW}⚠ No checksum available (manifest not found)${NC}" fi # Extract # Extract silently mkdir -p "$VERSIONS_DIR" if [ "$os" = "linux" ]; then tar -xzf "$archive_path" -C "$VERSIONS_DIR" else # macOS and Windows (Git Bash) if command -v tar >/dev/null 2>&1 && [ "$archive_ext" = "tar.gz" ]; then tar -xzf "$archive_path" -C "$VERSIONS_DIR" elif command -v unzip >/dev/null 2>&1; then unzip -q "$archive_path" -d "$VERSIONS_DIR" else echo -e "${RED}Error: No extraction tool available (need tar or unzip)${NC}" >&2 rm -rf "$tmp_dir" exit 1 fi fi # The tarball extracts to adal-/ — rename to just / if [ -d "$VERSIONS_DIR/adal-$VERSION" ]; then # Remove existing version dir if somehow there rm -rf "$VERSIONS_DIR/$VERSION" mv "$VERSIONS_DIR/adal-$VERSION" "$VERSIONS_DIR/$VERSION" fi # Verify extraction if [ ! -f "$VERSIONS_DIR/$VERSION/adal-cli.js" ]; then echo -e "${RED}Error: Extraction failed — adal-cli.js not found${NC}" >&2 rm -rf "$tmp_dir" exit 1 fi # Cleanup rm -rf "$tmp_dir" # Extracted silently # Track after successful extraction — guarantees the row represents a real, # finished install/upgrade, not a failed/aborted attempt. track_install } # ─── Symlink setup ──────────────────────────────────────────────────────────── setup_symlinks() { mkdir -p "$BIN_DIR" # Update 'current' symlink local current_link="$VERSIONS_DIR/current" rm -f "$current_link" ln -s "$VERSION" "$current_link" # Create bin/adal → ../versions/current/adal local bin_target="$BIN_DIR/adal" rm -f "$bin_target" if [ "$os" = "win32" ]; then # On Windows/Git Bash, create a shell wrapper instead of symlink cat > "$bin_target" << 'WINWRAP' #!/usr/bin/env sh SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" exec "$SCRIPT_DIR/../versions/current/adal.cmd" "$@" WINWRAP chmod +x "$bin_target" else ln -s "../versions/current/adal" "$bin_target" fi setup_immediate_path_shim # Symlinks created silently } # ─── Backend warm-up ────────────────────────────────────────────────────────── # Fire-and-forget: launch the just-installed `adal` CLI once, for real, # fully backgrounded and disowned so install.sh returns and prints success # immediately — the user never perceives this as part of the install — # then kill it a flat 30s later. Never blocks install.sh, never fails # the install. # # ROOT CAUSE (measured, not inferred — see the numbers below): the OS # malware scanner scans this 105MB / 233-file bundle the FIRST time a given # copy of it is executed, and caches the result against that file's identity # (inode). A freshly installed/extracted bundle is always a new identity, so # that full scan lands squarely on the user's first `adal` run. On macOS the # scanner is XProtect; on Windows it's Defender real-time protection. # # It is NOT page cache, and it is NOT code-signature validation — both were # ruled out empirically on a real machine (M4 Pro, macOS 26.2): # # * `cp -R` writes through the page cache, so a fresh copy's data is # already resident before it is ever executed. First execution of that # already-cached copy STILL took 7.2-11.6s, vs 0.87s for every # subsequent execution of the same inode. Page cache cannot explain a # penalty that survives the data being cached. # * Sampling the security daemons during one 7.8s cold run: # XprotectService peak 91.4% CPU, ~4.66 CPU-seconds # amfid (codesign) peak 0.6% CPU, ~0.02 CPU-seconds # adal-backend peak 49.7% CPU, ~0.62 CPU-seconds # XProtect dominates; code signing is noise. # # Effect of this function, measured as a real A/B on that same machine # (fresh copy = fresh install; then time the user's first launch): # without warm-up: 10490ms <- matches the 10-14s users report # with warm-up: 859ms <- 12.2x faster, 9.6s saved # The ~6s scan cost still happens, but during install, backgrounded, where # nobody is waiting on it. # # NOTE FOR ANYONE VALIDATING THIS IN CI: hosted CI runners do not reproduce # this. Their ephemeral VMs run the malware scanners in a neutered state # (GH macOS runners measured 2039ms "cold" vs 11613ms on a real Mac; GH # Windows runners ship with Defender disabled or the workspace excluded). # Disk-latency and memory-pressure simulations do NOT recreate it either — # they model page cache, which is the wrong mechanism. Validate on a real # machine, using a fresh copy (`cp -R`) to force a new inode. # # Warms the REAL entry point ($VERSIONS_DIR/current/adal, exactly what the # `adal` shim resolves to), not just the raw backend binary — the CLI's own # files need scanning too, and launching the CLI transitively spawns and # therefore also warms the backend, so this covers both in one pass. # # WHY IT MUST ACTUALLY RUN THE PROCESS, not just touch the executable: # measured on the same machine, the scan cost lives in the ~230 supporting # files under backend/adal-backend/_internal, NOT in the main executable. # Warming only the main binary and then handing it a freshly-copied # _internal still cost 7109ms, versus 873ms once everything had been seen. # So a cheaper "just stat/read the exe" warm-up would buy nothing; the # process has to start far enough to load its libraries. # # This also suggests a second, independent fix: the cost scales with what # we ship, so trimming unused dependencies out of _internal would cut # first-launch time for everyone without relying on warm-up at all. # # Do NOT "fix" this by switching to a onefile bundle. That was already # tried and deliberately reversed in 0e59ea553 ("Switch to PyInstaller # --onedir for 7x faster startup", 8000ms -> 1058ms), because onefile # re-extracts the whole archive to a temp dir on EVERY launch — it turns # a one-time first-run cost into a permanent per-launch one. Earlier # commits (44924c0fe and friends) had also moved to onedir to fix Windows # packaging outright. onedir + this warm-up is strictly better than either. # # Ruled out along the way: Gatekeeper *policy* assessment is not the cost. # `spctl --assess --type execute` on a fresh copy returns in 216ms (76ms # cached) — three orders of magnitude below the scan itself. # # Runs REAL interactive mode — not --non-interactive/headless, which is a # different code path that doesn't necessarily read the same files an # interactive session does. Real users run interactive; this warms what # they actually hit. # # Fixed 30s rather than "kill as soon as ready": the measured scan takes # ~6s on a fast M4 Pro, and real-world reports reach 14s — a slower disk, # a corporate laptop running EDR on top of the OS scanner, or an older CPU # all push it higher. 30s is a wide, simple margin so the scan reliably # COMPLETES (a scan that gets killed midway caches nothing and the user # pays it again), rather than racing a readiness signal that doesn't # universally exist across dev/non-dev modes (see the readiness-marker # comment in the function body). Worth 30 background seconds nobody is # waiting on to be certain, rather than cutting it at "ready" and risking # an undercount on a slower machine. # # The process is killed well before a real user would interact with it — # this never listens on a user-facing port, never renders anything a user # sees (it's backgrounded, disowned, and killed before the install # script's own output finishes scrolling). # # Opt-out: ADAL_NO_WARM=1 (mirrors ADAL_NO_TRACK for the install tracker). warm_backend() { [ "${ADAL_NO_WARM:-0}" = "1" ] && return 0 # The real CLI entry point — exactly what the `adal` shim on PATH # resolves to (see create_adal_shim's expected_target above), NOT the # raw backend binary. Warming only the backend binary (an earlier # version of this function) left the CLI's own JS bundle, bundled # assets, and model-catalog cache completely cold — measured gap: a # real 2nd launch (naturally warmed by the 1st) lands around 2-3s, but # backend-only warming only closed part of that, because most of a real # launch's disk reads never belong to the raw backend process at all. local entry_bin="$VERSIONS_DIR/current/adal" [ "$os" = "win32" ] && entry_bin="$VERSIONS_DIR/current/adal.cmd" [ -f "$entry_bin" ] || return 0 [ -x "$entry_bin" ] || chmod +x "$entry_bin" 2>/dev/null || true ( # This is the truest cold-start measurement available anywhere: the # very first execution of these exact freshly-extracted files, on the # real user's real machine, with no CI/synthetic cache tricks needed # to approximate "cold" — it just genuinely is. Logged to a small # persistent file (not stdout — this subshell runs disowned, after # install.sh has already printed success and returned, so nothing is # watching the terminal by the time this finishes) so real first-start # numbers can be collected from real user machines instead of only # ever inferred from CI. local timing_log="$HOME/.adal/warmup-timing.log" mkdir -p "$HOME/.adal" 2>/dev/null || true # One line per install/update, so this only grows on a machine that # reinstalls a lot -- but nothing ever truncates it. Keep the most recent # 200 lines, which is far more history than anyone reads back. if [ -f "$timing_log" ] && [ "$(wc -l < "$timing_log" 2>/dev/null || echo 0)" -gt 200 ]; then tail -n 200 "$timing_log" > "$timing_log.tmp" 2>/dev/null \ && mv "$timing_log.tmp" "$timing_log" 2>/dev/null || true fi local run_log run_log="$(mktemp 2>/dev/null || echo "/tmp/adal-warmup-$$.log")" local py_bin=python3 command -v python3 >/dev/null 2>&1 || py_bin=python local t0_ms t1_ms t0_ms=$("$py_bin" -c 'import time; print(int(time.time()*1000))' 2>/dev/null || echo "") # Real interactive invocation — the same command a real user runs, # not --non-interactive/headless (a different code path that doesn't # necessarily touch the same files an interactive session does). # Stdin from /dev/null so the piped-stdin read # (interactiveRuntime.tsx's `!process.stdin.isTTY` branch) resolves # immediately via EOF instead of blocking; ADAL_DEV_MODE inherited # as-is (default false) so this warms exactly what a real, non-dev # launch touches — no more, no less. # # ADAL_IS_SPAWNED_CHILD=1 is REQUIRED here, not optional: without it, # this session runs the CLI's normal startApp() auto-update check # (index.ts, gated only by !cliConfig.isSpawnedChild — config.ts:479 # reads this exact env var, and it gates nothing else in the CLI, so # setting it only skips the update check, not hydration/backend-spawn/ # rendering). Confirmed via a real CI reproduction: without this, the # warm-up session found a genuinely newer published version, silently # self-updated (nativeUpdate() -> cleanOldVersions(1) deletes the # just-installed version dir it's still running from), and a SECOND # real launch immediately afterward — exactly what a real user's # actual first launch would be, if it races the warm-up window — hit # "This session is outdated — AdaL was updated. Please restart AdaL." # and exited instead of starting normally. A silent background warm-up # must never be able to change what's installed or interfere with a # concurrent real launch. ADAL_DEV_MODE="${ADAL_DEV_MODE:-false}" ADAL_IS_SPAWNED_CHILD=1 \ "$entry_bin" < /dev/null > "$run_log" 2>&1 & local warm_pid=$! # Best-effort readiness signal for the timing log only — never gates # the fixed 30s hold below (kept deliberately flat-sleep, not # race-to-ready; see the rationale above this function). Two possible # markers: ADAL_BACKEND_PORT= (the spawned backend's own announce, # if forwarded to the CLI's stdout) or ADAL_STARTUP_MS (this CLI's own # dev-mode-gated marker, interactiveRuntime.tsx — only present when # ADAL_DEV_MODE=true, which most real installs won't have set). local waited=0 ready=0 died=0 while [ "$waited" -lt 250 ]; do grep -qE "ADAL_BACKEND_PORT=|ADAL_STARTUP_MS" "$run_log" 2>/dev/null && { ready=1; break; } kill -0 "$warm_pid" 2>/dev/null || { died=1; break; } # process died — stop polling, nothing left to hold open sleep 0.1 waited=$((waited + 1)) done if [ "$ready" -eq 1 ] && [ -n "$t0_ms" ]; then t1_ms=$("$py_bin" -c 'import time; print(int(time.time()*1000))' 2>/dev/null || echo "") if [ -n "$t1_ms" ]; then printf 'cold_start_ms=%s platform=%s dev_mode=%s\n' \ "$((t1_ms - t0_ms))" "$os" "${ADAL_DEV_MODE:-false}" >> "$timing_log" 2>/dev/null || true fi else printf 'cold_start_ms=unknown platform=%s dev_mode=%s\n' \ "$os" "${ADAL_DEV_MODE:-false}" >> "$timing_log" 2>/dev/null || true fi rm -f "$run_log" 2>/dev/null || true # Remaining sleep to fill out the original 30s warm-hold window (the # poll above already consumed part of it, in 0.1s ticks up to 25s). # Integer seconds only, rounded up, no awk dependency. Skipped entirely # if the process already died (crash, exec failure) — nothing left to # hold open, and there is no reason to sleep ~30s doing nothing. if [ "$died" -eq 0 ]; then local remaining_s=$(( (300 - waited + 9) / 10 )) [ "$remaining_s" -gt 0 ] && sleep "$remaining_s" fi # Bounded escalation: SIGTERM first, then give it up to 3s to actually # exit before SIGKILL. Plain `kill` + unconditional `wait` (the previous # version) had no upper bound — if the backend doesn't die immediately on # SIGTERM (a graceful-shutdown handler, or a hang mid-warm-up), `wait` # blocks for as long as the process takes, which on a machine where adal # gets reinstalled/updated often (CI runners, dev loops) can leave # orphaned warmup-install-* processes stacking up. # SIGTERM is deliberately first and unescalated for 3s: the CLI catches it # (installSignalHandlers -> exitApp) and tears down its own backend before # exiting, so the normal path needs no tree kill. kill "$warm_pid" 2>/dev/null || true for _ in 1 2 3; do kill -0 "$warm_pid" 2>/dev/null || break sleep 1 done # Only reached if that handler did not finish in 3s. SIGKILL cannot be # caught, so the CLI gets no chance to stop the backend it spawned -- kill # the children first or they outlive us, reparented to init, still holding # a version directory. (Signalling the process group is not an option here: # a background job in a non-interactive shell shares the installer's own # group, so a group kill would take down the installer too.) for _child in $(pgrep -P "$warm_pid" 2>/dev/null); do kill -9 "$_child" 2>/dev/null || true done kill -9 "$warm_pid" 2>/dev/null || true wait "$warm_pid" 2>/dev/null || true ) & disown 2>/dev/null || true } path_contains_dir() { local dir="$1" [[ ":$PATH:" == *":$dir:"* ]] } create_adal_shim() { local target_dir="$1" local shim_path="$target_dir/adal" local expected_target="$VERSIONS_DIR/current/adal" [ -d "$target_dir" ] || return 1 [ -w "$target_dir" ] || return 1 if [ -L "$shim_path" ] && [ "$(readlink "$shim_path" 2>/dev/null || true)" = "$expected_target" ]; then return 2 # Already correct; no user-visible work happened. fi # Avoid clobbering unrelated real files. Symlinks are safe to refresh because # previous npm/native installs commonly expose commands as symlinks. if [ -e "$shim_path" ] && [ ! -L "$shim_path" ]; then return 1 fi rm -f "$shim_path" ln -s "$expected_target" "$shim_path" return 0 } setup_immediate_path_shim() { [ "$os" != "win32" ] || return 0 # Best-effort same-terminal support for `curl | bash` followed by `adal`. # The installer cannot change the parent shell's PATH, so only use selected # directories that are already in PATH and writable without sudo. local candidates=( "$HOME/.local/bin" "/opt/homebrew/bin" "/usr/local/bin" ) local dir for dir in "${candidates[@]}"; do if path_contains_dir "$dir"; then if create_adal_shim "$dir"; then echo -e " ${GREEN}✅ Made adal available in current PATH via $dir${NC}" return 0 elif [ "$?" -eq 2 ]; then return 0 fi fi done # Still create the user-local shim for future shells that include ~/.local/bin. mkdir -p "$HOME/.local/bin" create_adal_shim "$HOME/.local/bin" || true } # ─── Clean old versions ────────────────────────────────────────────────────── cleanup_old_versions() { local keep=1 local versions=() # List version directories (exclude 'current' symlink and the just-installed version) for dir in "$VERSIONS_DIR"/*/; do [ -d "$dir" ] || continue local name name=$(basename "$dir") [ "$name" = "current" ] && continue [ "$name" = "$VERSION" ] && continue versions+=("$name") done # Remove all old versions (the current $VERSION is already excluded above) if [ ${#versions[@]} -gt 0 ]; then for ver in "${versions[@]}"; do rm -rf "$VERSIONS_DIR/$ver" done fi } # ─── PATH setup ────────────────────────────────────────────────────────────── add_to_path() { local config_file="$1" local command="$2" local legacy_command="${command//\$HOME/$HOME}" if grep -Fxq "$command" "$config_file" 2>/dev/null || \ grep -Fxq "$legacy_command" "$config_file" 2>/dev/null; then return 0 # Already present fi if [ -w "$config_file" ]; then echo "" >> "$config_file" echo "$command" >> "$config_file" echo -e " ${GREEN}✅ Added AdaL to PATH in $config_file${NC}" else echo -e " ${YELLOW}Manually add to $config_file:${NC}" echo -e " $command" fi } setup_path() { if [ "$no_modify_path" = true ]; then return 0 fi # Check if already in PATH if [[ ":$PATH:" == *":$BIN_DIR:"* ]]; then return 0 fi local current_shell current_shell=$(basename "${SHELL:-/bin/sh}") local config_files="" local default_config_file="" case "$current_shell" in fish) config_files="$HOME/.config/fish/config.fish" default_config_file="$HOME/.config/fish/config.fish" ;; zsh) config_files="${ZDOTDIR:-$HOME}/.zshrc" default_config_file="${ZDOTDIR:-$HOME}/.zshrc" ;; bash) config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile" default_config_file="$HOME/.bashrc" ;; ash|sh) config_files="$HOME/.profile" default_config_file="$HOME/.profile" ;; *) config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile" default_config_file="$HOME/.profile" ;; esac # Find the first existing config file. local config_file="" for file in $config_files; do if [ -f "$file" ]; then config_file="$file" break fi done # Fresh machines may not have a shell config yet. Create the default one so # future terminals can find AdaL without requiring manual PATH setup. if [ -z "$config_file" ]; then config_file="$default_config_file" mkdir -p "$(dirname "$config_file")" touch "$config_file" fi case "$current_shell" in fish) add_to_path "$config_file" "fish_add_path \$HOME/.adal/bin" ;; *) add_to_path "$config_file" "export PATH=\"\$HOME/.adal/bin:\$PATH\"" ;; esac } setup_github_actions_path() { if [ -n "${GITHUB_ACTIONS:-}" ] && [ "${GITHUB_ACTIONS}" = "true" ] && [ -n "${GITHUB_PATH:-}" ]; then if ! grep -qx "$BIN_DIR" "$GITHUB_PATH" 2>/dev/null; then echo "$BIN_DIR" >> "$GITHUB_PATH" echo -e " ${GREEN}✅ Added to \$GITHUB_PATH${NC}" fi fi } # ─── Migration detection ───────────────────────────────────────────────────── detect_npm_install() { if command -v adal >/dev/null 2>&1; then local existing_path existing_path=$(which adal 2>/dev/null || command -v adal 2>/dev/null || true) if [ -n "$existing_path" ] && [[ "$existing_path" != *".adal/bin"* ]]; then # Silently note the npm install exists — don't suggest uninstalling # because that can break 'adal' command if ~/.adal/bin isn't in PATH yet. # Both coexist safely; native takes priority after terminal restart. : fi fi } print_success() { echo -e "" echo -e "${GREEN}🌸 AdaL CLI v${VERSION} installed!${NC}" echo -e "" echo -e "${MUTED}Location: ${NC}$BIN_DIR/adal" echo -e "" echo -e "${MUTED}Next: Run ${NC}adal${MUTED} in your working directory${NC}" echo -e "" } # ─── Anonymous install tracking ────────────────────────────────────────────── # Fire-and-forget POST so we can count actual installs/upgrades. # Only called from install_version(), so same-version no-op reinstalls don't # fire (check_existing() exits earlier on that path). Honours ADAL_NO_TRACK=1. # # event_type: # install — first install on this machine (no other versions under $VERSIONS_DIR) # upgrade — some other version was already installed track_install() { [ "${ADAL_NO_TRACK:-0}" = "1" ] && return 0 [ -z "$TRACK_URL" ] && return 0 # Two channels: "stable" = clean semver (X.Y.Z exactly), "beta" = anything else. # Any version with a `-suffix` (-beta, -alpha, -rc, -preview, -dev, etc.) is "beta" # so dev/internal builds can't masquerade as stable in the dashboard. local channel="stable" case "$VERSION" in *-*) channel="beta" ;; esac # install vs upgrade — does any prior version dir exist? local event_type="install" if [ -d "$VERSIONS_DIR" ]; then for dir in "$VERSIONS_DIR"/*/; do [ -d "$dir" ] || continue local name name=$(basename "$dir") [ "$name" = "current" ] && continue [ "$name" = "$VERSION" ] && continue event_type="upgrade" break done fi local body="{\"platform\":\"cli\",\"channel\":\"$channel\",\"event_type\":\"$event_type\",\"version\":\"$VERSION\"}" ( if [ "$DOWNLOADER" = "curl" ]; then curl -fsS -m 2 -X POST -H 'content-type: application/json' \ -d "$body" "$TRACK_URL" >/dev/null 2>&1 || true elif [ "$DOWNLOADER" = "wget" ]; then wget -q -T 2 --header='content-type: application/json' \ --post-data="$body" -O /dev/null "$TRACK_URL" >/dev/null 2>&1 || true fi ) & disown 2>/dev/null || true } # ─── Main ───────────────────────────────────────────────────────────────────── main() { detect_platform ensure_musl_runtime_deps if [ -n "$local_tarball" ]; then # Local tarball mode: skip download, version resolution, and checksum install_from_local_tarball "$local_tarball" else detect_downloader resolve_version check_existing install_version fi setup_symlinks cleanup_old_versions setup_path setup_github_actions_path detect_npm_install warm_backend print_success } main